diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageApplicationModel.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageApplicationModel.cs index dee4f299fd..bdf0654dc0 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageApplicationModel.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageApplicationModel.cs @@ -72,9 +72,22 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels /// /// Gets the path relative to the base path for page discovery. + /// + /// This value is the path of the file without extension, relative to the pages root directory. + /// e.g. the for the file /Pages/Catalog/Antiques.cshtml is /Catalog/Antiques + /// + /// + /// In an area, this value is the path of the file without extension, relative to the pages root directory for the specified area. + /// e.g. the for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage/Accounts. + /// /// public string ViewEnginePath => ActionDescriptor.ViewEnginePath; + /// + /// Gets the area name. + /// + public string AreaName => ActionDescriptor.AreaName; + /// /// Gets the route template for the page. /// diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageConventionCollection.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageConventionCollection.cs index d883dacccf..ba38593f10 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageConventionCollection.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageConventionCollection.cs @@ -48,6 +48,40 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels return Add(new PageApplicationModelConvention(pageName, action)); } + /// + /// Creates and adds an that invokes an action on the + /// for the page with the specified name located in the specified area. + /// + /// The name of area. + /// + /// The page name e.g. /Users/List + /// + /// The page name is the path of the file without extension, relative to the pages root directory for the specified area. + /// e.g. the page name for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage/Accounts. + /// + /// + /// The . + /// The added . + public IPageApplicationModelConvention AddAreaPageApplicationModelConvention( + string areaName, + string pageName, + Action action) + { + if (string.IsNullOrEmpty(areaName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(areaName)); + } + + EnsureValidPageName(pageName); + + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Add(new PageApplicationModelConvention(areaName, pageName, action)); + } + /// /// Creates and adds an that invokes an action on /// instances for all page under the specified folder. @@ -67,6 +101,40 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels return Add(new FolderApplicationModelConvention(folderPath, action)); } + /// + /// Creates and adds an that invokes an action on + /// instances for all pages under the specified area folder. + /// + /// The name of area. + /// + /// The folder path e.g. /Manage/ + /// + /// The folder path is the path of the folder, relative to the pages root directory for the specified area. + /// e.g. the folder path for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage. + /// + /// + /// The . + /// The added . + public IPageApplicationModelConvention AddAreaFolderApplicationModelConvention( + string areaName, + string folderPath, + Action action) + { + if (string.IsNullOrEmpty(areaName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(areaName)); + } + + EnsureValidFolderPath(folderPath); + + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Add(new FolderApplicationModelConvention(areaName, folderPath, action)); + } + /// /// Creates and adds an that invokes an action on the /// for the page with the specified name. @@ -86,6 +154,37 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels return Add(new PageRouteModelConvention(pageName, action)); } + /// + /// Creates and adds an that invokes an action on the + /// for the page with the specified name located in the specified area. + /// + /// The area name. + /// + /// The page name e.g. /Users/List + /// + /// The page name is the path of the file without extension, relative to the pages root directory for the specified area. + /// e.g. the page name for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage/Accounts. + /// + /// + /// The . + /// The added . + public IPageRouteModelConvention AddAreaPageRouteModelConvention(string areaName, string pageName, Action action) + { + if (string.IsNullOrEmpty(areaName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(areaName)); + } + + EnsureValidPageName(pageName); + + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Add(new PageRouteModelConvention(areaName, pageName, action)); + } + /// /// Creates and adds an that invokes an action on /// instances for all page under the specified folder. @@ -105,6 +204,37 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels return Add(new FolderRouteModelConvention(folderPath, action)); } + /// + /// Creates and adds an that invokes an action on + /// instances for all page under the specified area folder. + /// + /// The area name. + /// + /// The folder path e.g. /Manage/ + /// + /// The folder path is the path of the folder, relative to the pages root directory for the specified area. + /// e.g. the folder path for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage. + /// + /// + /// The . + /// The added . + public IPageRouteModelConvention AddAreaFolderRouteModelConvention(string areaName, string folderPath, Action action) + { + if (string.IsNullOrEmpty(areaName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(areaName)); + } + + EnsureValidFolderPath(folderPath); + + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + return Add(new FolderRouteModelConvention(areaName, folderPath, action)); + } + /// /// Removes all instances of the specified type. /// @@ -158,7 +288,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels } } - private TConvention Add(TConvention convention) where TConvention: IPageConvention + private TConvention Add(TConvention convention) where TConvention : IPageConvention { base.Add(convention); return convention; @@ -166,18 +296,26 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels private class PageRouteModelConvention : IPageRouteModelConvention { + private readonly string _areaName; private readonly string _path; private readonly Action _action; public PageRouteModelConvention(string path, Action action) + : this(null, path, action) { + } + + public PageRouteModelConvention(string areaName, string path, Action action) + { + _areaName = areaName; _path = path; _action = action; } public void Apply(PageRouteModel model) { - if (string.Equals(model.ViewEnginePath, _path, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(_areaName, model.AreaName, StringComparison.OrdinalIgnoreCase) && + string.Equals(model.ViewEnginePath, _path, StringComparison.OrdinalIgnoreCase)) { _action(model); } @@ -186,20 +324,26 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels private class FolderRouteModelConvention : IPageRouteModelConvention { + private readonly string _areaName; private readonly string _folderPath; private readonly Action _action; public FolderRouteModelConvention(string folderPath, Action action) + : this(null, folderPath, action) { + } + + public FolderRouteModelConvention(string areaName, string folderPath, Action action) + { + _areaName = areaName; _folderPath = folderPath.TrimEnd('/'); _action = action; } public void Apply(PageRouteModel model) { - var viewEnginePath = model.ViewEnginePath; - - if (PathBelongsToFolder(_folderPath, viewEnginePath)) + if (string.Equals(_areaName, model.AreaName, StringComparison.OrdinalIgnoreCase) && + PathBelongsToFolder(_folderPath, model.ViewEnginePath)) { _action(model); } @@ -208,18 +352,26 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels private class PageApplicationModelConvention : IPageApplicationModelConvention { + private readonly string _areaName; private readonly string _path; private readonly Action _action; public PageApplicationModelConvention(string path, Action action) + : this(null, path, action) { + } + + public PageApplicationModelConvention(string areaName, string path, Action action) + { + _areaName = areaName; _path = path; _action = action; } public void Apply(PageApplicationModel model) { - if (string.Equals(model.ViewEnginePath, _path, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(model.ViewEnginePath, _path, StringComparison.OrdinalIgnoreCase) && + string.Equals(model.AreaName, _areaName, StringComparison.OrdinalIgnoreCase)) { _action(model); } @@ -228,20 +380,26 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels private class FolderApplicationModelConvention : IPageApplicationModelConvention { + private readonly string _areaName; private readonly string _folderPath; private readonly Action _action; public FolderApplicationModelConvention(string folderPath, Action action) + : this(null, folderPath, action) { + } + + public FolderApplicationModelConvention(string areaName, string folderPath, Action action) + { + _areaName = areaName; _folderPath = folderPath.TrimEnd('/'); _action = action; } public void Apply(PageApplicationModel model) { - var viewEnginePath = model.ViewEnginePath; - - if (PathBelongsToFolder(_folderPath, viewEnginePath)) + if (string.Equals(_areaName, model.AreaName, StringComparison.OrdinalIgnoreCase) && + PathBelongsToFolder(_folderPath, model.ViewEnginePath)) { _action(model); } diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs index 277fdad905..8c94ae18a7 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/ApplicationModels/PageRouteModel.cs @@ -21,9 +21,21 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels /// The application relative path of the page. /// The path relative to the base path for page discovery. public PageRouteModel(string relativePath, string viewEnginePath) + : this(relativePath, viewEnginePath, areaName: null) + { + } + + /// + /// Initializes a new instance of . + /// + /// The application relative path of the page. + /// The path relative to the base path for page discovery. + /// The area name. + public PageRouteModel(string relativePath, string viewEnginePath, string areaName) { RelativePath = relativePath ?? throw new ArgumentNullException(nameof(relativePath)); ViewEnginePath = viewEnginePath ?? throw new ArgumentNullException(nameof(viewEnginePath)); + AreaName = areaName; Properties = new Dictionary(); Selectors = new List(); @@ -43,6 +55,7 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels RelativePath = other.RelativePath; ViewEnginePath = other.ViewEnginePath; + AreaName = other.AreaName; Properties = new Dictionary(other.Properties); Selectors = new List(other.Selectors.Select(m => new SelectorModel(m))); @@ -56,12 +69,22 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels /// /// Gets the path relative to the base path for page discovery. + /// + /// This value is the path of the file without extension, relative to the pages root directory. + /// e.g. the for the file /Pages/Catalog/Antiques.cshtml is /Catalog/Antiques + /// + /// + /// In an area, this value is the path of the file without extension, relative to the pages root directory for the specified area. + /// e.g. the for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage/Accounts. + /// /// - /// - /// For area pages, this path is calculated relative to the of the specific area. - /// public string ViewEnginePath { get; } + /// + /// Gets the area name. Will be null for non-area pages. + /// + public string AreaName { get; } + /// /// Stores arbitrary metadata properties associated with the . /// @@ -79,7 +102,14 @@ namespace Microsoft.AspNetCore.Mvc.ApplicationModels /// /// /// The value of is considered an implicit route value corresponding - /// to the key page. These entries will be implicitly added to + /// to the key page. + /// + /// + /// The value of is considered an implicit route value corresponding + /// to the key area when is not null. + /// + /// + /// These entries will be implicitly added to /// when the action descriptor is created, but will not be visible in . /// /// diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/PageConventionCollectionExtensions.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/PageConventionCollectionExtensions.cs index b04072f217..6daa490f9f 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/PageConventionCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/DependencyInjection/PageConventionCollectionExtensions.cs @@ -82,6 +82,44 @@ namespace Microsoft.Extensions.DependencyInjection return conventions; } + /// + /// Adds a to the page with the specified name located in the specified area. + /// + /// The to configure. + /// The area name. + /// + /// The page name e.g. /Users/List + /// + /// The page name is the path of the file without extension, relative to the pages root directory for the specified area. + /// e.g. the page name for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage/Accounts. + /// + /// + /// The . + public static PageConventionCollection AllowAnonymousToAreaPage( + this PageConventionCollection conventions, + string areaName, + string pageName) + { + if (conventions == null) + { + throw new ArgumentNullException(nameof(conventions)); + } + + if (string.IsNullOrEmpty(areaName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(areaName)); + } + + if (string.IsNullOrEmpty(pageName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(pageName)); + } + + var anonymousFilter = new AllowAnonymousFilter(); + conventions.AddAreaPageApplicationModelConvention(areaName, pageName, model => model.Filters.Add(anonymousFilter)); + return conventions; + } + /// /// Adds a to all pages under the specified folder. /// @@ -105,6 +143,44 @@ namespace Microsoft.Extensions.DependencyInjection return conventions; } + /// + /// Adds a to all pages under the specified area folder. + /// + /// The to configure. + /// The area name. + /// + /// The folder path e.g. /Manage/ + /// + /// The folder path is the path of the folder, relative to the pages root directory for the specified area. + /// e.g. the folder path for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage. + /// + ///. + /// The . + public static PageConventionCollection AllowAnonymousToAreaFolder( + this PageConventionCollection conventions, + string areaName, + string folderPath) + { + if (conventions == null) + { + throw new ArgumentNullException(nameof(conventions)); + } + + if (string.IsNullOrEmpty(areaName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(areaName)); + } + + if (string.IsNullOrEmpty(folderPath)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(folderPath)); + } + + var anonymousFilter = new AllowAnonymousFilter(); + conventions.AddAreaFolderApplicationModelConvention(areaName, folderPath, model => model.Filters.Add(anonymousFilter)); + return conventions; + } + /// /// Adds a with the specified policy to the page with the specified name. /// @@ -138,6 +214,62 @@ namespace Microsoft.Extensions.DependencyInjection public static PageConventionCollection AuthorizePage(this PageConventionCollection conventions, string pageName) => AuthorizePage(conventions, pageName, policy: string.Empty); + /// + /// Adds a with default policy to the page with the specified name. + /// + /// The to configure. + /// The area name. + /// + /// The page name e.g. /Users/List + /// + /// The page name is the path of the file without extension, relative to the pages root directory for the specified area. + /// e.g. the page name for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage/Accounts. + /// + /// + /// The . + public static PageConventionCollection AuthorizeAreaPage(this PageConventionCollection conventions, string areaName, string pageName) + => AuthorizeAreaPage(conventions, areaName, pageName, policy: string.Empty); + + /// + /// Adds a with the specified policy to the page with the specified name. + /// + /// The to configure. + /// The area name. + /// + /// The page name e.g. /Users/List + /// + /// The page name is the path of the file without extension, relative to the pages root directory for the specified area. + /// e.g. the page name for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage/Accounts. + /// + /// + /// The authorization policy. + /// The . + public static PageConventionCollection AuthorizeAreaPage( + this PageConventionCollection conventions, + string areaName, + string pageName, + string policy) + { + if (conventions == null) + { + throw new ArgumentNullException(nameof(conventions)); + } + + if (string.IsNullOrEmpty(areaName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(areaName)); + } + + if (string.IsNullOrEmpty(pageName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(pageName)); + } + + var authorizeFilter = new AuthorizeFilter(policy); + conventions.AddAreaPageApplicationModelConvention(areaName, pageName, model => model.Filters.Add(authorizeFilter)); + return conventions; + } + /// /// Adds a with the specified policy to all pages under the specified folder. /// @@ -171,6 +303,62 @@ namespace Microsoft.Extensions.DependencyInjection public static PageConventionCollection AuthorizeFolder(this PageConventionCollection conventions, string folderPath) => AuthorizeFolder(conventions, folderPath, policy: string.Empty); + /// + /// Adds a with the default policy to all pages under the specified folder. + /// + /// The to configure. + /// The area name. + /// + /// The folder path e.g. /Manage/ + /// + /// The folder path is the path of the folder, relative to the pages root directory for the specified area. + /// e.g. the folder path for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage. + /// + /// + /// The . + public static PageConventionCollection AuthorizeAreaFolder(this PageConventionCollection conventions, string areaName, string folderPath) + => AuthorizeAreaFolder(conventions, areaName, folderPath, policy: string.Empty); + + /// + /// Adds a with the specified policy to all pages under the specified folder. + /// + /// The to configure. + /// The area name. + /// + /// The folder path e.g. /Manage/ + /// + /// The folder path is the path of the folder, relative to the pages root directory for the specified area. + /// e.g. the folder path for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage. + /// + /// + /// The authorization policy. + /// The . + public static PageConventionCollection AuthorizeAreaFolder( + this PageConventionCollection conventions, + string areaName, + string folderPath, + string policy) + { + if (conventions == null) + { + throw new ArgumentNullException(nameof(conventions)); + } + + if (string.IsNullOrEmpty(areaName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(areaName)); + } + + if (string.IsNullOrEmpty(folderPath)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(folderPath)); + } + + var authorizeFilter = new AuthorizeFilter(policy); + conventions.AddAreaFolderApplicationModelConvention(areaName, folderPath, model => model.Filters.Add(authorizeFilter)); + return conventions; + } + /// /// Adds the specified to the page at the specified . /// @@ -199,7 +387,64 @@ namespace Microsoft.Extensions.DependencyInjection throw new ArgumentNullException(nameof(route)); } - conventions.AddPageRouteModelConvention(pageName, model => + conventions.AddPageRouteModelConvention(pageName, AddPageRouteThunk(route)); + + return conventions; + } + + /// + /// Adds the specified to the page at the specified located in the specified + /// area. + /// + /// The page can be routed via in addition to the default set of path based routes. + /// All links generated for this page will use the specified route. + /// + /// + /// The . + /// The area name. + /// + /// The page name e.g. /Users/List + /// + /// The page name is the path of the file without extension, relative to the pages root directory for the specified area. + /// e.g. the page name for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage/Accounts. + /// + /// + /// The route to associate with the page. + /// The . + public static PageConventionCollection AddAreaPageRoute( + this PageConventionCollection conventions, + string areaName, + string pageName, + string route) + { + if (conventions == null) + { + throw new ArgumentNullException(nameof(conventions)); + } + + if (string.IsNullOrEmpty(areaName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(areaName)); + } + + if (string.IsNullOrEmpty(pageName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(pageName)); + } + + if (route == null) + { + throw new ArgumentNullException(nameof(route)); + } + + conventions.AddAreaPageRouteModelConvention(areaName, pageName, AddPageRouteThunk(route)); + + return conventions; + } + + private static Action AddPageRouteThunk(string route) + { + return model => { // Use the route specified in MapPageRoute for outbound routing. foreach (var selector in model.Selectors) @@ -214,9 +459,7 @@ namespace Microsoft.Extensions.DependencyInjection Template = route, } }); - }); - - return conventions; + }; } } } diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs index 670267ba9c..7e1dbb47c2 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Infrastructure/PageActionDescriptorProvider.cs @@ -73,9 +73,9 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure foreach (var selector in model.Selectors) { - var descriptor = new PageActionDescriptor() + var descriptor = new PageActionDescriptor { - AttributeRouteInfo = new AttributeRouteInfo() + AttributeRouteInfo = new AttributeRouteInfo { Name = selector.AttributeRouteModel.Name, Order = selector.AttributeRouteModel.Order ?? 0, @@ -88,6 +88,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure Properties = new Dictionary(model.Properties), RelativePath = model.RelativePath, ViewEnginePath = model.ViewEnginePath, + AreaName = model.AreaName, }; foreach (var kvp in model.RouteValues) diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs index 682d55141f..c82199cf9a 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/Internal/PageRouteModelFactory.cs @@ -47,7 +47,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal return null; } - var routeModel = new PageRouteModel(relativePath, areaResult.viewEnginePath); + var routeModel = new PageRouteModel(relativePath, areaResult.viewEnginePath, areaResult.areaName); var routePrefix = CreateAreaRoute(areaResult.areaName, areaResult.viewEnginePath); PopulateRouteModel(routeModel, routePrefix, routeTemplate); diff --git a/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs b/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs index 456bd613fd..b4101c9309 100644 --- a/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs +++ b/src/Microsoft.AspNetCore.Mvc.RazorPages/PageActionDescriptor.cs @@ -23,8 +23,14 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages /// The to copy from. public PageActionDescriptor(PageActionDescriptor other) { + if (other == null) + { + throw new ArgumentNullException(nameof(other)); + } + RelativePath = other.RelativePath; ViewEnginePath = other.ViewEnginePath; + AreaName = other.AreaName; } /// @@ -34,9 +40,23 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages /// /// Gets or sets the path relative to the base path for page discovery. + /// + /// This value is the path of the file without extension, relative to the pages root directory. + /// e.g. the for the file /Pages/Catalog/Antiques.cshtml is /Catalog/Antiques + /// + /// + /// In an area, this value is the path of the file without extension, relative to the pages root directory for the specified area. + /// e.g. the for the file Areas/Identity/Pages/Manage/Accounts.cshtml, is /Manage/Accounts. + /// /// public string ViewEnginePath { get; set; } + /// + /// Gets or sets the area name for this page. + /// This value will be null for non-area pages. + /// + public string AreaName { get; set; } + /// public override string DisplayName { diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs index 42605e4d50..b7fa5fa9ce 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/RazorPagesWithBasePathTest.cs @@ -370,5 +370,26 @@ Hello from /Pages/Shared/"; // Assert Assert.Equal(expected, response.Trim()); } + + [Fact] + public async Task AuthorizeFolderConvention_CanBeAppliedToAreaPages() + { + // Act + var response = await Client.GetAsync("/Accounts/RequiresAuth"); + + // Assert + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/Login?ReturnUrl=%2FAccounts%2FRequiresAuth", response.Headers.Location.PathAndQuery); + } + + [Fact] + public async Task AllowAnonymouseToPageConvention_CanBeAppliedToAreaPages() + { + // Act + var response = await Client.GetStringAsync("/Accounts/RequiresAuth/AllowAnonymous"); + + // Assert + Assert.Equal("Hello from AllowAnonymous", response.Trim()); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/DependencyInjection/PageConventionCollectionExtensionsTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/DependencyInjection/PageConventionCollectionExtensionsTest.cs index b6ea5e394e..063fad5022 100644 --- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/DependencyInjection/PageConventionCollectionExtensionsTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/DependencyInjection/PageConventionCollectionExtensionsTest.cs @@ -72,6 +72,31 @@ namespace Microsoft.Extensions.DependencyInjection }); } + [Fact] + public void AllowAnonymousToAreaPage_AddsAllowAnonymousFilterToSpecificPage() + { + // Arrange + var conventions = new PageConventionCollection(); + var models = new[] + { + CreateApplicationModel("/Profile.cshtml", "/Profile"), + CreateApplicationModel("/Areas/Accounts/Pages/Profile.cshtml", "/Profile", "Accounts"), + }; + + // Act + conventions.AllowAnonymousToAreaPage("Accounts", "/Profile"); + ApplyConventions(conventions, models); + + // Assert + Assert.Collection(models, + model => Assert.Empty(model.Filters), + model => + { + Assert.Equal("/Areas/Accounts/Pages/Profile.cshtml", model.RelativePath); + Assert.IsType(Assert.Single(model.Filters)); + }); + } + [Theory] [InlineData("/Users")] [InlineData("/Users/")] @@ -112,6 +137,41 @@ namespace Microsoft.Extensions.DependencyInjection }); } + [Fact] + public void AllowAnonymousToAreaFolder_AddsAllowAnonymousFilterToFolderInArea() + { + // Arrange + var conventions = new PageConventionCollection(); + var models = new[] + { + CreateApplicationModel("/Profile.cshtml", "/Profile"), + CreateApplicationModel("/Mange/Profile.cshtml", "/Manage/Profile"), + CreateApplicationModel("/Areas/Accounts/Pages/Manage/Profile.cshtml", "/Manage/Profile", "Accounts"), + CreateApplicationModel("/Areas/Accounts/Pages/Manage/2FA.cshtml", "/Manage/2FA", "Accounts"), + CreateApplicationModel("/Areas/Accounts/Pages/View/OrderHistory.cshtml", "/View/OrderHistory", "Accounts"), + }; + + // Act + conventions.AllowAnonymousToAreaFolder("Accounts", "/Manage"); + ApplyConventions(conventions, models); + + // Assert + Assert.Collection(models, + model => Assert.Empty(model.Filters), + model => Assert.Empty(model.Filters), + model => + { + Assert.Equal("/Areas/Accounts/Pages/Manage/Profile.cshtml", model.RelativePath); + Assert.IsType(Assert.Single(model.Filters)); + }, + model => + { + Assert.Equal("/Areas/Accounts/Pages/Manage/2FA.cshtml", model.RelativePath); + Assert.IsType(Assert.Single(model.Filters)); + }, + model => Assert.Empty(model.Filters)); + } + [Fact] public void AuthorizePage_AddsAuthorizeFilterWithPolicyToSpecificPage() { @@ -141,6 +201,60 @@ namespace Microsoft.Extensions.DependencyInjection model => Assert.Empty(model.Filters)); } + [Fact] + public void AuthorizeAreaPage_AddsAuthorizeFilterWithDefaultPolicyToAreaPage() + { + // Arrange + var conventions = new PageConventionCollection(); + var models = new[] + { + CreateApplicationModel("/Profile.cshtml", "/Profile"), + CreateApplicationModel("/Areas/Accounts/Pages/Profile.cshtml", "/Profile", "Accounts"), + }; + + // Act + conventions.AuthorizeAreaPage("Accounts", "/Profile"); + ApplyConventions(conventions, models); + + // Assert + Assert.Collection(models, + model => Assert.Empty(model.Filters), + model => + { + Assert.Equal("/Areas/Accounts/Pages/Profile.cshtml", model.RelativePath); + var authFilter = Assert.IsType(Assert.Single(model.Filters)); + var authorizeAttribute = Assert.Single(authFilter.AuthorizeData); + Assert.Empty(authorizeAttribute.Policy); + }); + } + + [Fact] + public void AuthorizeAreaPage_AddsAuthorizeFilterWithCustomPolicyToAreaPage() + { + // Arrange + var conventions = new PageConventionCollection(); + var models = new[] + { + CreateApplicationModel("/Profile.cshtml", "/Profile"), + CreateApplicationModel("/Areas/Accounts/Pages/Profile.cshtml", "/Profile", "Accounts"), + }; + + // Act + conventions.AuthorizeAreaPage("Accounts", "/Profile", "custom"); + ApplyConventions(conventions, models); + + // Assert + Assert.Collection(models, + model => Assert.Empty(model.Filters), + model => + { + Assert.Equal("/Areas/Accounts/Pages/Profile.cshtml", model.RelativePath); + var authFilter = Assert.IsType(Assert.Single(model.Filters)); + var authorizeAttribute = Assert.Single(authFilter.AuthorizeData); + Assert.Equal("custom", authorizeAttribute.Policy); + }); + } + [Fact] public void AuthorizePage_AddsAuthorizeFilterWithoutPolicyToSpecificPage() { @@ -244,6 +358,84 @@ namespace Microsoft.Extensions.DependencyInjection }); } + [Fact] + public void AuthorizeAreaFolder_AddsAuthorizeFilterWithDefaultPolicyToAreaPagesInFolder() + { + // Arrange + var conventions = new PageConventionCollection(); + var models = new[] + { + CreateApplicationModel("/Profile.cshtml", "/Profile"), + CreateApplicationModel("/Mange/Profile.cshtml", "/Manage/Profile"), + CreateApplicationModel("/Areas/Accounts/Pages/Manage/Profile.cshtml", "/Manage/Profile", "Accounts"), + CreateApplicationModel("/Areas/Accounts/Pages/Manage/2FA.cshtml", "/Manage/2FA", "Accounts"), + CreateApplicationModel("/Areas/Accounts/Pages/View/OrderHistory.cshtml", "/View/OrderHistory", "Accounts"), + }; + + // Act + conventions.AuthorizeAreaFolder("Accounts", "/Manage"); + ApplyConventions(conventions, models); + + // Assert + Assert.Collection(models, + model => Assert.Empty(model.Filters), + model => Assert.Empty(model.Filters), + model => + { + Assert.Equal("/Areas/Accounts/Pages/Manage/Profile.cshtml", model.RelativePath); + var authorizeFilter = Assert.IsType(Assert.Single(model.Filters)); + var authorizeData = Assert.IsType(Assert.Single(authorizeFilter.AuthorizeData)); + Assert.Empty(authorizeData.Policy); + }, + model => + { + Assert.Equal("/Areas/Accounts/Pages/Manage/2FA.cshtml", model.RelativePath); + var authorizeFilter = Assert.IsType(Assert.Single(model.Filters)); + var authorizeData = Assert.IsType(Assert.Single(authorizeFilter.AuthorizeData)); + Assert.Empty(authorizeData.Policy); + }, + model => Assert.Empty(model.Filters)); + } + + [Fact] + public void AuthorizeAreaFolder_AddsAuthorizeFilterWithCustomPolicyToAreaPagesInFolder() + { + // Arrange + var conventions = new PageConventionCollection(); + var models = new[] + { + CreateApplicationModel("/Profile.cshtml", "/Profile"), + CreateApplicationModel("/Mange/Profile.cshtml", "/Manage/Profile"), + CreateApplicationModel("/Areas/Accounts/Pages/Manage/Profile.cshtml", "/Manage/Profile", "Accounts"), + CreateApplicationModel("/Areas/Accounts/Pages/Manage/2FA.cshtml", "/Manage/2FA", "Accounts"), + CreateApplicationModel("/Areas/Accounts/Pages/View/OrderHistory.cshtml", "/View/OrderHistory", "Accounts"), + }; + + // Act + conventions.AuthorizeAreaFolder("Accounts", "/Manage", "custom"); + ApplyConventions(conventions, models); + + // Assert + Assert.Collection(models, + model => Assert.Empty(model.Filters), + model => Assert.Empty(model.Filters), + model => + { + Assert.Equal("/Areas/Accounts/Pages/Manage/Profile.cshtml", model.RelativePath); + var authorizeFilter = Assert.IsType(Assert.Single(model.Filters)); + var authorizeData = Assert.IsType(Assert.Single(authorizeFilter.AuthorizeData)); + Assert.Equal("custom", authorizeData.Policy); + }, + model => + { + Assert.Equal("/Areas/Accounts/Pages/Manage/2FA.cshtml", model.RelativePath); + var authorizeFilter = Assert.IsType(Assert.Single(model.Filters)); + var authorizeData = Assert.IsType(Assert.Single(authorizeFilter.AuthorizeData)); + Assert.Equal("custom", authorizeData.Policy); + }, + model => Assert.Empty(model.Filters)); + } + [Fact] public void AddPageRoute_AddsRouteToSelector() { @@ -306,6 +498,62 @@ namespace Microsoft.Extensions.DependencyInjection }); } + [Fact] + public void AddAreaPageRoute_AddsRouteToSelector() + { + // Arrange + var conventions = new PageConventionCollection(); + var models = new[] + { + new PageRouteModel("/Pages/Profile.cshtml", "/Profile") + { + Selectors = + { + CreateSelectorModel("Profile"), + } + }, + new PageRouteModel("/Areas/Accounts/Pages/Profile.cshtml", "/Profile", "Accounts") + { + Selectors = + { + CreateSelectorModel("Accounts/Profile"), + } + } + }; + + // Act + conventions.AddAreaPageRoute("Accounts", "/Profile", "Different-Route"); + ApplyConventions(conventions, models); + + // Assert + Assert.Collection(models, + model => + { + Assert.Equal("/Pages/Profile.cshtml", model.RelativePath); + Assert.Collection(model.Selectors, + selector => + { + Assert.Equal("Profile", selector.AttributeRouteModel.Template); + Assert.False(selector.AttributeRouteModel.SuppressLinkGeneration); + }); + }, + model => + { + Assert.Equal("/Areas/Accounts/Pages/Profile.cshtml", model.RelativePath); + Assert.Collection(model.Selectors, + selector => + { + Assert.Equal("Accounts/Profile", selector.AttributeRouteModel.Template); + Assert.True (selector.AttributeRouteModel.SuppressLinkGeneration); + }, + selector => + { + Assert.Equal("Different-Route", selector.AttributeRouteModel.Template); + Assert.False(selector.AttributeRouteModel.SuppressLinkGeneration); + }); + }); + } + private static SelectorModel CreateSelectorModel(string template, bool suppressLinkGeneration = false) { return new SelectorModel @@ -339,12 +587,13 @@ namespace Microsoft.Extensions.DependencyInjection } } - private PageApplicationModel CreateApplicationModel(string relativePath, string viewEnginePath) + private PageApplicationModel CreateApplicationModel(string relativePath, string viewEnginePath, string areaName = null) { var descriptor = new PageActionDescriptor { ViewEnginePath = viewEnginePath, RelativePath = relativePath, + AreaName = areaName, }; return new PageApplicationModel(descriptor, typeof(object).GetTypeInfo(), new object[0]); diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageActionDescriptorProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageActionDescriptorProviderTest.cs index b12d32443b..095a5f5415 100644 --- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageActionDescriptorProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Infrastructure/PageActionDescriptorProviderTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Linq; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.Extensions.Options; @@ -65,6 +66,107 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure Assert.Equal("/Test/{id:int?}", descriptor.AttributeRouteInfo.Template); } + [Fact] + public void GetDescriptors_AddsDescriptorsForAreaPages() + { + // Arrange + var model = new PageRouteModel("/Test.cshtml", "/Test") + { + RouteValues = + { + { "custom-key", "custom-value" }, + }, + Selectors = + { + new SelectorModel + { + AttributeRouteModel = new AttributeRouteModel + { + Template = "/Test/{id:int?}", + } + } + } + }; + var applicationModelProvider = new TestPageRouteModelProvider(model); + var provider = new PageActionDescriptorProvider( + new[] { applicationModelProvider }, + GetAccessor(), + GetRazorPagesOptions()); + var context = new ActionDescriptorProviderContext(); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var result = Assert.Single(context.Results); + var descriptor = Assert.IsType(result); + Assert.Equal(model.RelativePath, descriptor.RelativePath); + Assert.Collection( + descriptor.RouteValues.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("custom-key", kvp.Key); + Assert.Equal("custom-value", kvp.Value); + }, + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/Test", kvp.Value); + }); + } + + [Fact] + public void GetDescriptors_CopiesRouteValuesFromModel() + { + // Arrange + var model = new PageRouteModel("/Areas/Accounts/Pages/Test.cshtml", "/Test", "Accounts") + { + RouteValues = + { + { "page", "/Test" }, + { "area", "Accounts" }, + }, + Selectors = + { + new SelectorModel + { + AttributeRouteModel = new AttributeRouteModel + { + Template = "Accounts/Test/{id:int?}", + } + } + } + }; + var applicationModelProvider = new TestPageRouteModelProvider(model); + var provider = new PageActionDescriptorProvider( + new[] { applicationModelProvider }, + GetAccessor(), + GetRazorPagesOptions()); + var context = new ActionDescriptorProviderContext(); + + // Act + provider.OnProvidersExecuting(context); + + // Assert + var result = Assert.Single(context.Results); + var descriptor = Assert.IsType(result); + Assert.Equal(model.RelativePath, descriptor.RelativePath); + Assert.Collection( + descriptor.RouteValues.OrderBy(kvp => kvp.Key), + kvp => + { + Assert.Equal("area", kvp.Key); + Assert.Equal("Accounts", kvp.Value); + }, + kvp => + { + Assert.Equal("page", kvp.Key); + Assert.Equal("/Test", kvp.Value); + }); + Assert.Equal("Accounts", descriptor.AreaName); + Assert.Equal("Accounts/Test/{id:int?}", descriptor.AttributeRouteInfo.Template); + } + [Fact] public void GetDescriptors_AddsActionDescriptorForEachSelector() { diff --git a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageRouteModelFactoryTest.cs b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageRouteModelFactoryTest.cs index 67bb226d06..6885f040a8 100644 --- a/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageRouteModelFactoryTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.RazorPages.Test/Internal/PageRouteModelFactoryTest.cs @@ -24,6 +24,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal // Assert Assert.Equal(relativePath, routeModel.RelativePath); Assert.Equal("/Users/Profile", routeModel.ViewEnginePath); + Assert.Null(routeModel.AreaName); Assert.Collection( routeModel.Selectors, @@ -109,6 +110,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal // Assert Assert.Equal(relativePath, routeModel.RelativePath); Assert.Equal("/Users/Profile", routeModel.ViewEnginePath); + Assert.Equal("TestArea", routeModel.AreaName); Assert.Collection( routeModel.Selectors, @@ -142,6 +144,7 @@ namespace Microsoft.AspNetCore.Mvc.RazorPages.Internal // Assert Assert.Equal(relativePath, routeModel.RelativePath); Assert.Equal("/Users/Profile/Index", routeModel.ViewEnginePath); + Assert.Equal("TestArea", routeModel.AreaName); Assert.Collection( routeModel.Selectors, diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/RequiresAuth/AllowAnonymous.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/RequiresAuth/AllowAnonymous.cshtml new file mode 100644 index 0000000000..221402ef5c --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/RequiresAuth/AllowAnonymous.cshtml @@ -0,0 +1,2 @@ +@page +Hello from AllowAnonymous diff --git a/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/RequiresAuth/Index.cshtml b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/RequiresAuth/Index.cshtml new file mode 100644 index 0000000000..3749762261 --- /dev/null +++ b/test/WebSites/RazorPagesWebSite/Areas/Accounts/Pages/RequiresAuth/Index.cshtml @@ -0,0 +1,2 @@ +@page +@{ throw new Exception("This should not be rendered."); } diff --git a/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs b/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs index 549cf8c69f..4947f62e91 100644 --- a/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs +++ b/test/WebSites/RazorPagesWebSite/StartupWithBasePath.cs @@ -20,6 +20,8 @@ namespace RazorPagesWebSite options.AllowAreas = true; options.Conventions.AuthorizePage("/Conventions/Auth"); options.Conventions.AuthorizeFolder("/Conventions/AuthFolder"); + options.Conventions.AuthorizeAreaFolder("Accounts", "/RequiresAuth"); + options.Conventions.AllowAnonymousToAreaPage("Accounts", "/RequiresAuth/AllowAnonymous"); }); }