diff --git a/src/Umbraco.Core/Cache/PartialViewCacheInvalidators/IMemberPartialViewCacheInvalidator.cs b/src/Umbraco.Core/Cache/PartialViewCacheInvalidators/IMemberPartialViewCacheInvalidator.cs new file mode 100644 index 000000000000..289dad528844 --- /dev/null +++ b/src/Umbraco.Core/Cache/PartialViewCacheInvalidators/IMemberPartialViewCacheInvalidator.cs @@ -0,0 +1,16 @@ +namespace Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators; + +/// +/// Defines behaviours for clearing of cached partials views that are configured to be cached individually by member. +/// +public interface IMemberPartialViewCacheInvalidator +{ + /// + /// Clears the partial view cache items for the specified member ids. + /// + /// The member Ids to clear the cache for. + /// + /// Called from the when a member is saved or deleted. + /// + void ClearPartialViewCacheItems(IEnumerable memberIds); +} diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs index 1c19f62576f1..3e4181feac2a 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs @@ -1,10 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; -using Umbraco.Extensions; namespace Umbraco.Cms.Core.Cache; @@ -13,10 +15,32 @@ public sealed class MemberCacheRefresher : PayloadCacheRefresherBase + : this( + appCaches, + serializer, + idKeyMap, + eventAggregator, + factory, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public MemberCacheRefresher( + AppCaches appCaches, + IJsonSerializer serializer, + IIdKeyMap idKeyMap, + IEventAggregator eventAggregator, + ICacheRefresherNotificationFactory factory, + IMemberPartialViewCacheInvalidator memberPartialViewCacheInvalidator) + : base(appCaches, serializer, eventAggregator, factory) + { _idKeyMap = idKeyMap; + _memberPartialViewCacheInvalidator = memberPartialViewCacheInvalidator; + } #region Indirect @@ -65,7 +89,8 @@ public override void Remove(int id) private void ClearCache(params JsonPayload[] payloads) { - AppCaches.ClearPartialViewCache(); + _memberPartialViewCacheInvalidator.ClearPartialViewCacheItems(payloads.Select(p => p.Id)); + Attempt memberCache = AppCaches.IsolatedCaches.Get(); foreach (JsonPayload p in payloads) diff --git a/src/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidator.cs b/src/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidator.cs new file mode 100644 index 000000000000..8ee4be4c00ee --- /dev/null +++ b/src/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidator.cs @@ -0,0 +1,38 @@ +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Website.Cache.PartialViewCacheInvalidators; + +/// +/// Implementation of that only remove cached partial views +/// that were cached for the specified member(s). +/// +public class MemberPartialViewCacheInvalidator : IMemberPartialViewCacheInvalidator +{ + private readonly AppCaches _appCaches; + + /// + /// Initializes a new instance of the class. + /// + public MemberPartialViewCacheInvalidator(AppCaches appCaches) => _appCaches = appCaches; + + /// + /// + /// Partial view cache keys follow the following format: + /// [] is optional or only added if the information is available + /// {} is a parameter + /// "Umbraco.Web.PartialViewCacheKey{partialViewName}-[{currentThreadCultureName}-][m{memberId}-][c{contextualKey}-]" + /// See for more information. + /// + public void ClearPartialViewCacheItems(IEnumerable memberIds) + { + foreach (var memberId in memberIds) + { + _appCaches.RuntimeCache.ClearByRegex($"{CoreCacheHelperExtensions.PartialViewCacheKey}.*-m{memberId}-*"); + } + + // since it is possible to add a cache item linked to members without a member logged in, we should always clear these items. + _appCaches.RuntimeCache.ClearByRegex($"{CoreCacheHelperExtensions.PartialViewCacheKey}.*-m-*"); + } +} diff --git a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs index 400b288786f3..2fd979d8ca92 100644 --- a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Routing; @@ -13,6 +14,7 @@ using Umbraco.Cms.Infrastructure.DependencyInjection; using Umbraco.Cms.Web.Common.Middleware; using Umbraco.Cms.Web.Common.Routing; +using Umbraco.Cms.Web.Website.Cache.PartialViewCacheInvalidators; using Umbraco.Cms.Web.Website.Collections; using Umbraco.Cms.Web.Website.Models; using Umbraco.Cms.Web.Website.Routing; @@ -73,6 +75,9 @@ public static IUmbracoBuilder AddWebsite(this IUmbracoBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Partial view cache invalidators + builder.Services.AddUnique(); + builder .AddDistributedCache() .AddModelsBuilder(); diff --git a/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs b/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs index 6892ffd1452f..efb48d59da8a 100644 --- a/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs +++ b/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs @@ -105,7 +105,45 @@ public static IHtmlContent PreviewBadge( ViewDataDictionary? viewData = null, Func? contextualKeyBuilder = null) { - var cacheKey = new StringBuilder(partialViewName); + IUmbracoContextAccessor umbracoContextAccessor = GetRequiredService(htmlHelper); + umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext); + + string cacheKey = await GenerateCacheKeyForCachedPartialViewAsync( + partialViewName, + cacheByPage, + umbracoContext, + cacheByMember, + cacheByMember ? GetRequiredService(htmlHelper) : null, + model, + viewData, + contextualKeyBuilder); + + AppCaches appCaches = GetRequiredService(htmlHelper); + IHostingEnvironment hostingEnvironment = GetRequiredService(htmlHelper); + + return appCaches.CachedPartialView( + hostingEnvironment, + umbracoContext!, + htmlHelper, + partialViewName, + model, + cacheTimeout, + cacheKey.ToString(), + viewData); + } + + // Internal for tests. + internal static async Task GenerateCacheKeyForCachedPartialViewAsync( + string partialViewName, + bool cacheByPage, + IUmbracoContext? umbracoContext, + bool cacheByMember, + IMemberManager? memberManager, + object model, + ViewDataDictionary? viewData, + Func? contextualKeyBuilder) + { + var cacheKey = new StringBuilder(partialViewName + "-"); // let's always cache by the current culture to allow variants to have different cache results var cultureName = Thread.CurrentThread.CurrentUICulture.Name; @@ -114,15 +152,12 @@ public static IHtmlContent PreviewBadge( cacheKey.AppendFormat("{0}-", cultureName); } - IUmbracoContextAccessor umbracoContextAccessor = GetRequiredService(htmlHelper); - umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext); - if (cacheByPage) { if (umbracoContext == null) { throw new InvalidOperationException( - "Cannot cache by page if the UmbracoContext has not been initialized, this parameter can only be used in the context of an Umbraco request"); + "Cannot cache by page if the UmbracoContext has not been initialized, this parameter can only be used in the context of an Umbraco request."); } cacheKey.AppendFormat("{0}-", umbracoContext.PublishedRequest?.PublishedContent?.Id ?? 0); @@ -130,8 +165,12 @@ public static IHtmlContent PreviewBadge( if (cacheByMember) { - IMemberManager memberManager = - htmlHelper.ViewContext.HttpContext.RequestServices.GetRequiredService(); + if (memberManager == null) + { + throw new InvalidOperationException( + "Cannot cache by member if the MemberManager is not available."); + } + MemberIdentityUser? currentMember = await memberManager.GetCurrentMemberAsync(); cacheKey.AppendFormat("m{0}-", currentMember?.Id ?? "0"); } @@ -142,18 +181,7 @@ public static IHtmlContent PreviewBadge( cacheKey.AppendFormat("c{0}-", contextualKey); } - AppCaches appCaches = GetRequiredService(htmlHelper); - IHostingEnvironment hostingEnvironment = GetRequiredService(htmlHelper); - - return appCaches.CachedPartialView( - hostingEnvironment, - umbracoContext!, - htmlHelper, - partialViewName, - model, - cacheTimeout, - cacheKey.ToString(), - viewData); + return cacheKey.ToString(); } // public static IHtmlContent EditorFor(this IHtmlHelper htmlHelper, string templateName = "", string htmlFieldName = "", object additionalViewData = null) diff --git a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs index c03512b4830d..f2fdfe212824 100644 --- a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs @@ -12,6 +12,7 @@ using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators; using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DistributedLocking; @@ -43,6 +44,8 @@ public static class UmbracoBuilderExtensions public static IUmbracoBuilder AddTestServices(this IUmbracoBuilder builder, TestHelper testHelper) { builder.Services.AddUnique(AppCaches.NoCache); + builder.Services.AddUnique(Mock.Of()); + builder.Services.AddUnique(Mock.Of()); builder.Services.AddUnique(testHelper.MainDom); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidatorTests.cs new file mode 100644 index 000000000000..3e67e27d4fca --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidatorTests.cs @@ -0,0 +1,76 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Website.Cache.PartialViewCacheInvalidators; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Website.Routing; + +[TestFixture] +public class MemberPartialViewCacheInvalidatorTests +{ + [Test] + public void ClearPartialViewCacheItems_Clears_ExpectedItems() + { + var runTimeCacheMock = new Mock(); + runTimeCacheMock + .Setup(x => x.ClearByRegex(It.IsAny())) + .Verifiable(); + var appCaches = new AppCaches( + runTimeCacheMock.Object, + NoAppCache.Instance, + new IsolatedCaches(type => new ObjectCacheAppCache())); + var memberPartialViewCacheInvalidator = new MemberPartialViewCacheInvalidator(appCaches); + + var memberIds = new[] { 1, 2, 3 }; + + memberPartialViewCacheInvalidator.ClearPartialViewCacheItems(memberIds); + + foreach (var memberId in memberIds) + { + var regex = $"Umbraco.Web.PartialViewCacheKey.*-m{memberId}-*"; + runTimeCacheMock + .Verify(x => x.ClearByRegex(It.Is(x => x == regex)), Times.Once); + } + } + + [Test] + public async Task ClearPartialViewCacheItems_Regex_Matches_CachedKeys() + { + const int MemberId = 1234; + + var memberManagerMock = new Mock(); + memberManagerMock + .Setup(x => x.GetCurrentMemberAsync()) + .ReturnsAsync(new MemberIdentityUser { Id = MemberId.ToString() }); + + var cacheKey = await HtmlHelperRenderExtensions.GenerateCacheKeyForCachedPartialViewAsync( + "TestPartial.cshtml", + true, + Mock.Of(), + true, + memberManagerMock.Object, + new TestViewModel(), + new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()), + null); + cacheKey = CoreCacheHelperExtensions.PartialViewCacheKey + cacheKey; + Assert.AreEqual("Umbraco.Web.PartialViewCacheKeyTestPartial.cshtml-en-US-0-m1234-", cacheKey); + + var regexForMember = $"Umbraco.Web.PartialViewCacheKey.*-m{MemberId}-*"; + var regexMatch = Regex.IsMatch(cacheKey, regexForMember); + Assert.IsTrue(regexMatch); + + var regexForAnotherMember = $"Umbraco.Web.PartialViewCacheKey.*-m{4321}-*"; + regexMatch = Regex.IsMatch(cacheKey, regexForAnotherMember); + Assert.IsFalse(regexMatch); + } + + private class TestViewModel + { + } +}