Support finding "sibling" pages when using RedirecToPage

Fixes #6083
This commit is contained in:
Pranav K 2017-04-21 10:43:28 -07:00
parent 297196baa0
commit f568d3c2bc
10 changed files with 413 additions and 110 deletions

View File

@ -0,0 +1,57 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
namespace Microsoft.AspNetCore.Mvc.Core.Internal
{
public static class NormalizedRouteValue
{
/// <summary>
/// Gets the case-normalized route value for the specified route <paramref name="key"/>.
/// </summary>
/// <param name="context">The <see cref="ActionContext"/>.</param>
/// <param name="key">The route key to lookup.</param>
/// <returns>The value corresponding to the key.</returns>
/// <remarks>
/// The casing of a route value in <see cref="ActionContext.RouteData"/> is determined by the client.
/// This making constructing paths for view locations in a case sensitive file system unreliable. Using the
/// <see cref="Abstractions.ActionDescriptor.RouteValues"/> to get route values
/// produces consistently cased results.
/// </remarks>
public static string GetNormalizedRouteValue(ActionContext context, string key)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (key == null)
{
throw new ArgumentNullException(nameof(key));
}
if (!context.RouteData.Values.TryGetValue(key, out var routeValue))
{
return null;
}
var actionDescriptor = context.ActionDescriptor;
string normalizedValue = null;
if (actionDescriptor.RouteValues.TryGetValue(key, out var value) &&
!string.IsNullOrEmpty(value))
{
normalizedValue = value;
}
var stringRouteValue = routeValue?.ToString();
if (string.Equals(normalizedValue, stringRouteValue, StringComparison.OrdinalIgnoreCase))
{
return normalizedValue;
}
return stringRouteValue;
}
}
}

View File

@ -0,0 +1,95 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Mvc.Core.Internal
{
public static class ViewEnginePath
{
private const string ParentDirectoryToken = "..";
private static readonly char[] _pathSeparators = new[] { '/', '\\' };
public static string CombinePath(string first, string second)
{
Debug.Assert(!string.IsNullOrEmpty(first));
if (second.StartsWith("/", StringComparison.Ordinal))
{
// "second" is already an app-rooted path. Return it as-is.
return second;
}
string result;
// Get directory name (including final slash) but do not use Path.GetDirectoryName() to preserve path
// normalization.
var index = first.LastIndexOf('/');
Debug.Assert(index >= 0);
if (index == first.Length - 1)
{
// If the first ends in a trailing slash e.g. "/Home/", assume it's a directory.
result = first + second;
}
else
{
result = first.Substring(0, index + 1) + second;
}
return ResolvePath(result);
}
public static string ResolvePath(string path)
{
if (!RequiresPathResolution(path))
{
return path;
}
var pathSegments = new List<StringSegment>();
var tokenizer = new StringTokenizer(path, _pathSeparators);
foreach (var segment in tokenizer)
{
if (segment.Length == 0)
{
// Ignore multiple directory separators
continue;
}
if (segment.Equals(ParentDirectoryToken, StringComparison.Ordinal))
{
if (pathSegments.Count == 0)
{
// Don't resolve the path if we ever escape the file system root. We can't reason about it in a
// consistent way.
return path;
}
pathSegments.RemoveAt(pathSegments.Count - 1);
}
else
{
pathSegments.Add(segment);
}
}
var builder = new StringBuilder();
for (var i = 0; i < pathSegments.Count; i++)
{
var segment = pathSegments[i];
builder.Append('/');
builder.Append(segment.Buffer, segment.Offset, segment.Length);
}
return builder.ToString();
}
private static bool RequiresPathResolution(string path)
{
return path.IndexOf(ParentDirectoryToken, StringComparison.Ordinal) != -1;
}
}
}

View File

