diff --git a/src/Business/Grand.Business.Cms/Services/BlogService.cs b/src/Business/Grand.Business.Cms/Services/BlogService.cs index 647f75720..7fc75280a 100644 --- a/src/Business/Grand.Business.Cms/Services/BlogService.cs +++ b/src/Business/Grand.Business.Cms/Services/BlogService.cs @@ -67,7 +67,7 @@ public virtual Task GetBlogPostById(string blogPostId) /// Blog post name /// Category ident /// Blog posts - public virtual async Task> GetAllBlogPosts(string storeId = "", + public virtual async Task> GetAllBlogPosts(string storeId, DateTime? dateFrom = null, DateTime? dateTo = null, int pageIndex = 0, int pageSize = int.MaxValue, bool showHidden = false, string tag = null, string blogPostName = "", string categoryId = "") @@ -123,7 +123,7 @@ public virtual async Task> GetAllBlogPosts(string storeId = /// Page size /// A value indicating whether to show hidden records /// Blog posts - public virtual async Task> GetAllBlogPostsByTag(string storeId = "", + public virtual async Task> GetAllBlogPostsByTag(string storeId, string tag = "", int pageIndex = 0, int pageSize = int.MaxValue, bool showHidden = false) { @@ -336,7 +336,7 @@ public virtual async Task GetBlogCategoryBySeName(string blogCateg /// Get all blog categories /// /// - public virtual async Task> GetAllBlogCategories(string storeId = "") + public virtual async Task> GetAllBlogCategories(string storeId) { var query = from c in _blogCategoryRepository.Table select c; diff --git a/src/Business/Grand.Business.Cms/Services/NewsService.cs b/src/Business/Grand.Business.Cms/Services/NewsService.cs index 4abd31bd2..05e3868f1 100644 --- a/src/Business/Grand.Business.Cms/Services/NewsService.cs +++ b/src/Business/Grand.Business.Cms/Services/NewsService.cs @@ -60,7 +60,7 @@ public virtual Task GetNewsById(string newsId) /// A value indicating whether to show hidden records /// News title /// News items - public virtual async Task> GetAllNews(string storeId = "", + public virtual async Task> GetAllNews(string storeId, int pageIndex = 0, int pageSize = int.MaxValue, bool ignoreAcl = false, bool showHidden = false, string newsTitle = "") { diff --git a/src/Business/Grand.Business.Cms/Services/PageService.cs b/src/Business/Grand.Business.Cms/Services/PageService.cs index 6d0a8c112..8173d2e64 100644 --- a/src/Business/Grand.Business.Cms/Services/PageService.cs +++ b/src/Business/Grand.Business.Cms/Services/PageService.cs @@ -63,9 +63,11 @@ public virtual Task GetPageById(string pageId) /// Gets a page /// /// The page system name - /// Store identifier; pass 0 to ignore filtering by store and load the first one - /// Page - public virtual async Task GetPageBySystemName(string systemName, string storeId = "") + /// Store identifier; pass "" to ignore filtering by store and load the first one + /// + /// The page a store overrode this system name with, if it has one; otherwise the page shared by every store + /// + public virtual async Task GetPageBySystemName(string systemName, string storeId) { if (string.IsNullOrEmpty(systemName)) return null; @@ -78,8 +80,11 @@ public virtual async Task GetPageBySystemName(string systemName, string st query = query.Where(t => t.SystemName.ToLower() == systemName.ToLower()); query = query.OrderBy(t => t.Id); - var pages = await Task.FromResult(query.ToList()); - if (!string.IsNullOrEmpty(storeId)) pages = pages.Where(x => _aclService.Authorize(x, storeId)).ToList(); + IEnumerable pages = await _pageRepository.ToListAsync(query); + if (!string.IsNullOrEmpty(storeId)) + //a page this store was given for itself is the one it means, even though the shared page is older + pages = pages.Where(x => _aclService.Authorize(x, storeId)) + .OrderByDescending(x => x.LimitedToStores && x.Stores.Contains(storeId)); return pages.FirstOrDefault(); }); } @@ -101,7 +106,7 @@ public virtual async Task> GetAllPages(string storeId, bool ignoreAc query = query.OrderBy(t => t.DisplayOrder).ThenBy(t => t.SystemName); if ((string.IsNullOrEmpty(storeId) || _accessControlConfig.IgnoreStoreLimitations) && - (ignoreAcl || _accessControlConfig.IgnoreAcl)) return await Task.FromResult(query.ToList()); + (ignoreAcl || _accessControlConfig.IgnoreAcl)) return await _pageRepository.ToListAsync(query); { if (!ignoreAcl && !_accessControlConfig.IgnoreAcl) { @@ -113,14 +118,14 @@ public virtual async Task> GetAllPages(string storeId, bool ignoreAc //Store acl if (string.IsNullOrEmpty(storeId) || _accessControlConfig.IgnoreStoreLimitations) - return await Task.FromResult(query.ToList()); + return await _pageRepository.ToListAsync(query); query = from p in query where !p.LimitedToStores || p.Stores.Contains(storeId) select p; query = query.OrderBy(t => t.SystemName); } - return await Task.FromResult(query.ToList()); + return await _pageRepository.ToListAsync(query); }); } diff --git a/src/Business/Grand.Business.Core/Extensions/PageExtensions.cs b/src/Business/Grand.Business.Core/Extensions/PageExtensions.cs new file mode 100644 index 000000000..6416368f7 --- /dev/null +++ b/src/Business/Grand.Business.Core/Extensions/PageExtensions.cs @@ -0,0 +1,42 @@ +using Grand.Domain.Pages; + +namespace Grand.Business.Core.Extensions; + +public static class PageExtensions +{ + /// + /// Drops the page shared by every store wherever this store was given its own page under the same system name. + /// A store panel copies a shared page to edit it for one store, which leaves two pages carrying one system name + /// visible to that store; a list rendered for the storefront means the store's own one. + /// + /// Pages already filtered to what the store may see + /// Store identifier; pass "" to keep every page + /// The pages to render for the store + public static IList PreferStoreOverrides(this IEnumerable pages, string storeId) + { + ArgumentNullException.ThrowIfNull(pages); + + var all = pages as IList ?? pages.ToList(); + if (string.IsNullOrEmpty(storeId)) + return all; + + var overriddenSystemNames = all + .Where(p => IsOwnedBy(p, storeId) && !string.IsNullOrEmpty(p.SystemName)) + .Select(p => p.SystemName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (overriddenSystemNames.Count == 0) + return all; + + return all + .Where(p => IsOwnedBy(p, storeId) || + string.IsNullOrEmpty(p.SystemName) || + !overriddenSystemNames.Contains(p.SystemName)) + .ToList(); + } + + private static bool IsOwnedBy(Page page, string storeId) + { + return page.LimitedToStores && page.Stores.Contains(storeId); + } +} diff --git a/src/Business/Grand.Business.Core/Interfaces/Cms/IBlogService.cs b/src/Business/Grand.Business.Core/Interfaces/Cms/IBlogService.cs index c5935deac..94cb4e8aa 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Cms/IBlogService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Cms/IBlogService.cs @@ -28,7 +28,7 @@ public interface IBlogService /// Blog post name /// Category id /// Blog posts - Task> GetAllBlogPosts(string storeId = "", + Task> GetAllBlogPosts(string storeId, DateTime? dateFrom = null, DateTime? dateTo = null, int pageIndex = 0, int pageSize = int.MaxValue, bool showHidden = false, string tag = null, string blogPostName = "", string categoryId = ""); @@ -42,7 +42,7 @@ Task> GetAllBlogPosts(string storeId = "", /// Page size /// A value indicating whether to show hidden records /// Blog posts - Task> GetAllBlogPostsByTag(string storeId = "", + Task> GetAllBlogPostsByTag(string storeId, string tag = "", int pageIndex = 0, int pageSize = int.MaxValue, bool showHidden = false); @@ -122,7 +122,7 @@ Task> GetAllBlogPostsByTag(string storeId = "", /// Get all blog categories /// /// - Task> GetAllBlogCategories(string storeId = ""); + Task> GetAllBlogCategories(string storeId); /// /// Inserts an blog category diff --git a/src/Business/Grand.Business.Core/Interfaces/Cms/INewsService.cs b/src/Business/Grand.Business.Core/Interfaces/Cms/INewsService.cs index a74736595..33841ceb3 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Cms/INewsService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Cms/INewsService.cs @@ -25,7 +25,7 @@ public interface INewsService /// A value indicating whether to show hidden records /// News title /// News items - Task> GetAllNews(string storeId = "", + Task> GetAllNews(string storeId, int pageIndex = 0, int pageSize = int.MaxValue, bool ignoreAcl = false, bool showHidden = false, string newsTitle = ""); diff --git a/src/Business/Grand.Business.Core/Interfaces/Cms/IPageService.cs b/src/Business/Grand.Business.Core/Interfaces/Cms/IPageService.cs index f24c1189a..30bcde062 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Cms/IPageService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Cms/IPageService.cs @@ -20,7 +20,7 @@ public interface IPageService /// The page system name /// Store identifier; pass 0 to ignore filtering by store and load the first one /// Page - Task GetPageBySystemName(string systemName, string storeId = ""); + Task GetPageBySystemName(string systemName, string storeId); /// /// Gets all pages diff --git a/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs b/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs index ce42c9ecd..b521710f5 100644 --- a/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs +++ b/src/Business/Grand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.cs @@ -294,6 +294,7 @@ private async Task> GetPagesUrls(Language language, Stor { var now = DateTime.UtcNow; return (await _pageService.GetAllPages(store.Id)) + .PreferStoreOverrides(store.Id) .Where(t => t.IncludeInSitemap && (!t.StartDateUtc.HasValue || t.StartDateUtc < now) && (!t.EndDateUtc.HasValue || t.EndDateUtc > now)) .Select(topic => diff --git a/src/Business/Grand.Business.Messages/Services/MessageTemplateService.cs b/src/Business/Grand.Business.Messages/Services/MessageTemplateService.cs index 150c5f6d3..a5f223616 100644 --- a/src/Business/Grand.Business.Messages/Services/MessageTemplateService.cs +++ b/src/Business/Grand.Business.Messages/Services/MessageTemplateService.cs @@ -107,7 +107,9 @@ public virtual Task GetMessageTemplateById(string messageTempla /// /// Message template name /// Store identifier - /// Message template + /// + /// The template a store overrode this name with, if it has one; otherwise the template shared by every store + /// public virtual async Task GetMessageTemplateByName(string messageTemplateName, string storeId) { if (string.IsNullOrWhiteSpace(messageTemplateName)) @@ -121,13 +123,14 @@ public virtual async Task GetMessageTemplateByName(string messa query = query.Where(t => t.Name == messageTemplateName); query = query.OrderBy(t => t.Id); - var templates = await Task.FromResult(query.ToList()); + IEnumerable templates = await _messageTemplateRepository.ToListAsync(query); //store acl if (!string.IsNullOrEmpty(storeId)) + //a template this store was given for itself is the one it means, even though the shared template is older templates = templates .Where(t => _aclService.Authorize(t, storeId)) - .ToList(); + .OrderByDescending(t => t.LimitedToStores && t.Stores.Contains(storeId)); return templates.FirstOrDefault(); }); diff --git a/src/Tests/Grand.Business.Cms.Tests/Extensions/PageExtensionsTests.cs b/src/Tests/Grand.Business.Cms.Tests/Extensions/PageExtensionsTests.cs new file mode 100644 index 000000000..dc0f11e76 --- /dev/null +++ b/src/Tests/Grand.Business.Cms.Tests/Extensions/PageExtensionsTests.cs @@ -0,0 +1,124 @@ +using Grand.Business.Core.Extensions; +using Grand.Domain.Pages; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Cms.Tests.Extensions; + +[TestClass] +public class PageExtensionsTests +{ + private static Page Shared(string systemName, string id) + { + return new Page { Id = id, SystemName = systemName, LimitedToStores = false }; + } + + private static Page OwnedBy(string systemName, string id, string storeId) + { + return new Page { Id = id, SystemName = systemName, LimitedToStores = true, Stores = { storeId } }; + } + + [TestMethod] + public void PreferStoreOverrides_StoreCopiedTheSharedPage_KeepsOnlyTheCopy() + { + var pages = new List { Shared("about", "1"), OwnedBy("about", "2", "store-1") }; + + var result = pages.PreferStoreOverrides("store-1"); + + Assert.HasCount(1, result); + Assert.AreEqual("2", result[0].Id); + } + + [TestMethod] + public void PreferStoreOverrides_NoCopyForThisStore_KeepsTheSharedPage() + { + var pages = new List { Shared("about", "1") }; + + var result = pages.PreferStoreOverrides("store-1"); + + Assert.HasCount(1, result); + Assert.AreEqual("1", result[0].Id); + } + + /// + /// Only the system name the store overrode is affected; everything else it may see stays. + /// + [TestMethod] + public void PreferStoreOverrides_OtherSystemNames_AreUntouched() + { + var pages = new List { + Shared("about", "1"), + OwnedBy("about", "2", "store-1"), + Shared("contact", "3"), + OwnedBy("terms", "4", "store-1") + }; + + var result = pages.PreferStoreOverrides("store-1"); + + CollectionAssert.AreEqual(new[] { "2", "3", "4" }, result.Select(p => p.Id).ToArray()); + } + + /// + /// A store panel creates the copy with the system name it was copied from, but nothing stops the + /// casing from differing, and the storefront treats one page as one page regardless of casing. + /// + [TestMethod] + public void PreferStoreOverrides_SystemNameCasingDiffers_StillCountsAsAnOverride() + { + var pages = new List { Shared("About", "1"), OwnedBy("about", "2", "store-1") }; + + var result = pages.PreferStoreOverrides("store-1"); + + Assert.HasCount(1, result); + Assert.AreEqual("2", result[0].Id); + } + + /// + /// The order the caller was given is the order it renders in, so collapsing must not reshuffle. + /// + [TestMethod] + public void PreferStoreOverrides_PreservesTheIncomingOrder() + { + var pages = new List { + Shared("c", "1"), + OwnedBy("a", "2", "store-1"), + Shared("b", "3") + }; + + var result = pages.PreferStoreOverrides("store-1"); + + CollectionAssert.AreEqual(new[] { "1", "2", "3" }, result.Select(p => p.Id).ToArray()); + } + + /// + /// Without a store there is no override to prefer - the admin panel reads pages this way. + /// + [TestMethod] + public void PreferStoreOverrides_NoStore_KeepsEveryPage() + { + var pages = new List { Shared("about", "1"), OwnedBy("about", "2", "store-1") }; + + var result = pages.PreferStoreOverrides(""); + + Assert.HasCount(2, result); + } + + /// + /// Another store's copy is not this store's override, and a page limited to another store should + /// not have reached this method at all. + /// + [TestMethod] + public void PreferStoreOverrides_CopyBelongsToAnotherStore_KeepsTheSharedPage() + { + var pages = new List { Shared("about", "1"), OwnedBy("about", "2", "store-2") }; + + var result = pages.PreferStoreOverrides("store-1"); + + CollectionAssert.AreEqual(new[] { "1", "2" }, result.Select(p => p.Id).ToArray()); + } + + [TestMethod] + public void PreferStoreOverrides_NullPages_Throws() + { + Assert.ThrowsExactly(() => ((IEnumerable)null).PreferStoreOverrides("store-1")); + } +} diff --git a/src/Tests/Grand.Business.Cms.Tests/Services/NewsServiceTests.cs b/src/Tests/Grand.Business.Cms.Tests/Services/NewsServiceTests.cs index 35fa9df10..b86bed89a 100644 --- a/src/Tests/Grand.Business.Cms.Tests/Services/NewsServiceTests.cs +++ b/src/Tests/Grand.Business.Cms.Tests/Services/NewsServiceTests.cs @@ -55,7 +55,7 @@ public async Task GetAllNewsTest() var newsItem = new NewsItem { Published = true }; await _repository.InsertAsync(newsItem); //Act - var result = await _newsService.GetAllNews(); + var result = await _newsService.GetAllNews(storeId: ""); //Assert Assert.IsTrue(result.Any()); } diff --git a/src/Tests/Grand.Business.Cms.Tests/Services/PageServiceTests.cs b/src/Tests/Grand.Business.Cms.Tests/Services/PageServiceTests.cs index 9be7c35db..e52a5b57c 100644 --- a/src/Tests/Grand.Business.Cms.Tests/Services/PageServiceTests.cs +++ b/src/Tests/Grand.Business.Cms.Tests/Services/PageServiceTests.cs @@ -66,11 +66,80 @@ public async Task GetPageBySystemNameTest() var page = new Page { SystemName = "test" }; await _repository.InsertAsync(page); //Act - var result = await _pageService.GetPageBySystemName(page.SystemName); + var result = await _pageService.GetPageBySystemName(page.SystemName, storeId: ""); //Assert Assert.IsNotNull(result); } + /// + /// A page limited to one store must not answer a lookup made in another store. The store + /// used to be optional here, so a caller that forgot it - the storefront contact page and + /// both panel dashboards did - got whichever page matched the system name first. + /// + [TestMethod] + public async Task GetPageBySystemName_LimitedToAnotherStore_IsNotReturned() + { + //Arrange + var page = new Page { SystemName = "test", LimitedToStores = true, Stores = { "store-1" } }; + await _repository.InsertAsync(page); + //Act + var result = await _pageService.GetPageBySystemName(page.SystemName, "store-2"); + //Assert + Assert.IsNull(result); + } + + /// + /// A page that is not limited to any store stays visible in every store - the fix narrows + /// what leaks, not what existing installations can see. + /// + [TestMethod] + public async Task GetPageBySystemName_NotLimitedToStores_IsReturnedInAnyStore() + { + //Arrange + var page = new Page { SystemName = "test", LimitedToStores = false }; + await _repository.InsertAsync(page); + //Act + var result = await _pageService.GetPageBySystemName(page.SystemName, "store-2"); + //Assert + Assert.IsNotNull(result); + } + + /// + /// The store panel overrides a shared page by copying it for one store, which leaves two pages + /// carrying one system name visible to that store. The store's own copy is inserted second, so + /// ordering by identifier alone handed the store back the shared page and made the copy inert. + /// + [TestMethod] + public async Task GetPageBySystemName_StoreHasItsOwnCopy_ReturnsTheCopyNotTheSharedPage() + { + //Arrange + var sharedPage = new Page { SystemName = "test", LimitedToStores = false }; + await _repository.InsertAsync(sharedPage); + var storeCopy = new Page { SystemName = "test", LimitedToStores = true, Stores = { "store-1" } }; + await _repository.InsertAsync(storeCopy); + //Act + var result = await _pageService.GetPageBySystemName("test", "store-1"); + //Assert + Assert.AreEqual(storeCopy.Id, result.Id); + } + + /// + /// A store without a copy of its own keeps resolving to the shared page. + /// + [TestMethod] + public async Task GetPageBySystemName_AnotherStoreHasTheCopy_ReturnsTheSharedPage() + { + //Arrange + var sharedPage = new Page { SystemName = "test", LimitedToStores = false }; + await _repository.InsertAsync(sharedPage); + await _repository.InsertAsync(new Page + { SystemName = "test", LimitedToStores = true, Stores = { "store-1" } }); + //Act + var result = await _pageService.GetPageBySystemName("test", "store-2"); + //Assert + Assert.AreEqual(sharedPage.Id, result.Id); + } + [TestMethod] public async Task GetAllPagesTest() { diff --git a/src/Tests/Grand.Business.Messages.Tests/Services/MessageTemplateServiceTests.cs b/src/Tests/Grand.Business.Messages.Tests/Services/MessageTemplateServiceTests.cs index 34a7209db..f7038d21e 100644 --- a/src/Tests/Grand.Business.Messages.Tests/Services/MessageTemplateServiceTests.cs +++ b/src/Tests/Grand.Business.Messages.Tests/Services/MessageTemplateServiceTests.cs @@ -56,6 +56,51 @@ public async Task CopyMessageTemplate_InsertCopyEntity() _repositoryMock.Verify(c => c.InsertAsync(It.IsAny()), Times.Once); } + /// + /// The store panel overrides a shared template by copying it for one store, which leaves two + /// templates carrying one name visible to that store. The store's own copy is inserted second, + /// so ordering by identifier alone sent the store's mail from the shared template. + /// + [TestMethod] + public async Task GetMessageTemplateByName_StoreHasItsOwnCopy_ReturnsTheCopyNotTheSharedTemplate() + { + var sharedTemplate = new MessageTemplate { Id = "id1", Name = "Name", LimitedToStores = false }; + var storeCopy = new MessageTemplate { Id = "id2", Name = "Name", LimitedToStores = true, Stores = { "store-1" } }; + ArrangeLookup(sharedTemplate, storeCopy); + + var result = await _service.GetMessageTemplateByName("Name", "store-1"); + + Assert.AreEqual(storeCopy.Id, result.Id); + } + + /// + /// A store without a copy of its own keeps resolving to the shared template. + /// + [TestMethod] + public async Task GetMessageTemplateByName_AnotherStoreHasTheCopy_ReturnsTheSharedTemplate() + { + var sharedTemplate = new MessageTemplate { Id = "id1", Name = "Name", LimitedToStores = false }; + var storeCopy = new MessageTemplate { Id = "id2", Name = "Name", LimitedToStores = true, Stores = { "store-1" } }; + ArrangeLookup(sharedTemplate, storeCopy); + + var result = await _service.GetMessageTemplateByName("Name", "store-2"); + + Assert.AreEqual(sharedTemplate.Id, result.Id); + } + + private void ArrangeLookup(params MessageTemplate[] templates) + { + _repositoryMock.Setup(c => c.Table).Returns(templates.AsQueryable()); + _repositoryMock + .Setup(c => c.ToListAsync(It.IsAny>(), It.IsAny())) + .Returns((IQueryable query, CancellationToken _) => + Task.FromResult>(query.ToList())); + _cacheMock.Setup(c => c.GetAsync(It.IsAny(), It.IsAny>>())) + .Returns((string _, Func> acquire) => acquire()); + _aclService.Setup(c => c.Authorize(It.IsAny(), It.IsAny())) + .Returns((MessageTemplate t, string storeId) => !t.LimitedToStores || t.Stores.Contains(storeId)); + } + [TestMethod] public async Task InsertMessageTemplate_InvokeExpectedMethods() { diff --git a/src/Web/Grand.Web.Admin/Controllers/SearchController.cs b/src/Web/Grand.Web.Admin/Controllers/SearchController.cs index 4c649a990..59ebb3870 100644 --- a/src/Web/Grand.Web.Admin/Controllers/SearchController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/SearchController.cs @@ -131,7 +131,7 @@ public async Task Index(string searchTerm, FoundMenuItem[] foundM if (result.Count < _adminSearchSettings.MaxSearchResultsCount && _adminSearchSettings.SearchInNews) { - var news = await _newsService.GetAllNews(newsTitle: searchTerm, + var news = await _newsService.GetAllNews(storeId: "", newsTitle: searchTerm, pageSize: _adminSearchSettings.MaxSearchResultsCount - result.Count, showHidden: true); foreach (var signleNews in news) result.Add(new Tuple(new @@ -144,7 +144,7 @@ public async Task Index(string searchTerm, FoundMenuItem[] foundM if (result.Count < _adminSearchSettings.MaxSearchResultsCount && _adminSearchSettings.SearchInBlogs) { - var blogPosts = await _blogService.GetAllBlogPosts(blogPostName: searchTerm, + var blogPosts = await _blogService.GetAllBlogPosts(storeId: "", blogPostName: searchTerm, pageSize: _adminSearchSettings.MaxSearchResultsCount - result.Count, showHidden: true); foreach (var blogPost in blogPosts) result.Add(new Tuple(new diff --git a/src/Web/Grand.Web.Common/Components/BaseStoreViewComponent.cs b/src/Web/Grand.Web.Common/Components/BaseStoreViewComponent.cs new file mode 100644 index 000000000..8c142b154 --- /dev/null +++ b/src/Web/Grand.Web.Common/Components/BaseStoreViewComponent.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Web.Common.Components; + +[Area("Store")] +public abstract class BaseStoreViewComponent : ViewComponent +{ + public new IViewComponentResult View(string viewName, TModel model) + { + return base.View(viewName, model); + } + + public new IViewComponentResult View(TModel model) + { + return base.View(model); + } + + public new IViewComponentResult View(string viewName) + { + return base.View(viewName); + } +} diff --git a/src/Web/Grand.Web.Store/Components/StoreBestsellersBriefReportByAmount.cs b/src/Web/Grand.Web.Store/Components/StoreBestsellersBriefReportByAmount.cs index fbcdcc2dd..b66ca880a 100644 --- a/src/Web/Grand.Web.Store/Components/StoreBestsellersBriefReportByAmount.cs +++ b/src/Web/Grand.Web.Store/Components/StoreBestsellersBriefReportByAmount.cs @@ -5,7 +5,7 @@ namespace Grand.Web.Store.Components; -public class StoreBestsellersBriefReportByAmountViewComponent : BaseAdminViewComponent +public class StoreBestsellersBriefReportByAmountViewComponent : BaseStoreViewComponent { private readonly IPermissionService _permissionService; diff --git a/src/Web/Grand.Web.Store/Components/StoreBestsellersBriefReportByQuantity.cs b/src/Web/Grand.Web.Store/Components/StoreBestsellersBriefReportByQuantity.cs index 754ff6c3c..f699514b0 100644 --- a/src/Web/Grand.Web.Store/Components/StoreBestsellersBriefReportByQuantity.cs +++ b/src/Web/Grand.Web.Store/Components/StoreBestsellersBriefReportByQuantity.cs @@ -5,7 +5,7 @@ namespace Grand.Web.Store.Components; -public class StoreBestsellersBriefReportByQuantityViewComponent : BaseAdminViewComponent +public class StoreBestsellersBriefReportByQuantityViewComponent : BaseStoreViewComponent { private readonly IPermissionService _permissionService; diff --git a/src/Web/Grand.Web.Store/Components/StoreCustomerReportRegistered.cs b/src/Web/Grand.Web.Store/Components/StoreCustomerReportRegistered.cs index f6c9880e9..48e66f706 100644 --- a/src/Web/Grand.Web.Store/Components/StoreCustomerReportRegistered.cs +++ b/src/Web/Grand.Web.Store/Components/StoreCustomerReportRegistered.cs @@ -5,7 +5,7 @@ namespace Grand.Web.Store.Components; -public class StoreCustomerReportRegisteredViewComponent : BaseAdminViewComponent +public class StoreCustomerReportRegisteredViewComponent : BaseStoreViewComponent { private readonly IPermissionService _permissionService; diff --git a/src/Web/Grand.Web.Store/Components/StoreCustomerReportTimeChart.cs b/src/Web/Grand.Web.Store/Components/StoreCustomerReportTimeChart.cs index 0b1113cf0..2415c7397 100644 --- a/src/Web/Grand.Web.Store/Components/StoreCustomerReportTimeChart.cs +++ b/src/Web/Grand.Web.Store/Components/StoreCustomerReportTimeChart.cs @@ -5,7 +5,7 @@ namespace Grand.Web.Store.Components; -public class StoreCustomerReportTimeChartViewComponent : BaseAdminViewComponent +public class StoreCustomerReportTimeChartViewComponent : BaseStoreViewComponent { private readonly IPermissionService _permissionService; diff --git a/src/Web/Grand.Web.Store/Components/StoreLatestOrder.cs b/src/Web/Grand.Web.Store/Components/StoreLatestOrder.cs index d7a22c11d..bf67c4d5b 100644 --- a/src/Web/Grand.Web.Store/Components/StoreLatestOrder.cs +++ b/src/Web/Grand.Web.Store/Components/StoreLatestOrder.cs @@ -5,7 +5,7 @@ namespace Grand.Web.Store.Components; -public class StoreLatestOrderViewComponent : BaseAdminViewComponent +public class StoreLatestOrderViewComponent : BaseStoreViewComponent { private readonly IPermissionService _permissionService; diff --git a/src/Web/Grand.Web.Store/Components/StoreOrderAverageReport.cs b/src/Web/Grand.Web.Store/Components/StoreOrderAverageReport.cs index 7d23bd9b1..134b8cab2 100644 --- a/src/Web/Grand.Web.Store/Components/StoreOrderAverageReport.cs +++ b/src/Web/Grand.Web.Store/Components/StoreOrderAverageReport.cs @@ -5,7 +5,7 @@ namespace Grand.Web.Store.Components; -public class StoreOrderAverageReportViewComponent : BaseAdminViewComponent +public class StoreOrderAverageReportViewComponent : BaseStoreViewComponent { private readonly IPermissionService _permissionService; diff --git a/src/Web/Grand.Web.Store/Components/StoreOrderIncompleteReport.cs b/src/Web/Grand.Web.Store/Components/StoreOrderIncompleteReport.cs index 008f3fc2c..8379dcb4e 100644 --- a/src/Web/Grand.Web.Store/Components/StoreOrderIncompleteReport.cs +++ b/src/Web/Grand.Web.Store/Components/StoreOrderIncompleteReport.cs @@ -5,7 +5,7 @@ namespace Grand.Web.Store.Components; -public class StoreOrderIncompleteReportViewComponent : BaseAdminViewComponent +public class StoreOrderIncompleteReportViewComponent : BaseStoreViewComponent { private readonly IPermissionService _permissionService; diff --git a/src/Web/Grand.Web.Store/Components/StoreOrderPeriodReport.cs b/src/Web/Grand.Web.Store/Components/StoreOrderPeriodReport.cs index 783626446..f1fda4951 100644 --- a/src/Web/Grand.Web.Store/Components/StoreOrderPeriodReport.cs +++ b/src/Web/Grand.Web.Store/Components/StoreOrderPeriodReport.cs @@ -5,7 +5,7 @@ namespace Grand.Web.Store.Components; -public class StoreOrderPeriodReportViewComponent : BaseAdminViewComponent +public class StoreOrderPeriodReportViewComponent : BaseStoreViewComponent { private readonly IPermissionService _permissionService; diff --git a/src/Web/Grand.Web.Store/Components/StoreOrderTimeChart.cs b/src/Web/Grand.Web.Store/Components/StoreOrderTimeChart.cs index 1aa983c98..6a6f23439 100644 --- a/src/Web/Grand.Web.Store/Components/StoreOrderTimeChart.cs +++ b/src/Web/Grand.Web.Store/Components/StoreOrderTimeChart.cs @@ -5,7 +5,7 @@ namespace Grand.Web.Store.Components; -public class StoreOrderTimeChartViewComponent : BaseAdminViewComponent +public class StoreOrderTimeChartViewComponent : BaseStoreViewComponent { private readonly IPermissionService _permissionService; diff --git a/src/Web/Grand.Web.Store/Components/StorePage.cs b/src/Web/Grand.Web.Store/Components/StorePage.cs index f1b66bcff..0df494bc8 100644 --- a/src/Web/Grand.Web.Store/Components/StorePage.cs +++ b/src/Web/Grand.Web.Store/Components/StorePage.cs @@ -1,24 +1,28 @@ using Grand.Business.Core.Interfaces.Cms; +using Grand.Infrastructure; using Grand.Web.Common.Components; using Grand.Web.Store.Models.Common; using Microsoft.AspNetCore.Mvc; -namespace Grand.Web.Vendor.Components; +namespace Grand.Web.Store.Components; -public class StorePageViewComponent : BaseVendorViewComponent +public class StorePageViewComponent : BaseStoreViewComponent { #region Fields private readonly IPageService _pageService; + private readonly IContextAccessor _contextAccessor; #endregion #region Constructors public StorePageViewComponent( - IPageService pageService) + IPageService pageService, + IContextAccessor contextAccessor) { _pageService = pageService; + _contextAccessor = contextAccessor; } #endregion @@ -27,7 +31,8 @@ public StorePageViewComponent( public async Task InvokeAsync(string systemName) { - var page = await _pageService.GetPageBySystemName(systemName); + var page = await _pageService.GetPageBySystemName(systemName, + _contextAccessor.StoreContext.CurrentStore.Id); var model = new StorePortalModel(page?.Title, page?.Body); return View(model); } diff --git a/src/Web/Grand.Web.Store/Components/StoreWidget.cs b/src/Web/Grand.Web.Store/Components/StoreWidget.cs index dcf15adae..4fdcdf0c8 100644 --- a/src/Web/Grand.Web.Store/Components/StoreWidget.cs +++ b/src/Web/Grand.Web.Store/Components/StoreWidget.cs @@ -6,7 +6,7 @@ namespace Grand.Web.Store.Components; -public class StoreWidgetViewComponent : BaseVendorViewComponent +public class StoreWidgetViewComponent : BaseStoreViewComponent { #region Constructors diff --git a/src/Web/Grand.Web.Store/Controllers/PageController.cs b/src/Web/Grand.Web.Store/Controllers/PageController.cs index 1af17dfb3..41ffb81ea 100644 --- a/src/Web/Grand.Web.Store/Controllers/PageController.cs +++ b/src/Web/Grand.Web.Store/Controllers/PageController.cs @@ -242,10 +242,12 @@ public async Task Copy(string id) if (page == null) return RedirectToAction("List"); - if (!page.AccessToEntityByStore(storeId)) + // A page is copyable only while it is still readable here and not yet owned by this store, + // so AccessToEntityByStore - which demands sole ownership - cannot be the guard. + if (page.LimitedToStores && !page.Stores.Contains(storeId)) return RedirectToAction("List"); - // Only allow copy for multistore or store-unrestricted topics + // Only allow copy for multistore or store-unrestricted pages if (page.LimitedToStores && page.Stores.Count <= 1) return RedirectToAction("Edit", new { id }); diff --git a/src/Web/Grand.Web.Vendor/Components/VendorPage.cs b/src/Web/Grand.Web.Vendor/Components/VendorPage.cs index 22f3b41b2..d994d3c74 100644 --- a/src/Web/Grand.Web.Vendor/Components/VendorPage.cs +++ b/src/Web/Grand.Web.Vendor/Components/VendorPage.cs @@ -1,4 +1,5 @@ using Grand.Business.Core.Interfaces.Cms; +using Grand.Infrastructure; using Grand.Web.Common.Components; using Grand.Web.Vendor.Models.Common; using Microsoft.AspNetCore.Mvc; @@ -10,15 +11,18 @@ public class VendorPageViewComponent : BaseVendorViewComponent #region Fields private readonly IPageService _pageService; + private readonly IContextAccessor _contextAccessor; #endregion #region Constructors public VendorPageViewComponent( - IPageService pageService) + IPageService pageService, + IContextAccessor contextAccessor) { _pageService = pageService; + _contextAccessor = contextAccessor; } #endregion @@ -27,7 +31,8 @@ public VendorPageViewComponent( public async Task InvokeAsync(string systemName) { - var page = await _pageService.GetPageBySystemName(systemName); + var page = await _pageService.GetPageBySystemName(systemName, + _contextAccessor.StoreContext.CurrentStore.Id); var model = new VendorPortalModel(page?.Title, page?.Body); return View(model); } diff --git a/src/Web/Grand.Web/Components/Footer.cs b/src/Web/Grand.Web/Components/Footer.cs index 442e78991..956baf2a0 100644 --- a/src/Web/Grand.Web/Components/Footer.cs +++ b/src/Web/Grand.Web/Components/Footer.cs @@ -66,6 +66,7 @@ private async Task PrepareFooter() { var now = DateTime.UtcNow; var pageModel = (await _pageService.GetAllPages(_contextAccessor.StoreContext.CurrentStore.Id)) + .PreferStoreOverrides(_contextAccessor.StoreContext.CurrentStore.Id) .Where(t => (t.IncludeInFooterRow1 || t.IncludeInFooterRow2 || t.IncludeInFooterRow3) && t.Published && (!t.StartDateUtc.HasValue || t.StartDateUtc < now) && (!t.EndDateUtc.HasValue || t.EndDateUtc > now)) diff --git a/src/Web/Grand.Web/Controllers/ContactController.cs b/src/Web/Grand.Web/Controllers/ContactController.cs index 9f0f9c956..6ed39bbcc 100644 --- a/src/Web/Grand.Web/Controllers/ContactController.cs +++ b/src/Web/Grand.Web/Controllers/ContactController.cs @@ -45,7 +45,7 @@ public virtual async Task> Index( { if (storeInformationSettings.StoreClosed) { - var closestorepage = await pageService.GetPageBySystemName("ContactUs"); + var closestorepage = await pageService.GetPageBySystemName("ContactUs", _contextAccessor.StoreContext.CurrentStore.Id); if (closestorepage is not { AccessibleWhenStoreClosed: true }) return RedirectToRoute("StoreClosed"); } @@ -68,7 +68,7 @@ public virtual async Task> Index( { if (storeInformationSettings.StoreClosed) { - var closeStorePage = await pageService.GetPageBySystemName("ContactUs"); + var closeStorePage = await pageService.GetPageBySystemName("ContactUs", _contextAccessor.StoreContext.CurrentStore.Id); if (closeStorePage is not { AccessibleWhenStoreClosed: true }) return RedirectToRoute("StoreClosed"); } diff --git a/src/Web/Grand.Web/Features/Handlers/Catalog/GetMenuHandler.cs b/src/Web/Grand.Web/Features/Handlers/Catalog/GetMenuHandler.cs index 0aaddc9a7..43822a64c 100644 --- a/src/Web/Grand.Web/Features/Handlers/Catalog/GetMenuHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Catalog/GetMenuHandler.cs @@ -67,6 +67,7 @@ public async Task Handle(GetMenu request, CancellationToken cancellat //top menu pages var now = DateTime.UtcNow; var pageModel = (await _pageService.GetAllPages(request.Store.Id)) + .PreferStoreOverrides(request.Store.Id) .Where(t => t.Published && t.IncludeInMenu && (!t.StartDateUtc.HasValue || t.StartDateUtc < now) && (!t.EndDateUtc.HasValue || t.EndDateUtc > now)) .Select(t => new MenuModel.MenuPageModel { diff --git a/src/Web/Grand.Web/Features/Handlers/Common/GetSitemapHandler.cs b/src/Web/Grand.Web/Features/Handlers/Common/GetSitemapHandler.cs index c7fe3c4cf..5bc1758d6 100644 --- a/src/Web/Grand.Web/Features/Handlers/Common/GetSitemapHandler.cs +++ b/src/Web/Grand.Web/Features/Handlers/Common/GetSitemapHandler.cs @@ -109,6 +109,7 @@ public async Task Handle(GetSitemap request, CancellationToken can //pages var now = DateTime.UtcNow; var pages = (await _pageService.GetAllPages(request.Store.Id)) + .PreferStoreOverrides(request.Store.Id) .Where(t => t.IncludeInSitemap && (!t.StartDateUtc.HasValue || t.StartDateUtc < now) && (!t.EndDateUtc.HasValue || t.EndDateUtc > now)) .ToList();