From f4299cb12ae9279ffd8e0b643ea1e72f4d77a7cc Mon Sep 17 00:00:00 2001 From: RehanSaeed Date: Sat, 11 Jul 2015 11:08:51 +0100 Subject: [PATCH 1/6] New absolute URL methods added to UrlHelper --- src/Microsoft.AspNet.Mvc.Core/IUrlHelper.cs | 48 ++++++++++--- src/Microsoft.AspNet.Mvc.Core/UrlHelper.cs | 74 ++++++++++++++++++--- 2 files changed, 106 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.AspNet.Mvc.Core/IUrlHelper.cs b/src/Microsoft.AspNet.Mvc.Core/IUrlHelper.cs index 6b85af47e3..c83a43196a 100644 --- a/src/Microsoft.AspNet.Mvc.Core/IUrlHelper.cs +++ b/src/Microsoft.AspNet.Mvc.Core/IUrlHelper.cs @@ -58,15 +58,47 @@ public interface IUrlHelper /// The fully qualified or absolute URL. string RouteUrl([NotNull] UrlRouteContext routeContext); + /// /// - /// 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. /// - /// The name of the route that is used to generate the URL. - /// An object that contains the route values. - /// The generated absolute URL. - /// - /// The protocol and host is obtained from the current request. - /// - string Link(string routeName, object values); + /// The URL helper. + /// The name of the action method. + /// The name of the controller. + /// The route values. + /// The absolute URL. + string AbsoluteAction( + string actionName, + string controllerName, + object routeValues = null); + + /// + /// 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. + /// + /// The URL helper. + /// The content path. + /// The absolute URL. + string AbsoluteContent(string contentPath); + + /// + /// 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. + /// + /// The URL helper. + /// The content path. + /// The host name. + /// The absolute URL. + string AbsoluteContent(string contentPath, string host); + + /// + /// Generates a fully qualified URL to the specified route by using the route name and route values. + /// + /// The URL helper. + /// Name of the route. + /// The route values. + /// The absolute URL. + string AbsoluteRouteUrl(string routeName, object routeValues = null); } } diff --git a/src/Microsoft.AspNet.Mvc.Core/UrlHelper.cs b/src/Microsoft.AspNet.Mvc.Core/UrlHelper.cs index 5f787503fc..6c37f49359 100644 --- a/src/Microsoft.AspNet.Mvc.Core/UrlHelper.cs +++ b/src/Microsoft.AspNet.Mvc.Core/UrlHelper.cs @@ -126,15 +126,73 @@ public virtual string Content([NotNull] string contentPath) } /// - public virtual string Link(string routeName, object values) + /// + /// Generates a fully qualified URL to an action method by using the specified action name, controller name and + /// route values. + /// + /// The URL helper. + /// The name of the action method. + /// The name of the controller. + /// The route values. + /// The absolute URL. + public virtual string AbsoluteAction( + string actionName, + string controllerName, + object routeValues = null) { - return RouteUrl(new UrlRouteContext() - { - RouteName = routeName, - Values = values, - Protocol = _httpContext.Request.Scheme, - Host = _httpContext.Request.Host.ToUriComponent() - }); + return Action( + new UrlActionContext() + { + Action = actionName, + Controller = controllerName, + Protocol = _httpContext.Request.Scheme, + Host = _httpContext.Request.Host.Value + }); + } + + /// + /// 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. + /// + /// The URL helper. + /// The content path. + /// The absolute URL. + public virtual string AbsoluteContent(string contentPath) + { + HttpRequest request = _httpContext.Request; + return new Uri(new Uri(request.Scheme + "://" + request.Host.Value), Content(contentPath)).ToString(); + } + + /// + /// 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. + /// + /// The URL helper. + /// The content path. + /// The host name. + /// The absolute URL. + public virtual string AbsoluteContent(string contentPath, string host) + { + return new Uri(new Uri(_httpContext.Request.Scheme + "://" + host), Content(contentPath)).ToString(); + } + + /// + /// Generates a fully qualified URL to the specified route by using the route name and route values. + /// + /// The URL helper. + /// Name of the route. + /// The route values. + /// The absolute URL. + public virtual string AbsoluteRouteUrl(string routeName, object routeValues = null) + { + return RouteUrl( + new UrlRouteContext() + { + RouteName = routeName, + Values = routeValues, + Protocol = _httpContext.Request.Scheme, + Host = _httpContext.Request.Host.Value + }); } private static string GenerateClientUrl([NotNull] PathString applicationPath, From 306b1261fbfe525914bf2e7eff78c9db66324229 Mon Sep 17 00:00:00 2001 From: RehanSaeed Date: Fri, 17 Jul 2015 08:39:22 +0100 Subject: [PATCH 2/6] 405 Method Not Allowed is the more appropriate status code. --- src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs index e6747956da..1a4d84e712 100644 --- a/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs +++ b/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs @@ -27,7 +27,7 @@ protected virtual void HandleNonHttpsRequest(AuthorizationContext filterContext) // 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 { From 0edf9fa10f7bebbcf5a07264e2610858874d3880 Mon Sep 17 00:00:00 2001 From: RehanSaeed Date: Fri, 17 Jul 2015 08:43:54 +0100 Subject: [PATCH 3/6] Revert "405 Method Not Allowed is the more appropriate status code." This reverts commit 306b1261fbfe525914bf2e7eff78c9db66324229. --- src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs index 1a4d84e712..e6747956da 100644 --- a/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs +++ b/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs @@ -27,7 +27,7 @@ protected virtual void HandleNonHttpsRequest(AuthorizationContext filterContext) // body correctly. if (!string.Equals(filterContext.HttpContext.Request.Method, "GET", StringComparison.OrdinalIgnoreCase)) { - filterContext.Result = new HttpStatusCodeResult(StatusCodes.Status405MethodNotAllowed); + filterContext.Result = new HttpStatusCodeResult(StatusCodes.Status403Forbidden); } else { From 47d4fa1c5b898c49693ddda188848c6254b18252 Mon Sep 17 00:00:00 2001 From: RehanSaeed Date: Fri, 17 Jul 2015 08:47:15 +0100 Subject: [PATCH 4/6] 405 Method Not Allowed is the more appropriate status code --- src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs index e6747956da..1a4d84e712 100644 --- a/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs +++ b/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs @@ -27,7 +27,7 @@ protected virtual void HandleNonHttpsRequest(AuthorizationContext filterContext) // 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 { From 7d2848966620522fdd2f2b3af01267893dec5b79 Mon Sep 17 00:00:00 2001 From: RehanSaeed Date: Fri, 17 Jul 2015 08:51:09 +0100 Subject: [PATCH 5/6] Support for canonical URL's with the RedirectToCanonicalUrlAttribute and NoTrailingSlashAttribute filters. --- .../NoTrailingSlashAttribute.cs | 59 +++++ .../RedirectToCanonicalUrlAttribute.cs | 208 ++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 src/Microsoft.AspNet.Mvc.Core/NoTrailingSlashAttribute.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/RedirectToCanonicalUrlAttribute.cs diff --git a/src/Microsoft.AspNet.Mvc.Core/NoTrailingSlashAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/NoTrailingSlashAttribute.cs new file mode 100644 index 0000000000..ad759e2dd9 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/NoTrailingSlashAttribute.cs @@ -0,0 +1,59 @@ +namespace Microsoft.AspNet.Mvc +{ + using System; + + /// + /// 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. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = false)] + public class NoTrailingSlashAttribute : AuthorizationFilterAttribute + { + private const char QueryCharacter = '?'; + private const char SlashCharacter = '/'; + + /// + /// Determines whether a request contains a trailing slash and, if it does, calls the + /// method. + /// + /// An object that encapsulates information that is required in order to use the + /// attribute. + 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); + } + } + } + + /// + /// Handles HTTP requests that have a trailing slash but are not meant to. + /// + /// An object that encapsulates information that is required in order to use the + /// attribute. + protected virtual void HandleTrailingSlashRequest(AuthorizationContext filterContext) + { + filterContext.Result = new HttpNotFoundResult(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/RedirectToCanonicalUrlAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/RedirectToCanonicalUrlAttribute.cs new file mode 100644 index 0000000000..ec5c4b38e7 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/RedirectToCanonicalUrlAttribute.cs @@ -0,0 +1,208 @@ +namespace Microsoft.AspNet.Mvc +{ + using System; + + /// + /// 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). + /// + [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 + + /// + /// Initializes a new instance of the class. + /// + /// If set to true append trailing slashes, otherwise strip trailing + /// slashes. + /// If set to true lower-case all URL's. + public RedirectToCanonicalUrlAttribute( + bool appendTrailingSlash, + bool lowercaseUrls) + { + this.appendTrailingSlash = appendTrailingSlash; + this.lowercaseUrls = lowercaseUrls; + } + + #endregion + + #region Public Properties + + /// + /// Gets a value indicating whether to append trailing slashes. + /// + /// + /// true if appending trailing slashes; otherwise, strip trailing slashes. + /// + public bool AppendTrailingSlash + { + get { return this.appendTrailingSlash; } + } + + /// + /// Gets a value indicating whether to lower-case all URL's. + /// + /// + /// true if lower-casing URL's; otherwise, false. + /// + public bool LowercaseUrls + { + get { return this.lowercaseUrls; } + } + + #endregion + + #region Public Methods + + /// + /// Determines whether the HTTP request contains a non-canonical URL using , + /// if it doesn't calls the method. + /// + /// An object that encapsulates information that is required in order to use the + /// attribute. + /// The parameter is null. + 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 + + /// + /// Determines whether the specified URl is canonical and if it is not, outputs the canonical URL. + /// + /// An object that encapsulates information that is required in order to use the + /// attribute. + /// The canonical URL. + /// true if the URL is canonical, otherwise false. + 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; + } + + /// + /// Handles HTTP requests for URL's that are not canonical. Performs a 301 Permanent Redirect to the canonical URL. + /// + /// An object that encapsulates information that is required in order to use the + /// attribute. + /// The canonical URL. + protected virtual void HandleNonCanonicalRequest(AuthorizationContext context, string canonicalUrl) + { + context.Result = new RedirectResult(canonicalUrl, true); + } + + /// + /// Determines whether the specified action or its controller has the + /// attribute specified. + /// + /// The filter context. + /// true if a attribute is specified, otherwise + /// false. + protected virtual bool HasNoTrailingSlashAttribute(AuthorizationContext filterContext) + { + foreach (IFilterMetadata filter in filterContext.Filters) + { + if (filter is NoTrailingSlashAttribute) + { + return true; + } + } + + return false; + } + + #endregion + } +} \ No newline at end of file From 92ccdd26cfbedb6f3302960498dc843f75203632 Mon Sep 17 00:00:00 2001 From: RehanSaeed Date: Thu, 23 Jul 2015 19:27:42 +0100 Subject: [PATCH 6/6] Returning a 405 Method Not Allowed response is the more appropriate status code when you only want to allow GET requests. --- src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs | 2 +- .../RequireHttpsAttributeTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs index 1a4d84e712..8401f34175 100644 --- a/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs +++ b/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs @@ -24,7 +24,7 @@ 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.Status405MethodNotAllowed); diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/RequireHttpsAttributeTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/RequireHttpsAttributeTests.cs index eb781f7295..8f66c6b77e 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/RequireHttpsAttributeTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/RequireHttpsAttributeTests.cs @@ -120,7 +120,7 @@ public void OnAuthorization_SignalsBadRequestStatusCode_ForNonHttpsAndNonGetRequ // Assert Assert.NotNull(authContext.Result); var result = Assert.IsType(authContext.Result); - Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode); + Assert.Equal(StatusCodes.Status405MethodNotAllowed, result.StatusCode); } [Fact]