@ -1284,6 +1284,20 @@ namespace Microsoft.AspNetCore.Mvc.Core
internal static string FormatNoRoutesMatchedForPage(object p0)
=> string.Format(CultureInfo.CurrentCulture, GetString("NoRoutesMatchedForPage"), p0);
/// <summary>
/// The relative page path '{0}' can only can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page.
/// </summary>
internal static string UrlHelper_RelativePagePathIsNotSupported
{
get => GetString("UrlHelper_RelativePagePathIsNotSupported");
}
/// <summary>
/// The relative page path '{0}' can only can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page.
/// </summary>
internal static string FormatUrlHelper_RelativePagePathIsNotSupported(object p0)
=> string.Format(CultureInfo.CurrentCulture, GetString("UrlHelper_RelativePagePathIsNotSupported"), p0);
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);

View File

@ -403,4 +403,7 @@
<data name="NoRoutesMatchedForPage" xml:space="preserve">
<value>No page named '{0}' matches the supplied values.</value>
</data>
<data name="UrlHelper_RelativePagePathIsNotSupported" xml:space="preserve">
<value>The relative page path '{0}' can only can only be used while executing a Razor Page. Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page.</value>
</data>
</root>

View File

@ -2,6 +2,9 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc.Core;
using Microsoft.AspNetCore.Mvc.Core.Internal;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;
@ -413,7 +416,7 @@ namespace Microsoft.AspNetCore.Mvc
var routeValues = new RouteValueDictionary(values);
var ambientValues = urlHelper.ActionContext.RouteData.Values;
if (pageName == null)
if (string.IsNullOrEmpty(pageName))
{
if (!routeValues.ContainsKey("page") &&
ambientValues.TryGetValue("page", out var value))
@ -423,7 +426,7 @@ namespace Microsoft.AspNetCore.Mvc
}
else
{
routeValues["page"] = pageName;
routeValues["page"] = CalculatePageName(urlHelper.ActionContext, pageName);
}
if (!routeValues.ContainsKey("formaction") &&
@ -440,5 +443,24 @@ namespace Microsoft.AspNetCore.Mvc
host: host,
fragment: fragment);
}
private static object CalculatePageName(ActionContext actionContext, string pageName)
{
Debug.Assert(pageName.Length > 0);
// Paths not qualified with a leading slash are treated as relative to the current page.
if (pageName[0] != '/')
{
var currentPagePath = NormalizedRouteValue.GetNormalizedRouteValue(actionContext, "page");
if (string.IsNullOrEmpty(currentPagePath))
{
// Disallow the use sibling page routing, a Razor page specific feature, from a non-page action.
throw new InvalidOperationException(Resources.FormatUrlHelper_RelativePagePathIsNotSupported(pageName));
}
return ViewEnginePath.CombinePath(currentPagePath, pageName);
}
return pageName;
}
}
}

View File

@ -8,6 +8,7 @@ using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Mvc.Core.Internal;
using Microsoft.AspNetCore.Mvc.Razor.Internal;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Razor.Language;
@ -36,9 +37,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor
private const string ControllerKey = "controller";
private const string PageKey = "page";
private const string ParentDirectoryToken = "..";
private static readonly TimeSpan _cacheExpirationDuration = TimeSpan.FromMinutes(20);
private static readonly char[] _pathSeparators = new[] { '/', '\\' };
private readonly IRazorPageFactoryProvider _pageFactory;
private readonly IRazorPageActivator _pageActivator;
@ -100,41 +99,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor
/// produces consistently cased results.
/// </remarks>
public static string GetNormalizedRouteValue(ActionContext context, string key)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (key == null)
{
throw new ArgumentNullException(nameof(key));
}
object routeValue;
if (!context.RouteData.Values.TryGetValue(key, out routeValue))
{
return null;
}
var actionDescriptor = context.ActionDescriptor;
string normalizedValue = null;
string value;
if (actionDescriptor.RouteValues.TryGetValue(key, out value) &&
!string.IsNullOrEmpty(value))
{
normalizedValue = value;
}
var stringRouteValue = routeValue?.ToString();
if (string.Equals(normalizedValue, stringRouteValue, StringComparison.OrdinalIgnoreCase))
{
return normalizedValue;
}
return stringRouteValue;
}
=> NormalizedRouteValue.GetNormalizedRouteValue(context, key);
/// <inheritdoc />
public RazorPageResult FindPage(ActionContext context, string pageName)
@ -345,59 +310,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor
// path relative to currently-executing view, if any.
// Not yet executing a view. Start in app root.
absolutePath = "/" + pagePath;
}
else
{
// Get directory name (including final slash) but do not use Path.GetDirectoryName() to preserve path
// normalization.
var index = executingFilePath.LastIndexOf('/');
Debug.Assert(index >= 0);
absolutePath = executingFilePath.Substring(0, index + 1) + pagePath;
if (!RequiresPathResolution(pagePath))
{
return absolutePath;
}
return ViewEnginePath.ResolvePath(absolutePath);
}
if (!RequiresPathResolution(pagePath))
{
return absolutePath;
}
var pathSegments = new List<StringSegment>();
var tokenizer = new StringTokenizer(absolutePath, _pathSeparators);
foreach (var segment in tokenizer)
{
if (segment.Length == 0)
{
// Ignore multiple directory separators
continue;
}
if (segment.Equals(ParentDirectoryToken, StringComparison.Ordinal))
{
if (pathSegments.Count == 0)
{
// Don't resolve the path if we ever escape the file system root. We can't reason about it in a
// consistent way.
return absolutePath;
}
pathSegments.RemoveAt(pathSegments.Count - 1);
}
else
{
pathSegments.Add(segment);
}
}
var builder = new StringBuilder();
for (var i = 0; i < pathSegments.Count; i++)
{
var segment = pathSegments[i];
builder.Append('/');
builder.Append(segment.Buffer, segment.Offset, segment.Length);
}
return builder.ToString();
return ViewEnginePath.CombinePath(executingFilePath, pagePath);
}
// internal for tests
@ -585,10 +501,5 @@ namespace Microsoft.AspNetCore.Mvc.Razor
// Though ./ViewName looks like a relative path, framework searches for that view using view locations.
return name.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase);
}
private static bool RequiresPathResolution(string path)
{
return path.IndexOf(ParentDirectoryToken, StringComparison.Ordinal) != -1;
}
}
}

View File

@ -941,7 +941,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
[Theory]
[InlineData(null, null, null, "/", null, "/")]
[InlineData(null, null, null, "/Hello", null, "/Hello" )]
[InlineData(null, null, null, "/Hello", null, "/Hello")]
[InlineData(null, null, null, "Hello", null, "/Hello")]
[InlineData("/", null, null, "", null, "/")]
[InlineData("/hello/", null, null, "/world", null, "/hello/world")]
@ -1052,7 +1052,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
.Callback((UrlRouteContext context) => actual = context);
// Act
urlHelper.Object.Page("TestPage");
urlHelper.Object.Page("/TestPage");
// Assert
urlHelper.Verify();
@ -1062,7 +1062,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
value =>
{
Assert.Equal("page", value.Key);
Assert.Equal("TestPage", value.Value);
Assert.Equal("/TestPage", value.Value);
});
Assert.Null(actual.Host);
Assert.Null(actual.Protocol);
@ -1100,7 +1100,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
.Callback((UrlRouteContext context) => actual = context);
// Act
urlHelper.Object.Page("TestPage", values);
urlHelper.Object.Page("/TestPage", values);
// Assert
urlHelper.Verify();
@ -1115,7 +1115,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
value =>
{
Assert.Equal("page", value.Key);
Assert.Equal("TestPage", value.Value);
Assert.Equal("/TestPage", value.Value);
});
Assert.Null(actual.Host);
Assert.Null(actual.Protocol);
@ -1132,7 +1132,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
.Callback((UrlRouteContext context) => actual = context);
// Act
urlHelper.Object.Page("TestPage", new { id = 13 }, "https");
urlHelper.Object.Page("/TestPage", new { id = 13 }, "https");
// Assert
urlHelper.Verify();
@ -1147,7 +1147,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
value =>
{
Assert.Equal("page", value.Key);
Assert.Equal("TestPage", value.Value);
Assert.Equal("/TestPage", value.Value);
});
Assert.Equal("https", actual.Protocol);
Assert.Null(actual.Host);
@ -1164,7 +1164,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
.Callback((UrlRouteContext context) => actual = context);
// Act
urlHelper.Object.Page("TestPage", new { id = 13 }, "https", "mytesthost");
urlHelper.Object.Page("/TestPage", new { id = 13 }, "https", "mytesthost");
// Assert
urlHelper.Verify();
@ -1179,7 +1179,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
value =>
{
Assert.Equal("page", value.Key);
Assert.Equal("TestPage", value.Value);
Assert.Equal("/TestPage", value.Value);
});
Assert.Equal("https", actual.Protocol);
Assert.Equal("mytesthost", actual.Host);
@ -1196,7 +1196,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
.Callback((UrlRouteContext context) => actual = context);
// Act
urlHelper.Object.Page("TestPage", new { id = 13 }, "https", "mytesthost", "#toc");
urlHelper.Object.Page("/TestPage", new { id = 13 }, "https", "mytesthost", "#toc");
// Assert
urlHelper.Verify();
@ -1211,7 +1211,7 @@ namespace Microsoft.AspNetCore.Mvc.Routing
value =>
{
Assert.Equal("page", value.Key);
Assert.Equal("TestPage", value.Value);
Assert.Equal("/TestPage", value.Value);
});
Assert.Equal("https", actual.Protocol);
Assert.Equal("mytesthost", actual.Host);
@ -1354,14 +1354,134 @@ namespace Microsoft.AspNetCore.Mvc.Routing
});
}
[Theory]
[InlineData("Sibling", "/Dir1/Dir2/Sibling")]
[InlineData("Dir3/Sibling", "/Dir1/Dir2/Dir3/Sibling")]
[InlineData("Dir4/Dir5/Index", "/Dir1/Dir2/Dir4/Dir5/Index")]
public void Page_CalculatesPathRelativeToViewEnginePath_WhenNotRooted(string pageName, string expected)
{
// Arrange
UrlRouteContext actual = null;
var routeData = new RouteData();
var actionContext = GetActionContextForPage("/Dir1/Dir2/About");
var urlHelper = CreateMockUrlHelper(actionContext);
urlHelper.Setup(h => h.RouteUrl(It.IsAny<UrlRouteContext>()))
.Callback((UrlRouteContext context) => actual = context);
// Act
urlHelper.Object.Page(pageName);
// Assert
urlHelper.Verify();
Assert.NotNull(actual);
Assert.Null(actual.RouteName);
Assert.Collection(Assert.IsType<RouteValueDictionary>(actual.Values),
value =>
{
Assert.Equal("page", value.Key);
Assert.Equal(expected, value.Value);
});
}
[Fact]
public void Page_CalculatesPathRelativeToViewEnginePath_ForIndexPagePaths()
{
// Arrange
var expected = "/Dir1/Dir2/Sibling";
UrlRouteContext actual = null;
var actionContext = GetActionContextForPage("/Dir1/Dir2/");
var urlHelper = CreateMockUrlHelper(actionContext);
urlHelper.Setup(h => h.RouteUrl(It.IsAny<UrlRouteContext>()))
.Callback((UrlRouteContext context) => actual = context);
// Act
urlHelper.Object.Page("Sibling");
// Assert
urlHelper.Verify();
Assert.NotNull(actual);
Assert.Null(actual.RouteName);
Assert.Collection(Assert.IsType<RouteValueDictionary>(actual.Values),
value =>
{
Assert.Equal("page", value.Key);
Assert.Equal(expected, value.Value);
});
}
[Fact]
public void Page_CalculatesPathRelativeToViewEnginePath_WhenNotRooted_ForPageAtRoot()
{
// Arrange
var expected = "/SiblingName";
UrlRouteContext actual = null;
var routeData = new RouteData();
var actionContext = new ActionContext
{
ActionDescriptor = new ActionDescriptor
{
RouteValues = new Dictionary<string, string>
{
{ "page", "/Home" },
},
},
RouteData = new RouteData
{
Values =
{
[ "page" ] = "/Home"
},
},
};
var urlHelper = CreateMockUrlHelper(actionContext);
urlHelper.Setup(h => h.RouteUrl(It.IsAny<UrlRouteContext>()))
.Callback((UrlRouteContext context) => actual = context);
// Act
urlHelper.Object.Page("SiblingName");
// Assert
urlHelper.Verify();
Assert.NotNull(actual);
Assert.Null(actual.RouteName);
Assert.Collection(Assert.IsType<RouteValueDictionary>(actual.Values),
value =>
{
Assert.Equal("page", value.Key);
Assert.Equal(expected, value.Value);
});
}
[Fact]
public void Page_Throws_IfRouteValueDoesNotIncludePageKey()
{
// Arrange
var expected = "SiblingName";
UrlRouteContext actual = null;
var routeData = new RouteData();
var actionContext = new ActionContext
{
RouteData = new RouteData(),
};
var urlHelper = CreateMockUrlHelper(actionContext);
urlHelper.Setup(h => h.RouteUrl(It.IsAny<UrlRouteContext>()))
.Callback((UrlRouteContext context) => actual = context);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => urlHelper.Object.Page(expected));
Assert.Equal($"The relative page path '{expected}' can only can only be used while executing a Razor Page. " +
"Specify a root relative path with a leading '/' to generate a URL outside of a Razor Page.", ex.Message);
}
private static Mock<IUrlHelper> CreateMockUrlHelper(ActionContext context = null)
{
if (context == null)
{
context = new ActionContext
{
RouteData = new RouteData(),
};
context = GetActionContextForPage("/Page");
}
var urlHelper = new Mock<IUrlHelper>();
@ -1513,6 +1633,27 @@ namespace Microsoft.AspNetCore.Mvc.Routing
return routeBuilder.Build();
}
private static ActionContext GetActionContextForPage(string page)
{
return new ActionContext
{
ActionDescriptor = new ActionDescriptor
{
RouteValues = new Dictionary<string, string>
{
{ "page", page },
},
},
RouteData = new RouteData
{
Values =
{
[ "page" ] = page
},
},
};
}
private class PassThroughRouter : IRouter
{
public VirtualPathData GetVirtualPath(VirtualPathContext context)

View File

@ -855,6 +855,54 @@ Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary`1[AspNetCore._InjectedP
Assert.Equal(expected, response.Headers.Location.ToString());
}
[Fact]
public async Task RedirectToSibling_Works()
{
// Arrange
var expected = "/Pages/Redirects/Redirect/10";
var response = await Client.GetAsync("/Pages/Redirects/RedirectToSibling/RedirectToRedirect");
// Assert
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal(expected, response.Headers.Location.ToString());
}
[Fact]
public async Task RedirectToSibling_RedirectsToIndexPage_WithoutIndexSegment()
{
// Arrange
var expected = "/Pages/Redirects";
var response = await Client.GetAsync("/Pages/Redirects/RedirectToSibling/RedirectToIndex");
// Assert
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal(expected, response.Headers.Location.ToString());
}
[Fact]
public async Task RedirectToSibling_RedirectsToSubDirectory()
{
// Arrange
var expected = "/Pages/Redirects/SubDir/SubDirPage";
var response = await Client.GetAsync("/Pages/Redirects/RedirectToSibling/RedirectToSubDir");
// Assert
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal(expected, response.Headers.Location.ToString());
}
[Fact]
public async Task RedirectToSibling_RedirectsToParentDirectory()
{
// Arrange
var expected = "/Pages/Conventions/AuthFolder";
var response = await Client.GetAsync("/Pages/Redirects/RedirectToSibling/RedirectToParent");
// Assert
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal(expected, response.Headers.Location.ToString());
}
private async Task AddAntiforgeryHeaders(HttpRequestMessage request)
{
var getResponse = await Client.GetAsync(request.RequestUri);

View File

@ -0,0 +1,11 @@
@page "{formaction?}"
@functions
{
public IActionResult OnGetRedirectToIndex() => RedirectToPage("Index");
public IActionResult OnGetRedirectToRedirect() => RedirectToPage("Redirect", new { id = 10 });
public IActionResult OnGetRedirectToSubDir() => RedirectToPage("SubDir/SubDirPage");
public IActionResult OnGetRedirectToParent() => RedirectToPage("../Conventions/AuthFolder/Index");
}