From a090997aa19a78821836d8a1f5e2237befed7fd5 Mon Sep 17 00:00:00 2001 From: Anders Bjerner Date: Tue, 27 Jun 2023 16:50:11 +0200 Subject: [PATCH 01/18] Added missing Danish translation for `general_change` (#14461) * Added missing Danish translation for `general_change` * Update en_us.xml --- src/Umbraco.Core/EmbeddedResources/Lang/da.xml | 1 + src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml index 049d7477e7..f621d27fa6 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml @@ -719,6 +719,7 @@ af Fortryd Celle margen + Skift Vælg Ryd Luk diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index c7ad610ef0..38c4916e31 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -759,6 +759,7 @@ by Cancel Cell margin + Change Choose Clear Close From 1d14158d82ce5d9e3ed9766e8e11faddb455be19 Mon Sep 17 00:00:00 2001 From: Nikolaj Brask-Nielsen Date: Tue, 27 Jun 2023 18:18:07 +0200 Subject: [PATCH 02/18] fix: Allows copying from unsaved content (#14464) #14459 --- .../src/common/services/clipboard.service.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js b/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js index abf173b129..238d9a8ee6 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/clipboard.service.js @@ -344,10 +344,14 @@ function clipboardService($window, notificationsService, eventsService, localSto // Clean up each entry var copiedDatas = datas.map(data => prepareEntryForStorage(type, data, firstLevelClearupMethod)); - // remove previous copies of this entry: + // remove previous copies of this entry (Make sure to not remove copies from unsaved content): storage.entries = storage.entries.filter( (entry) => { - return entry.unique !== uniqueKey; + if (entry.unique === 0) { + return displayLabel !== entry.label; + } else { + return entry.unique !== uniqueKey; + } } ); From 1d239a30ca0bf741316fe73f2b307876eaad0264 Mon Sep 17 00:00:00 2001 From: Maarten Date: Tue, 4 Jul 2023 09:37:13 +0200 Subject: [PATCH 03/18] Fix broken CookieAuthenticationRedirect caused by PR #14036 for non-api requests (#14399) * Fix broken CookieAuthenticationRedirect caused by PR #14036 when not in an API controller * Added Integration Tests for the MemberAuthorizationFilter * Fix merge conflict --------- Co-authored-by: Elitsa --- .../Filters/UmbracoMemberAuthorizeFilter.cs | 6 +- .../Security/ConfigureMemberCookieOptions.cs | 13 +- .../Security/MemberAuthorizeTests.cs | 126 ++++++++++++++++++ 3 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Web.Website/Security/MemberAuthorizeTests.cs diff --git a/src/Umbraco.Web.Common/Filters/UmbracoMemberAuthorizeFilter.cs b/src/Umbraco.Web.Common/Filters/UmbracoMemberAuthorizeFilter.cs index 95c4ae5cec..2f56cdb51f 100644 --- a/src/Umbraco.Web.Common/Filters/UmbracoMemberAuthorizeFilter.cs +++ b/src/Umbraco.Web.Common/Filters/UmbracoMemberAuthorizeFilter.cs @@ -60,14 +60,14 @@ public class UmbracoMemberAuthorizeFilter : IAsyncAuthorizationFilter { context.HttpContext.SetReasonPhrase( "Resource restricted: the member is not of a permitted type or group."); + context.HttpContext.Response.StatusCode = 403; context.Result = new ForbidResult(); } } else { - context.HttpContext.SetReasonPhrase( - "Resource restricted: the member is not logged in."); - context.Result = new UnauthorizedResult(); + context.HttpContext.Response.StatusCode = 401; + context.Result = new ForbidResult(); } } diff --git a/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs b/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs index b8c2874641..1ba9a52526 100644 --- a/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs +++ b/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs @@ -1,10 +1,12 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Controllers; using Umbraco.Extensions; namespace Umbraco.Cms.Web.Common.Security; @@ -58,7 +60,16 @@ public sealed class ConfigureMemberCookieOptions : IConfigureNamedOptions { - ctx.Response.StatusCode = StatusCodes.Status403Forbidden; + // When the controller is an UmbracoAPIController, we want to return a StatusCode instead of a redirect. + // All other cases should use the default Redirect of the CookieAuthenticationEvent. + var controllerDescriptor = ctx.HttpContext.GetEndpoint()?.Metadata + .OfType() + .FirstOrDefault(); + + if (!controllerDescriptor?.ControllerTypeInfo.IsSubclassOf(typeof(UmbracoApiController)) ?? false) + { + new CookieAuthenticationEvents().OnRedirectToAccessDenied(ctx); + } return Task.CompletedTask; }, diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.Website/Security/MemberAuthorizeTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.Website/Security/MemberAuthorizeTests.cs new file mode 100644 index 0000000000..0fc1dfa85d --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.Website/Security/MemberAuthorizeTests.cs @@ -0,0 +1,126 @@ +using System.Net; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Tests.Integration.TestServerTest; +using Umbraco.Cms.Web.Common.Controllers; +using Umbraco.Cms.Web.Common.Filters; +using Umbraco.Cms.Web.Common.Security; +using Umbraco.Cms.Web.Website.Controllers; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.Website.Security +{ + public class MemberAuthorizeTests : UmbracoTestServerTestBase + { + private Mock _memberManagerMock = new(); + + protected override void ConfigureTestServices(IServiceCollection services) + { + _memberManagerMock = new Mock(); + services.Remove(new ServiceDescriptor(typeof(IMemberManager), typeof(MemberManager), ServiceLifetime.Scoped)); + services.Remove(new ServiceDescriptor(typeof(MemberManager), ServiceLifetime.Scoped)); + services.AddScoped(_ => _memberManagerMock.Object); + } + + [Test] + public async Task Secure_SurfaceController_Should_Return_Redirect_WhenNotLoggedIn() + { + _memberManagerMock.Setup(x => x.IsLoggedIn()).Returns(false); + + var url = PrepareSurfaceControllerUrl(x => x.Secure()); + + var response = await Client.GetAsync(url); + + var cookieAuthenticationOptions = Services.GetService>(); + Assert.AreEqual(HttpStatusCode.Redirect, response.StatusCode); + Assert.AreEqual(cookieAuthenticationOptions.Value.AccessDeniedPath.ToString(), response.Headers.Location?.AbsolutePath); + } + + [Test] + public async Task Secure_SurfaceController_Should_Return_Redirect_WhenNotAuthorized() + { + _memberManagerMock.Setup(x => x.IsLoggedIn()).Returns(true); + _memberManagerMock.Setup(x => x.IsMemberAuthorizedAsync( + It.IsAny>(), + It.IsAny>(), + It.IsAny>())) + .ReturnsAsync(false); + + var url = PrepareSurfaceControllerUrl(x => x.Secure()); + + var response = await Client.GetAsync(url); + + var cookieAuthenticationOptions = Services.GetService>(); + Assert.AreEqual(HttpStatusCode.Redirect, response.StatusCode); + Assert.AreEqual(cookieAuthenticationOptions.Value.AccessDeniedPath.ToString(), response.Headers.Location?.AbsolutePath); + } + + + [Test] + public async Task Secure_ApiController_Should_Return_Unauthorized_WhenNotLoggedIn() + { + _memberManagerMock.Setup(x => x.IsLoggedIn()).Returns(false); + var url = PrepareApiControllerUrl(x => x.Secure()); + + var response = await Client.GetAsync(url); + + Assert.AreEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Test] + public async Task Secure_ApiController_Should_Return_Forbidden_WhenNotAuthorized() + { + _memberManagerMock.Setup(x => x.IsLoggedIn()).Returns(true); + _memberManagerMock.Setup(x => x.IsMemberAuthorizedAsync( + It.IsAny>(), + It.IsAny>(), + It.IsAny>())) + .ReturnsAsync(false); + + var url = PrepareApiControllerUrl(x => x.Secure()); + + var response = await Client.GetAsync(url); + + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + } + } + + public class TestSurfaceController : SurfaceController + { + public TestSurfaceController( + IUmbracoContextAccessor umbracoContextAccessor, + IUmbracoDatabaseFactory databaseFactory, + ServiceContext services, + AppCaches appCaches, + IProfilingLogger profilingLogger, + IPublishedUrlProvider publishedUrlProvider) + : base( + umbracoContextAccessor, + databaseFactory, + services, + appCaches, + profilingLogger, + publishedUrlProvider) + { + } + + [UmbracoMemberAuthorize] + public IActionResult Secure() => NoContent(); + } + + public class TestApiController : UmbracoApiController + { + [UmbracoMemberAuthorize] + public IActionResult Secure() => NoContent(); + } +} From 41805af0d9a3793d2276387d079010e61ffb84ff Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Thu, 6 Jul 2023 07:51:49 +0200 Subject: [PATCH 04/18] Updated the package validation baseline version to 12.0.0 (#14389) * Updated the package validation baseline version to 12 RC1 * Update PackageValidationBaselineVersion to 12.0.0 and remove CompatibilitySuppressions.xml files * Clean up project files --------- Co-authored-by: Ronald Barendse --- Directory.Build.props | 2 +- .../Umbraco.Cms.Api.Common.csproj | 11 +- .../Umbraco.Cms.Api.Delivery.csproj | 6 -- .../Umbraco.Cms.Imaging.ImageSharp2.csproj | 2 - ...co.Cms.Persistence.EFCore.SqlServer.csproj | 5 +- ...braco.Cms.Persistence.EFCore.Sqlite.csproj | 3 +- .../Umbraco.Cms.Persistence.EFCore.csproj | 6 +- .../Umbraco.Cms.Targets.csproj | 2 - .../CompatibilitySuppressions.xml | 67 ------------ .../CompatibilitySuppressions.xml | 102 ------------------ .../Umbraco.Web.BackOffice.csproj | 1 - src/Umbraco.Web.UI/Umbraco.Web.UI.csproj | 1 + .../Umbraco.Web.Website.csproj | 1 - tests/Directory.Build.props | 7 +- .../Umbraco.TestData/Umbraco.TestData.csproj | 6 -- .../Umbraco.Tests.Benchmarks.csproj | 3 - .../Umbraco.Tests.Common.csproj | 1 + .../Umbraco.Tests.Integration.csproj | 4 +- .../Umbraco.Tests.UnitTests.csproj | 2 - 19 files changed, 24 insertions(+), 208 deletions(-) delete mode 100644 src/Umbraco.Core/CompatibilitySuppressions.xml delete mode 100644 src/Umbraco.Infrastructure/CompatibilitySuppressions.xml diff --git a/Directory.Build.props b/Directory.Build.props index 01cd3b1064..c8418ebedb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -31,7 +31,7 @@ false true - 11.0.0 + 12.0.0 true true diff --git a/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj b/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj index edf3fd1f98..e2d7c8e38d 100644 --- a/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj +++ b/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj @@ -2,17 +2,18 @@ Umbraco CMS - API Common Contains the bits and pieces that are shared between the Umbraco CMS APIs. - true - false - Umbraco.Cms.Api.Common - Umbraco.Cms.Api.Common + + + + + - + diff --git a/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj b/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj index 33a3105b73..a37f74f541 100644 --- a/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj +++ b/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj @@ -2,14 +2,8 @@ Umbraco CMS - Delivery API Contains the presentation layer for the Umbraco CMS Delivery API. - true - false - Umbraco.Cms.Api.Delivery - Umbraco.Cms.Api.Delivery - Umbraco.Cms.Api.Delivery - diff --git a/src/Umbraco.Cms.Imaging.ImageSharp2/Umbraco.Cms.Imaging.ImageSharp2.csproj b/src/Umbraco.Cms.Imaging.ImageSharp2/Umbraco.Cms.Imaging.ImageSharp2.csproj index dc0299defd..14c203bad6 100644 --- a/src/Umbraco.Cms.Imaging.ImageSharp2/Umbraco.Cms.Imaging.ImageSharp2.csproj +++ b/src/Umbraco.Cms.Imaging.ImageSharp2/Umbraco.Cms.Imaging.ImageSharp2.csproj @@ -2,8 +2,6 @@ Umbraco CMS - Imaging - ImageSharp 2 Adds imaging support using ImageSharp/ImageSharp.Web version 2 to Umbraco CMS. - - false diff --git a/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj b/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj index 946c08556f..f710397004 100644 --- a/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj +++ b/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj @@ -1,6 +1,7 @@ - + - Umbraco CMS - EF Core - SqlServer migrations + Umbraco CMS - Persistence - Entity Framework Core - SQL Server migrations + Adds support for Entity Framework Core SQL Server migrations to Umbraco CMS. false diff --git a/src/Umbraco.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj b/src/Umbraco.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj index f95f1cd1e1..6087a86746 100644 --- a/src/Umbraco.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj +++ b/src/Umbraco.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj @@ -1,6 +1,7 @@  - Umbraco CMS - EF Core - Sqlite migrations + Umbraco CMS - Persistence - Entity Framework Core - SQLite migrations + Adds support for Entity Framework Core SQLite migrations to Umbraco CMS. false diff --git a/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj index f8e3851ccd..afeab8aa89 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj +++ b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj @@ -1,8 +1,7 @@ - Umbraco CMS - Persistence - EFCore - - false + Umbraco CMS - Persistence - Entity Framework Core + Adds support for Entity Framework Core to Umbraco CMS. @@ -21,5 +20,4 @@ <_Parameter1>Umbraco.Tests.Integration - diff --git a/src/Umbraco.Cms.Targets/Umbraco.Cms.Targets.csproj b/src/Umbraco.Cms.Targets/Umbraco.Cms.Targets.csproj index e7dcdd5bec..37653bc9a8 100644 --- a/src/Umbraco.Cms.Targets/Umbraco.Cms.Targets.csproj +++ b/src/Umbraco.Cms.Targets/Umbraco.Cms.Targets.csproj @@ -4,8 +4,6 @@ Installs Umbraco CMS with minimal dependencies in your ASP.NET Core project. false false - - false diff --git a/src/Umbraco.Core/CompatibilitySuppressions.xml b/src/Umbraco.Core/CompatibilitySuppressions.xml deleted file mode 100644 index f486c5dc88..0000000000 --- a/src/Umbraco.Core/CompatibilitySuppressions.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - CP0001 - T:Umbraco.New.Cms.Core.Models.PagedModel`1 - lib/net7.0/Umbraco.Core.dll - lib/net7.0/Umbraco.Core.dll - true - - - CP0005 - M:Umbraco.Cms.Core.Models.PublishedContent.PublishedPropertyBase.GetDeliveryApiValue(System.Boolean,System.String,System.String) - lib/net7.0/Umbraco.Core.dll - lib/net7.0/Umbraco.Core.dll - true - - - CP0006 - M:Umbraco.Cms.Core.Events.IEventAggregator.Publish``2(System.Collections.Generic.IEnumerable{``0}) - lib/net7.0/Umbraco.Core.dll - lib/net7.0/Umbraco.Core.dll - true - - - CP0006 - M:Umbraco.Cms.Core.Events.IEventAggregator.PublishAsync``2(System.Collections.Generic.IEnumerable{``0},System.Threading.CancellationToken) - lib/net7.0/Umbraco.Core.dll - lib/net7.0/Umbraco.Core.dll - true - - - CP0006 - M:Umbraco.Cms.Core.Models.PublishedContent.IPublishedProperty.GetDeliveryApiValue(System.Boolean,System.String,System.String) - lib/net7.0/Umbraco.Core.dll - lib/net7.0/Umbraco.Core.dll - true - - - CP0006 - M:Umbraco.Cms.Core.Models.PublishedContent.IPublishedPropertyType.ConvertInterToDeliveryApiObject(Umbraco.Cms.Core.Models.PublishedContent.IPublishedElement,Umbraco.Cms.Core.PropertyEditors.PropertyCacheLevel,System.Object,System.Boolean,System.Boolean) - lib/net7.0/Umbraco.Core.dll - lib/net7.0/Umbraco.Core.dll - true - - - CP0006 - M:Umbraco.Cms.Core.Models.PublishedContent.IPublishedPropertyType.ConvertInterToDeliveryApiObject(Umbraco.Cms.Core.Models.PublishedContent.IPublishedElement,Umbraco.Cms.Core.PropertyEditors.PropertyCacheLevel,System.Object,System.Boolean) - lib/net7.0/Umbraco.Core.dll - lib/net7.0/Umbraco.Core.dll - true - - - CP0006 - P:Umbraco.Cms.Core.Models.PublishedContent.IPublishedPropertyType.DeliveryApiCacheLevel - lib/net7.0/Umbraco.Core.dll - lib/net7.0/Umbraco.Core.dll - true - - - CP0006 - P:Umbraco.Cms.Core.Scoping.ICoreScope.Locks - lib/net7.0/Umbraco.Core.dll - lib/net7.0/Umbraco.Core.dll - true - - \ No newline at end of file diff --git a/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml b/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml deleted file mode 100644 index a9076b112d..0000000000 --- a/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - CP0001 - T:Umbraco.Cms.Core.Cache.DistributedCacheBinder - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0001 - T:Umbraco.Cms.Infrastructure.Migrations.PostMigrations.ClearCsrfCookies - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0001 - T:Umbraco.Cms.Infrastructure.Migrations.PostMigrations.DeleteLogViewerQueryFile - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0001 - T:Umbraco.Cms.Infrastructure.Migrations.PostMigrations.RebuildPublishedSnapshot - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0001 - T:Umbraco.Extensions.DistributedCacheExtensions - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0002 - M:Umbraco.Cms.Core.Migrations.IMigrationPlanExecutor.Execute(Umbraco.Cms.Infrastructure.Migrations.MigrationPlan,System.String) - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0002 - M:Umbraco.Cms.Infrastructure.Migrations.IMigrationContext.AddPostMigration``1 - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0002 - M:Umbraco.Cms.Infrastructure.Migrations.MigrationPlan.AddPostMigration``1 - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0002 - M:Umbraco.Cms.Infrastructure.Migrations.MigrationPlan.get_PostMigrationTypes - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0002 - M:Umbraco.Cms.Infrastructure.Migrations.MigrationPlanExecutor.Execute(Umbraco.Cms.Infrastructure.Migrations.MigrationPlan,System.String) - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0002 - M:Umbraco.Cms.Infrastructure.Migrations.Upgrade.Upgrader.Execute(Umbraco.Cms.Core.Migrations.IMigrationPlanExecutor,Umbraco.Cms.Core.Scoping.IScopeProvider,Umbraco.Cms.Core.Services.IKeyValueService) - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0006 - M:Umbraco.Cms.Core.Migrations.IMigrationPlanExecutor.ExecutePlan(Umbraco.Cms.Infrastructure.Migrations.MigrationPlan,System.String) - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0006 - M:Umbraco.Cms.Infrastructure.Search.IUmbracoIndexingHandler.RemoveProtectedContent - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - - CP0006 - P:Umbraco.Cms.Infrastructure.Examine.IUmbracoIndex.SupportProtectedContent - lib/net7.0/Umbraco.Infrastructure.dll - lib/net7.0/Umbraco.Infrastructure.dll - true - - diff --git a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj index 00e6c46273..0fe0a3784f 100644 --- a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj +++ b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj @@ -3,7 +3,6 @@ Umbraco.Cms.Web.BackOffice Umbraco CMS - Web - Backoffice Contains the backoffice assembly needed to run the backend of Umbraco CMS. - Library Umbraco.Cms.Web.BackOffice diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index bc05a391cc..5f359bf99e 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -1,6 +1,7 @@ Umbraco.Cms.Web.UI + false false diff --git a/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj b/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj index 0d4a775a95..4b71708b53 100644 --- a/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj +++ b/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj @@ -3,7 +3,6 @@ Umbraco.Cms.Web.Website Umbraco CMS - Web - Website Contains the website assembly needed to run the frontend of Umbraco CMS. - Library Umbraco.Cms.Web.Website diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index a773c6a1c9..ec220ec3a8 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -2,10 +2,15 @@ + + + false + false + + annotations - false diff --git a/tests/Umbraco.TestData/Umbraco.TestData.csproj b/tests/Umbraco.TestData/Umbraco.TestData.csproj index 64772d371e..fd5335ecc6 100644 --- a/tests/Umbraco.TestData/Umbraco.TestData.csproj +++ b/tests/Umbraco.TestData/Umbraco.TestData.csproj @@ -1,10 +1,4 @@ - - Umbraco.TestData - false - false - - diff --git a/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj b/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj index f338b443f3..1bc14388f0 100644 --- a/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj +++ b/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj @@ -1,9 +1,6 @@ Exe - false - false - false diff --git a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj index 835bde8848..26d1baf85b 100644 --- a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj +++ b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj @@ -4,6 +4,7 @@ Umbraco CMS - Test tools Contains commonly used tools to write tests for Umbraco CMS, such as various builders for content etc. Umbraco.Cms.Tests.Common + true true diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 6ef2c2a70f..069fb7e5a7 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -1,11 +1,11 @@ + true Umbraco.Cms.Tests.Integration Umbraco CMS - Integration tests Contains helper classes for integration tests with Umbraco CMS, including all internal integration tests. - true - true Umbraco.Cms.Tests.Integration + true true diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index 5efd49eedf..8e7d3175f5 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -2,8 +2,6 @@ true Umbraco.Cms.Tests.UnitTests - false - false From 0da33d064ae13caed5a5390e4f269629bfd91f04 Mon Sep 17 00:00:00 2001 From: Ibrahim Muhammad Nada Date: Thu, 6 Jul 2023 13:29:18 +0300 Subject: [PATCH 05/18] Fixes #12904 UmbracoHelper.GetDictionaryValue defaults to en-US when used in non-front end code (#12942) * adding new overload/rename a method * remove this keyword * fix comment * remove space * commit * revert * replace param name * public backward compatibility * Minor style tweaks * Don't change default culture in UmbracoCultureDictionary --------- Co-authored-by: Nikolaj --- .../Dictionary/ICultureDictionaryFactory.cs | 4 ++ .../Dictionary/UmbracoCultureDictionary.cs | 3 +- .../UmbracoCultureDictionaryFactory.cs | 4 ++ src/Umbraco.Web.Common/UmbracoHelper.cs | 52 +++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Core/Dictionary/ICultureDictionaryFactory.cs b/src/Umbraco.Core/Dictionary/ICultureDictionaryFactory.cs index 6cb2642b15..242cfb8e35 100644 --- a/src/Umbraco.Core/Dictionary/ICultureDictionaryFactory.cs +++ b/src/Umbraco.Core/Dictionary/ICultureDictionaryFactory.cs @@ -1,6 +1,10 @@ +using System.Globalization; + namespace Umbraco.Cms.Core.Dictionary; public interface ICultureDictionaryFactory { ICultureDictionary CreateDictionary(); + + ICultureDictionary CreateDictionary(CultureInfo specificCulture) => throw new NotImplementedException(); } diff --git a/src/Umbraco.Core/Dictionary/UmbracoCultureDictionary.cs b/src/Umbraco.Core/Dictionary/UmbracoCultureDictionary.cs index de968f1676..36e1acbce8 100644 --- a/src/Umbraco.Core/Dictionary/UmbracoCultureDictionary.cs +++ b/src/Umbraco.Core/Dictionary/UmbracoCultureDictionary.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.RegularExpressions; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -47,7 +48,7 @@ internal class DefaultCultureDictionary : ICultureDictionary } /// - /// Returns the current culture + /// Returns the defualt umbraco's back office culture /// public CultureInfo Culture => _specificCulture ?? Thread.CurrentThread.CurrentUICulture; diff --git a/src/Umbraco.Core/Dictionary/UmbracoCultureDictionaryFactory.cs b/src/Umbraco.Core/Dictionary/UmbracoCultureDictionaryFactory.cs index 4c4eb030cc..2f00114c13 100644 --- a/src/Umbraco.Core/Dictionary/UmbracoCultureDictionaryFactory.cs +++ b/src/Umbraco.Core/Dictionary/UmbracoCultureDictionaryFactory.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Services; @@ -23,4 +24,7 @@ public class DefaultCultureDictionaryFactory : ICultureDictionaryFactory public ICultureDictionary CreateDictionary() => new DefaultCultureDictionary(_localizationService, _appCaches.RequestCache); + + public ICultureDictionary CreateDictionary(CultureInfo specificCulture) => + new DefaultCultureDictionary(specificCulture, _localizationService, _appCaches.RequestCache); } diff --git a/src/Umbraco.Web.Common/UmbracoHelper.cs b/src/Umbraco.Web.Common/UmbracoHelper.cs index 56181b29da..2cb2ea8002 100644 --- a/src/Umbraco.Web.Common/UmbracoHelper.cs +++ b/src/Umbraco.Web.Common/UmbracoHelper.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.Xml.XPath; +using Serilog.Events; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Dictionary; using Umbraco.Cms.Core.Models.PublishedContent; @@ -139,12 +141,43 @@ public class UmbracoHelper /// public string? GetDictionaryValue(string key) => CultureDictionary[key]; + + /// + /// Returns the dictionary value for the key specified, and if empty returns the specified default fall back value + /// + /// key of dictionary item + /// the specific culture on which the result well be back upon + /// + public string? GetDictionaryValue(string key, CultureInfo specificCulture) + { + _cultureDictionary = _cultureDictionaryFactory.CreateDictionary(specificCulture); + return GetDictionaryValue(key); + } + + /// + /// Returns the dictionary value for the key specified, and if empty returns the specified default fall back value + /// + /// key of dictionary item + /// fall back text if dictionary item is empty - Name altText to match Umbraco.Field + /// + public string GetDictionaryValueOrDefault(string key, string defaultValue) + { + var dictionaryValue = GetDictionaryValue(key); + if (string.IsNullOrWhiteSpace(dictionaryValue)) + { + dictionaryValue = defaultValue; + } + + return dictionaryValue; + } + /// /// Returns the dictionary value for the key specified, and if empty returns the specified default fall back value /// /// key of dictionary item /// fall back text if dictionary item is empty - Name altText to match Umbraco.Field /// + [Obsolete("Use GetDictionaryValueOrDefault instead, scheduled for removal in v14.")] public string GetDictionaryValue(string key, string altText) { var dictionaryValue = GetDictionaryValue(key); @@ -156,6 +189,25 @@ public class UmbracoHelper return dictionaryValue; } + /// + /// Returns the dictionary value for the key specified, and if empty returns the specified default fall back value + /// + /// key of dictionary item + /// the specific culture on which the result well be back upon + /// fall back text if dictionary item is empty - Name altText to match Umbraco.Field + /// + public string GetDictionaryValueOrDefault(string key, CultureInfo specificCulture, string defaultValue) + { + _cultureDictionary = _cultureDictionaryFactory.CreateDictionary(specificCulture); + var dictionaryValue = GetDictionaryValue(key); + if (string.IsNullOrWhiteSpace(dictionaryValue)) + { + dictionaryValue = defaultValue; + } + return dictionaryValue; + } + + /// /// Returns the ICultureDictionary for access to dictionary items /// From 0c595ccc5f88750a8f547e4bbbe58c457864094d Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Thu, 6 Jul 2023 18:22:49 +0200 Subject: [PATCH 06/18] Only show groups when any filtered results in group (#14505) --- .../blockpicker/blockpicker.controller.js | 27 ++++++++++++------- .../blockpicker/blockpicker.html | 10 +++---- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.controller.js index b453127613..cae4b803d8 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.controller.js @@ -3,11 +3,14 @@ angular.module("umbraco") function ($scope, localizationService, $filter) { var unsubscribe = []; - var vm = this; + const vm = this; vm.navigation = []; - vm.filterSearchTerm = ''; + vm.filter = { + searchTerm: "" + }; + vm.filteredItems = []; // Ensure groupKey value, as we need it to be present for the filtering logic. @@ -15,12 +18,19 @@ angular.module("umbraco") item.blockConfigModel.groupKey = item.blockConfigModel.groupKey || null; }); - unsubscribe.push($scope.$watch('vm.filterSearchTerm', updateFiltering)); + unsubscribe.push($scope.$watch('vm.filter.searchTerm', updateFiltering)); function updateFiltering() { - vm.filteredItems = $filter('umbCmsBlockCard')($scope.model.availableItems, vm.filterSearchTerm); + vm.filteredItems = $filter('umbCmsBlockCard')($scope.model.availableItems, vm.filter.searchTerm); } + vm.filterByGroup = function (group) { + + const items = $filter('filter')(vm.filteredItems, { blockConfigModel: { groupKey: group?.key || null } }); + + return items; + }; + localizationService.localizeMany(["blockEditor_tabCreateEmpty", "blockEditor_tabClipboard"]).then( function (data) { @@ -47,9 +57,7 @@ angular.module("umbraco") } else { vm.activeTab = vm.navigation[0]; } - - - + vm.activeTab.active = true; } ); @@ -61,12 +69,13 @@ angular.module("umbraco") }; vm.clickClearClipboard = function () { - vm.model.clipboardItems = [];// This dialog is not connected via the clipboardService events, so we need to update manually. + vm.model.clipboardItems = []; // This dialog is not connected via the clipboardService events, so we need to update manually. vm.model.clickClearClipboard(); + if (vm.model.singleBlockMode !== true && vm.model.openClipboard !== true) { vm.onNavigationChanged(vm.navigation[0]); - vm.navigation[1].disabled = true;// disabled ws determined when creating the navigation, so we need to update it here. + vm.navigation[1].disabled = true; // disabled ws determined when creating the navigation, so we need to update it here. } else { vm.close(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html index 2a84fad343..b7b38797da 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/blockpicker/blockpicker.html @@ -19,7 +19,7 @@
-
+
@@ -39,14 +39,14 @@
-
+
{{blockGroup.name}}
From dd697ab71c6c9340f4ed5fd82d3028959e473178 Mon Sep 17 00:00:00 2001 From: Sebastiaan Janssen Date: Tue, 11 Jul 2023 12:58:11 +0200 Subject: [PATCH 07/18] Delete add-issues-to-review-project.yml --- .../add-issues-to-review-project.yml | 58 ------------------- 1 file changed, 58 deletions(-) delete mode 100644 .github/workflows/add-issues-to-review-project.yml diff --git a/.github/workflows/add-issues-to-review-project.yml b/.github/workflows/add-issues-to-review-project.yml deleted file mode 100644 index 0d89451373..0000000000 --- a/.github/workflows/add-issues-to-review-project.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Add issues to review project - -on: - issues: - types: - - opened - -permissions: - contents: read - -jobs: - get-user-type: - runs-on: ubuntu-latest - outputs: - ignored: ${{ steps.set-output.outputs.ignored }} - steps: - - name: Install dependencies - run: | - npm install node-fetch@2 - - uses: actions/github-script@v5 - name: "Determing HQ user or not" - id: set-output - with: - script: | - const fetch = require('node-fetch'); - const response = await fetch('https://collaboratorsv2.euwest01.umbraco.io/umbraco/api/users/IsIgnoredUser', { - method: 'post', - body: JSON.stringify('${{ github.event.issue.user.login }}'), - headers: { - 'Authorization': 'Bearer ${{ secrets.OUR_BOT_API_TOKEN }}', - 'Content-Type': 'application/json' - } - }); - - var isIgnoredUser = true; - try { - if(response.status === 200) { - const data = await response.text(); - isIgnoredUser = data === "true"; - } else { - console.log("Returned data not indicate success:", response.status); - } - } catch(error) { - console.log(error); - }; - core.setOutput("ignored", isIgnoredUser); - console.log("Ignored is", isIgnoredUser); - add-to-project: - permissions: - repository-projects: write # for actions/add-to-project - if: needs.get-user-type.outputs.ignored == 'false' - runs-on: ubuntu-latest - needs: [get-user-type] - steps: - - uses: actions/add-to-project@main - with: - project-url: https://github.com/orgs/${{ github.repository_owner }}/projects/21 - github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} From 2b629693950818865a9f8ee316ec7bc1538d287f Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Tue, 11 Jul 2023 16:14:19 +0200 Subject: [PATCH 08/18] Make it possible to remove blocks using tab key in block grid configuration (#14518) --- ...blockgrid.blockconfiguration.controller.js | 29 ++++++++++++------- .../blockgrid.blockconfiguration.html | 20 +++++++------ ...blocklist.blockconfiguration.controller.js | 12 ++++---- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.controller.js index 85c31dfc6b..ade5e9829a 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.controller.js @@ -114,22 +114,29 @@ }); } - vm.requestRemoveBlockByIndex = function (index) { - localizationService.localizeMany(["general_delete", "blockEditor_confirmDeleteBlockTypeMessage", "blockEditor_confirmDeleteBlockTypeNotice"]).then(function (data) { + vm.requestRemoveBlockByIndex = function (index, event) { + + const labelKeys = [ + "general_delete", + "blockEditor_confirmDeleteBlockTypeMessage", + "blockEditor_confirmDeleteBlockTypeNotice" + ]; + + localizationService.localizeMany(labelKeys).then(data => { var contentElementType = vm.getElementTypeByKey($scope.model.value[index].contentElementTypeKey); overlayService.confirmDelete({ title: data[0], content: localizationService.tokenReplace(data[1], [contentElementType ? contentElementType.name : "(Unavailable ElementType)"]), confirmMessage: data[2], - close: function () { - overlayService.close(); - }, - submit: function () { + submit: () => { vm.removeBlockByIndex(index); overlayService.close(); - } + }, + close: overlayService.close() }); }); + + event.stopPropagation(); } vm.removeBlockByIndex = function (index) { @@ -164,7 +171,7 @@ placeholder: '--sortable-placeholder', forcePlaceHolderSize: true, stop: function(e, ui) { - if(ui.item.sortable.droptarget && ui.item.sortable.droptarget.length > 0) { + if (ui.item.sortable.droptarget && ui.item.sortable.droptarget.length > 0) { // We do not want sortable to actually move the data, as we are using the same ng-model. Instead we just change the groupKey and cancel the transfering. ui.item.sortable.model.groupKey = ui.item.sortable.droptarget[0].dataset.groupKey || null; ui.item.sortable.cancel(); @@ -346,7 +353,7 @@ // Then remove group: const groupIndex = vm.blockGroups.indexOf(blockGroup); - if(groupIndex !== -1) { + if (groupIndex !== -1) { vm.blockGroups.splice(groupIndex, 1); removeReferencesToGroupKey(blockGroup.key); } @@ -375,7 +382,7 @@ const groupName = "Demo Blocks"; var sampleGroup = vm.blockGroups.find(x => x.name === groupName); - if(!sampleGroup) { + if (!sampleGroup) { sampleGroup = { key: String.CreateGuid(), name: groupName @@ -394,6 +401,7 @@ initSampleBlock(data.umbBlockGridDemoHeadlineBlock, sampleGroup.key, {"label": "Headline ({{headline | truncate:true:36}})", "view": "~/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoHeadlineBlock.html"}); initSampleBlock(data.umbBlockGridDemoImageBlock, sampleGroup.key, {"label": "Image", "view": "~/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoImageBlock.html"}); initSampleBlock(data.umbBlockGridDemoRichTextBlock, sampleGroup.key, { "label": "Rich Text ({{richText | ncRichText | truncate:true:36}})", "view": "~/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoRichTextBlock.html"}); + const twoColumnLayoutAreas = [ { 'key': String.CreateGuid(), @@ -414,6 +422,7 @@ 'specifiedAllowance': [] } ]; + initSampleBlock(data.umbBlockGridDemoTwoColumnLayoutBlock, sampleGroup.key, {"label": "Two Column Layout", "view": "~/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoTwoColumnLayoutBlock.html", "allowInAreas": false, "areas": twoColumnLayoutAreas}); vm.showSampleDataCTA = false; diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.html index e78d94d486..a30ef4e8db 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.html @@ -19,12 +19,12 @@ ng-class="{'--isOpen':vm.openBlock === block}" ng-click="vm.openBlockOverlay(block)" data-content-element-type-key="{{block.contentElementTypeKey}}"> -
- - @@ -46,11 +46,13 @@
-
-
- +
+ +
+ + - diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.controller.js index 93d4398125..63ab76b553 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.controller.js @@ -51,13 +51,13 @@ vm.requestRemoveBlockByIndex = function (index, event) { - const labelKeys = [ - "general_delete", - "blockEditor_confirmDeleteBlockTypeMessage", - "blockEditor_confirmDeleteBlockTypeNotice" - ]; + const labelKeys = [ + "general_delete", + "blockEditor_confirmDeleteBlockTypeMessage", + "blockEditor_confirmDeleteBlockTypeNotice" + ]; - localizationService.localizeMany(labelKeys).then(data => { + localizationService.localizeMany(labelKeys).then(data => { var contentElementType = vm.getElementTypeByKey($scope.model.value[index].contentElementTypeKey); overlayService.confirmDelete({ title: data[0], From 99263113a00ebda7c26bf129fdcd413398f6f182 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Jul 2023 01:31:36 +0000 Subject: [PATCH 09/18] Bump tough-cookie from 4.1.2 to 4.1.3 in /src/Umbraco.Web.UI.Client Bumps [tough-cookie](https://github.com/salesforce/tough-cookie) from 4.1.2 to 4.1.3. - [Release notes](https://github.com/salesforce/tough-cookie/releases) - [Changelog](https://github.com/salesforce/tough-cookie/blob/master/CHANGELOG.md) - [Commits](https://github.com/salesforce/tough-cookie/compare/v4.1.2...v4.1.3) --- updated-dependencies: - dependency-name: tough-cookie dependency-type: indirect ... Signed-off-by: dependabot[bot] --- src/Umbraco.Web.UI.Client/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 24ff76f74e..c088148f80 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -16470,9 +16470,9 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", - "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", From 531ad2e4d56de7f2bee281ad7e66c7d0a5f26051 Mon Sep 17 00:00:00 2001 From: Nuklon Date: Mon, 17 Jul 2023 18:35:44 +0200 Subject: [PATCH 10/18] Improve Enum performance (#14384) --- src/Umbraco.Core/Enum.cs | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/Umbraco.Core/Enum.cs b/src/Umbraco.Core/Enum.cs index 6084dfe971..03dd0d51bc 100644 --- a/src/Umbraco.Core/Enum.cs +++ b/src/Umbraco.Core/Enum.cs @@ -22,7 +22,7 @@ public static class Enum IntToValue = new Dictionary(); ValueToName = new Dictionary(); SensitiveNameToValue = new Dictionary(); - InsensitiveNameToValue = new Dictionary(); + InsensitiveNameToValue = new Dictionary(StringComparer.InvariantCultureIgnoreCase); foreach (T value in Values) { @@ -31,15 +31,15 @@ public static class Enum IntToValue[Convert.ToInt32(value)] = value; ValueToName[value] = name!; SensitiveNameToValue[name!] = value; - InsensitiveNameToValue[name!.ToLowerInvariant()] = value; + InsensitiveNameToValue[name!] = value; } } - public static bool IsDefined(T value) => ValueToName.Keys.Contains(value); + public static bool IsDefined(T value) => ValueToName.ContainsKey(value); - public static bool IsDefined(string value) => SensitiveNameToValue.Keys.Contains(value); + public static bool IsDefined(string value) => SensitiveNameToValue.ContainsKey(value); - public static bool IsDefined(int value) => IntToValue.Keys.Contains(value); + public static bool IsDefined(int value) => IntToValue.ContainsKey(value); public static IEnumerable GetValues() => Values; @@ -50,28 +50,15 @@ public static class Enum public static T Parse(string value, bool ignoreCase = false) { Dictionary names = ignoreCase ? InsensitiveNameToValue : SensitiveNameToValue; - if (ignoreCase) - { - value = value.ToLowerInvariant(); - } - if (names.TryGetValue(value, out T parsed)) - { - return parsed; - } + return names.TryGetValue(value, out T parsed) ? parsed : Throw(); - throw new ArgumentException( - $"Value \"{value}\"is not a valid {typeof(T).Name} enumeration value.", - nameof(value)); + T Throw() => throw new ArgumentException($"Value \"{value}\"is not a valid {typeof(T).Name} enumeration value.", nameof(value)); } public static bool TryParse(string value, out T returnValue, bool ignoreCase = false) { Dictionary names = ignoreCase ? InsensitiveNameToValue : SensitiveNameToValue; - if (ignoreCase) - { - value = value.ToLowerInvariant(); - } return names.TryGetValue(value, out returnValue); } @@ -83,7 +70,7 @@ public static class Enum return null; } - if (InsensitiveNameToValue.TryGetValue(value.ToLowerInvariant(), out T parsed)) + if (InsensitiveNameToValue.TryGetValue(value, out T parsed)) { return parsed; } From d52dcb78bbba02981e2bb469b75385124c343dbd Mon Sep 17 00:00:00 2001 From: Nuklon Date: Mon, 17 Jul 2023 21:52:04 +0200 Subject: [PATCH 11/18] Use built-in Enum parse method, avoids keeping single-use Enums in memory --- .../Services/SqlServerSyntaxProvider.cs | 5 +++-- src/Umbraco.Core/Configuration/Models/ContentSettings.cs | 4 ++-- .../Models/HealthChecksNotificationMethodSettings.cs | 3 +-- src/Umbraco.Core/Configuration/Models/HostingSettings.cs | 3 +-- .../Configuration/Models/ModelsBuilderSettings.cs | 5 ++--- src/Umbraco.Core/Configuration/Models/NuCacheSettings.cs | 3 +-- .../Configuration/Models/RuntimeMinificationSettings.cs | 3 +-- src/Umbraco.Core/Configuration/Models/SmtpSettings.cs | 5 ++--- src/Umbraco.Core/Configuration/Models/WebRoutingSettings.cs | 2 +- 9 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/Umbraco.Cms.Persistence.SqlServer/Services/SqlServerSyntaxProvider.cs b/src/Umbraco.Cms.Persistence.SqlServer/Services/SqlServerSyntaxProvider.cs index 055da32d75..511826114d 100644 --- a/src/Umbraco.Cms.Persistence.SqlServer/Services/SqlServerSyntaxProvider.cs +++ b/src/Umbraco.Cms.Persistence.SqlServer/Services/SqlServerSyntaxProvider.cs @@ -81,15 +81,16 @@ public class SqlServerSyntaxProvider : MicrosoftSqlSyntaxProviderBase.TryParse(setting.Substring("SqlServer.".Length), out VersionName versionName, true)) + if (setting.IsNullOrWhiteSpace() || !setting.StartsWith("SqlServer.") || !Enum.TryParse(setting.AsSpan("SqlServer.".Length), true, out VersionName versionName)) { versionName = GetSetVersion(connectionString, ProviderName, _logger).ProductVersionName; } + if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) { _logger.LogDebug("SqlServer {SqlServerVersion}, DatabaseType is {DatabaseType} ({Source}).", versionName, DatabaseType.SqlServer2012, fromSettings ? "settings" : "detected"); } + return DatabaseType.SqlServer2012; } diff --git a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs index 290836d31e..e209e45cc4 100644 --- a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs @@ -191,7 +191,7 @@ public class ContentSettings /// Gets or sets a value for the macro error behaviour. /// [DefaultValue(StaticMacroErrors)] - public MacroErrorBehaviour MacroErrors { get; set; } = Enum.Parse(StaticMacroErrors); + public MacroErrorBehaviour MacroErrors { get; set; } = Enum.Parse(StaticMacroErrors); /// /// Gets or sets a value for the collection of file extensions that are disallowed for upload. @@ -243,7 +243,7 @@ public class ContentSettings public bool DisableUnpublishWhenReferenced { get; set; } = StaticDisableUnpublishWhenReferenced; /// - /// Get or sets the model representing the global content version cleanup policy + /// Gets or sets the model representing the global content version cleanup policy /// public ContentVersionCleanupPolicySettings ContentVersionCleanupPolicy { get; set; } = new(); diff --git a/src/Umbraco.Core/Configuration/Models/HealthChecksNotificationMethodSettings.cs b/src/Umbraco.Core/Configuration/Models/HealthChecksNotificationMethodSettings.cs index c973f59025..00b3f56583 100644 --- a/src/Umbraco.Core/Configuration/Models/HealthChecksNotificationMethodSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/HealthChecksNotificationMethodSettings.cs @@ -25,8 +25,7 @@ public class HealthChecksNotificationMethodSettings /// Gets or sets a value for the health check notifications reporting verbosity. /// [DefaultValue(StaticVerbosity)] - public HealthCheckNotificationVerbosity Verbosity { get; set; } = - Enum.Parse(StaticVerbosity); + public HealthCheckNotificationVerbosity Verbosity { get; set; } = Enum.Parse(StaticVerbosity); /// /// Gets or sets a value indicating whether the health check notifications should occur on failures only. diff --git a/src/Umbraco.Core/Configuration/Models/HostingSettings.cs b/src/Umbraco.Core/Configuration/Models/HostingSettings.cs index 2329c73d66..c8df39b49a 100644 --- a/src/Umbraco.Core/Configuration/Models/HostingSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/HostingSettings.cs @@ -23,8 +23,7 @@ public class HostingSettings /// Gets or sets a value for the location of temporary files. /// [DefaultValue(StaticLocalTempStorageLocation)] - public LocalTempStorage LocalTempStorageLocation { get; set; } = - Enum.Parse(StaticLocalTempStorageLocation); + public LocalTempStorage LocalTempStorageLocation { get; set; } = Enum.Parse(StaticLocalTempStorageLocation); /// /// Gets or sets a value indicating whether umbraco is running in [debug mode]. diff --git a/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs b/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs index 0e7e1812c6..be86cf1f2b 100644 --- a/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs @@ -22,7 +22,7 @@ public class ModelsBuilderSettings /// Gets or sets a value for the models mode. /// [DefaultValue(StaticModelsMode)] - public ModelsMode ModelsMode { get; set; } = Enum.Parse(StaticModelsMode); + public ModelsMode ModelsMode { get; set; } = Enum.Parse(StaticModelsMode); /// /// Gets or sets a value for models namespace. @@ -52,10 +52,9 @@ public class ModelsBuilderSettings return _flagOutOfDateModels; } - set => _flagOutOfDateModels = value; + set => _flagOutOfDateModels = value; } - /// /// Gets or sets a value for the models directory. /// diff --git a/src/Umbraco.Core/Configuration/Models/NuCacheSettings.cs b/src/Umbraco.Core/Configuration/Models/NuCacheSettings.cs index b88dbb5d0d..490e03096d 100644 --- a/src/Umbraco.Core/Configuration/Models/NuCacheSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/NuCacheSettings.cs @@ -24,8 +24,7 @@ public class NuCacheSettings /// The serializer type that nucache uses to persist documents in the database. /// [DefaultValue(StaticNuCacheSerializerType)] - public NuCacheSerializerType NuCacheSerializerType { get; set; } = - Enum.Parse(StaticNuCacheSerializerType); + public NuCacheSerializerType NuCacheSerializerType { get; set; } = Enum.Parse(StaticNuCacheSerializerType); /// /// The paging size to use for nucache SQL queries. diff --git a/src/Umbraco.Core/Configuration/Models/RuntimeMinificationSettings.cs b/src/Umbraco.Core/Configuration/Models/RuntimeMinificationSettings.cs index 09c55c784b..6ec84ffe1e 100644 --- a/src/Umbraco.Core/Configuration/Models/RuntimeMinificationSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RuntimeMinificationSettings.cs @@ -19,8 +19,7 @@ public class RuntimeMinificationSettings /// The cache buster type to use /// [DefaultValue(StaticCacheBuster)] - public RuntimeMinificationCacheBuster CacheBuster { get; set; } = - Enum.Parse(StaticCacheBuster); + public RuntimeMinificationCacheBuster CacheBuster { get; set; } = Enum.Parse(StaticCacheBuster); /// /// The unique version string used if CacheBuster is 'Version'. diff --git a/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs b/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs index 7d5c126542..92229b1b6d 100644 --- a/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs @@ -74,8 +74,7 @@ public class SmtpSettings : ValidatableEntryBase /// Gets or sets a value for the secure socket options. /// [DefaultValue(StaticSecureSocketOptions)] - public SecureSocketOptions SecureSocketOptions { get; set; } = - Enum.Parse(StaticSecureSocketOptions); + public SecureSocketOptions SecureSocketOptions { get; set; } = Enum.Parse(StaticSecureSocketOptions); /// /// Gets or sets a value for the SMTP pick-up directory. @@ -86,7 +85,7 @@ public class SmtpSettings : ValidatableEntryBase /// Gets or sets a value for the SMTP delivery method. /// [DefaultValue(StaticDeliveryMethod)] - public SmtpDeliveryMethod DeliveryMethod { get; set; } = Enum.Parse(StaticDeliveryMethod); + public SmtpDeliveryMethod DeliveryMethod { get; set; } = Enum.Parse(StaticDeliveryMethod); /// /// Gets or sets a value for the SMTP user name. diff --git a/src/Umbraco.Core/Configuration/Models/WebRoutingSettings.cs b/src/Umbraco.Core/Configuration/Models/WebRoutingSettings.cs index c4dff7a542..12f71c7b44 100644 --- a/src/Umbraco.Core/Configuration/Models/WebRoutingSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/WebRoutingSettings.cs @@ -75,7 +75,7 @@ public class WebRoutingSettings /// Gets or sets a value for the URL provider mode (). /// [DefaultValue(StaticUrlProviderMode)] - public UrlMode UrlProviderMode { get; set; } = Enum.Parse(StaticUrlProviderMode); + public UrlMode UrlProviderMode { get; set; } = Enum.Parse(StaticUrlProviderMode); /// /// Gets or sets a value for the Umbraco application URL. From ab836d232681c3012e974157a4e25446c48b7ce1 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 18 Jul 2023 07:17:19 +0000 Subject: [PATCH 12/18] V10: Find and persist embedded images in rich text (#14546) * add method to find and persist embedded base64 (data-uri) images in a html string * use method to find embedded images in the Umbraco.TinyMce and Umbraco.Grid property editors * rename method to better reflect what it does * set allowed upload file types for integration tests * add test for embedded images in Umbraco.TinyMce * let old ctor call new ctor * Apply suggestions from code review Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> * apply pattern matching --------- Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> --- .../PropertyEditors/GridPropertyEditor.cs | 4 +- .../RichTextEditorPastedImages.cs | 379 ++++++++++++------ .../PropertyEditors/RichTextPropertyEditor.cs | 4 +- .../Controllers/ContentControllerTests.cs | 83 ++++ .../appsettings.Tests.json | 8 + 5 files changed, 363 insertions(+), 115 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/GridPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/GridPropertyEditor.cs index e0140b65b3..f875908849 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/GridPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/GridPropertyEditor.cs @@ -224,7 +224,9 @@ namespace Umbraco.Cms.Core.PropertyEditors if (html is not null) { - var parseAndSavedTempImages = _pastedImages.FindAndPersistPastedTempImages(html, mediaParentId, userId, _imageUrlGenerator); + var parseAndSaveBase64Images = _pastedImages.FindAndPersistEmbeddedImages( + html, mediaParentId, userId); + var parseAndSavedTempImages = _pastedImages.FindAndPersistPastedTempImages(parseAndSaveBase64Images, mediaParentId, userId); var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages); rte.Value = editorValueWithMediaUrlsRemoved; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs index 5044a5b13e..8dbe6ad5b3 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs @@ -1,8 +1,14 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text; +using System.Text.RegularExpressions; +using System.Web; using HtmlAgilityPack; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; @@ -13,6 +19,7 @@ using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; @@ -30,7 +37,11 @@ public sealed class RichTextEditorPastedImages private readonly IShortStringHelper _shortStringHelper; private readonly IUmbracoContextAccessor _umbracoContextAccessor; private readonly string _tempFolderAbsolutePath; + private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly ContentSettings _contentSettings; + private readonly Dictionary _uploadedImages = new(); + [Obsolete("Use the ctor which takes an IImageUrlGenerator and IOptions instead, scheduled for removal in v14")] public RichTextEditorPastedImages( IUmbracoContextAccessor umbracoContextAccessor, ILogger logger, @@ -41,6 +52,33 @@ public sealed class RichTextEditorPastedImages MediaUrlGeneratorCollection mediaUrlGenerators, IShortStringHelper shortStringHelper, IPublishedUrlProvider publishedUrlProvider) + : this( + umbracoContextAccessor, + logger, + hostingEnvironment, + mediaService, + contentTypeBaseServiceProvider, + mediaFileManager, + mediaUrlGenerators, + shortStringHelper, + publishedUrlProvider, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + public RichTextEditorPastedImages( + IUmbracoContextAccessor umbracoContextAccessor, + ILogger logger, + IHostingEnvironment hostingEnvironment, + IMediaService mediaService, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + MediaFileManager mediaFileManager, + MediaUrlGeneratorCollection mediaUrlGenerators, + IShortStringHelper shortStringHelper, + IPublishedUrlProvider publishedUrlProvider, + IImageUrlGenerator imageUrlGenerator, + IOptions contentSettings) { _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); @@ -53,15 +91,132 @@ public sealed class RichTextEditorPastedImages _mediaUrlGenerators = mediaUrlGenerators; _shortStringHelper = shortStringHelper; _publishedUrlProvider = publishedUrlProvider; + _imageUrlGenerator = imageUrlGenerator; + _contentSettings = contentSettings.Value; _tempFolderAbsolutePath = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempImageUploads); - } /// - /// Used by the RTE (and grid RTE) for drag/drop/persisting images + /// Used by the RTE (and grid RTE) for converting inline base64 images to Media items. /// - public string FindAndPersistPastedTempImages(string html, Guid mediaParentFolder, int userId, IImageUrlGenerator imageUrlGenerator) + /// HTML from the Rich Text Editor property editor. + /// + /// + /// Formatted HTML. + /// Thrown if image extension is not allowed + internal string FindAndPersistEmbeddedImages(string html, Guid mediaParentFolder, int userId) + { + // Find all img's that has data-tmpimg attribute + // Use HTML Agility Pack - https://html-agility-pack.net + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(html); + + HtmlNodeCollection? imagesWithDataUris = htmlDoc.DocumentNode.SelectNodes("//img"); + if (imagesWithDataUris is null || imagesWithDataUris.Count is 0) + { + return html; + } + + foreach (HtmlNode? img in imagesWithDataUris) + { + var srcValue = img.GetAttributeValue("src", string.Empty); + + // Ignore src-less images + if (string.IsNullOrEmpty(srcValue)) + { + continue; + } + + // Take only images that have a "data:image" uri into consideration + if (!srcValue.StartsWith("data:image")) + { + continue; + } + + // Create tmp image by scanning the srcValue + // the value will look like "data:image/jpg;base64,abc" where the first part + // is the mimetype and the second (after the comma) is the image blob + Match dataUriInfo = Regex.Match(srcValue, @"^data:\w+\/(?\w+)[\w\+]*?;(?\w+),(?.+)$"); + + // If it turns up false, it was probably a false-positive and we can't do anything with it + if (dataUriInfo.Success is false) + { + continue; + } + + var ext = dataUriInfo.Groups["ext"].Value.ToLowerInvariant(); + var encoding = dataUriInfo.Groups["encoding"].Value.ToLowerInvariant(); + var imageData = dataUriInfo.Groups["data"].Value; + + if (_contentSettings.IsFileAllowedForUpload(ext) is false) + { + // If the image format is not supported we should probably leave it be + // since the user decided to include it. + // If we accepted it anyway, they could technically circumvent the allow list for file types, + // but the user experience would not be very good if we simply failed to save the content. + // Besides, there may be other types of data uri images technically supported by a browser that we cannot handle. + _logger.LogWarning( + "Performance impact: Could not convert embedded image to a Media item because the file extension {Ext} was not allowed. HTML extract: {OuterHtml}", + ext, + img.OuterHtml.Length < 100 ? img.OuterHtml : img.OuterHtml[..100]); // only log the first 100 chars because base64 images can be very long + continue; + } + + // Create an unique folder path to help with concurrent users to avoid filename clash + var imageTempPath = + _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempImageUploads + Path.DirectorySeparatorChar + Guid.NewGuid()); + + // Ensure image temp path exists + if (Directory.Exists(imageTempPath) is false) + { + Directory.CreateDirectory(imageTempPath); + } + + // To get the filename, we simply manipulate the mimetype into a filename + var filePath = $"image.{ext}"; + var safeFileName = filePath.ToSafeFileName(_shortStringHelper); + var tmpImgPath = imageTempPath + Path.DirectorySeparatorChar + safeFileName; + var absoluteTempImagePath = Path.GetFullPath(tmpImgPath); + + // Convert the base64 content to a byte array and save the bytes directly to a file + // this method should work for most use-cases + if (encoding.Equals("base64")) + { + System.IO.File.WriteAllBytes(absoluteTempImagePath, Convert.FromBase64String(imageData)); + } + else + { + System.IO.File.WriteAllText(absoluteTempImagePath, HttpUtility.HtmlDecode(imageData), Encoding.UTF8); + } + + // When the temp file has been created, we can persist it + PersistMediaItem(mediaParentFolder, userId, img, tmpImgPath); + } + + return htmlDoc.DocumentNode.OuterHtml; + } + + /// + /// Used by the RTE (and grid RTE) for drag/drop/persisting images. + /// + /// HTML from the Rich Text Editor property editor. + /// + /// + /// + /// Formatted HTML. + [Obsolete("It is not needed to supply the imageUrlGenerator parameter")] + public string FindAndPersistPastedTempImages(string html, Guid mediaParentFolder, int userId, IImageUrlGenerator imageUrlGenerator) => + FindAndPersistPastedTempImages(html, mediaParentFolder, userId); + + /// + /// Used by the RTE (and grid RTE) for drag/drop/persisting images. + /// + /// HTML from the Rich Text Editor property editor. + /// + /// + /// Formatted HTML. + public string FindAndPersistPastedTempImages(string html, Guid mediaParentFolder, int userId) { // Find all img's that has data-tmpimg attribute // Use HTML Agility Pack - https://html-agility-pack.net @@ -69,16 +224,11 @@ public sealed class RichTextEditorPastedImages htmlDoc.LoadHtml(html); HtmlNodeCollection? tmpImages = htmlDoc.DocumentNode.SelectNodes($"//img[@{TemporaryImageDataAttribute}]"); - if (tmpImages == null || tmpImages.Count == 0) + if (tmpImages is null || tmpImages.Count is 0) { return html; } - // An array to contain a list of URLs that - // we have already processed to avoid dupes - var uploadedImages = new Dictionary(); - - foreach (HtmlNode? img in tmpImages) { // The data attribute contains the path to the tmp img to persist as a media item @@ -89,116 +239,119 @@ public sealed class RichTextEditorPastedImages continue; } + var qualifiedTmpImgPath = _hostingEnvironment.MapPathContentRoot(tmpImgPath); - var absoluteTempImagePath = Path.GetFullPath(_hostingEnvironment.MapPathContentRoot(tmpImgPath)); - - if (IsValidPath(absoluteTempImagePath) == false) - { - continue; - } - - var fileName = Path.GetFileName(absoluteTempImagePath); - var safeFileName = fileName.ToSafeFileName(_shortStringHelper); - - var mediaItemName = safeFileName.ToFriendlyName(); - IMedia mediaFile; - GuidUdi udi; - - if (uploadedImages.ContainsKey(tmpImgPath) == false) - { - if (mediaParentFolder == Guid.Empty) - { - mediaFile = _mediaService.CreateMedia(mediaItemName, Constants.System.Root, Constants.Conventions.MediaTypes.Image, userId); - } - else - { - mediaFile = _mediaService.CreateMedia(mediaItemName, mediaParentFolder, Constants.Conventions.MediaTypes.Image, userId); - } - - var fileInfo = new FileInfo(absoluteTempImagePath); - - FileStream? fileStream = fileInfo.OpenReadWithRetry(); - if (fileStream == null) - { - throw new InvalidOperationException("Could not acquire file stream"); - } - - using (fileStream) - { - mediaFile.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper, _contentTypeBaseServiceProvider, Constants.Conventions.Media.File, safeFileName, fileStream); - } - - _mediaService.Save(mediaFile, userId); - - udi = mediaFile.GetUdi(); - } - else - { - // Already been uploaded & we have it's UDI - udi = uploadedImages[tmpImgPath]; - } - - // Add the UDI to the img element as new data attribute - img.SetAttributeValue("data-udi", udi.ToString()); - - // Get the new persisted image URL - _umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext); - IPublishedContent? mediaTyped = umbracoContext?.Media?.GetById(udi.Guid); - if (mediaTyped == null) - { - throw new PanicException( - $"Could not find media by id {udi.Guid} or there was no UmbracoContext available."); - } - - var location = mediaTyped.Url(_publishedUrlProvider); - - // Find the width & height attributes as we need to set the imageprocessor QueryString - var width = img.GetAttributeValue("width", int.MinValue); - var height = img.GetAttributeValue("height", int.MinValue); - - if (width != int.MinValue && height != int.MinValue) - { - location = imageUrlGenerator.GetImageUrl(new ImageUrlGenerationOptions(location) - { - ImageCropMode = ImageCropMode.Max, - Width = width, - Height = height, - }); - } - - img.SetAttributeValue("src", location); - - // Remove the data attribute (so we do not re-process this) - img.Attributes.Remove(TemporaryImageDataAttribute); - - // Add to the dictionary to avoid dupes - if (uploadedImages.ContainsKey(tmpImgPath) == false) - { - uploadedImages.Add(tmpImgPath, udi); - - // Delete folder & image now its saved in media - // The folder should contain one image - as a unique guid folder created - // for each image uploaded from TinyMceController - var folderName = Path.GetDirectoryName(absoluteTempImagePath); - try - { - if (folderName is not null) - { - Directory.Delete(folderName, true); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not delete temp file or folder {FileName}", absoluteTempImagePath); - } - } + PersistMediaItem(mediaParentFolder, userId, img, qualifiedTmpImgPath); } return htmlDoc.DocumentNode.OuterHtml; } - private bool IsValidPath(string imagePath) + private void PersistMediaItem(Guid mediaParentFolder, int userId, HtmlNode img, string qualifiedTmpImgPath) { - return imagePath.StartsWith(_tempFolderAbsolutePath); + var absoluteTempImagePath = Path.GetFullPath(qualifiedTmpImgPath); + + if (IsValidPath(absoluteTempImagePath) is false) + { + return; + } + + var fileName = Path.GetFileName(absoluteTempImagePath); + var safeFileName = fileName.ToSafeFileName(_shortStringHelper); + + var mediaItemName = safeFileName.ToFriendlyName(); + GuidUdi udi; + + if (_uploadedImages.ContainsKey(qualifiedTmpImgPath) is false) + { + var isSvg = qualifiedTmpImgPath.EndsWith(".svg"); + var mediaType = isSvg + ? Constants.Conventions.MediaTypes.VectorGraphicsAlias + : Constants.Conventions.MediaTypes.Image; + + IMedia mediaFile = mediaParentFolder == Guid.Empty + ? _mediaService.CreateMedia(mediaItemName, Constants.System.Root, mediaType, userId) + : _mediaService.CreateMedia(mediaItemName, mediaParentFolder, mediaType, userId); + + var fileInfo = new FileInfo(absoluteTempImagePath); + + FileStream? fileStream = fileInfo.OpenReadWithRetry(); + if (fileStream is null) + { + throw new InvalidOperationException("Could not acquire file stream"); + } + + using (fileStream) + { + mediaFile.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper, + _contentTypeBaseServiceProvider, Constants.Conventions.Media.File, safeFileName, fileStream); + } + + _mediaService.Save(mediaFile, userId); + + udi = mediaFile.GetUdi(); + } + else + { + // Already been uploaded & we have it's UDI + udi = _uploadedImages[qualifiedTmpImgPath]; + } + + // Add the UDI to the img element as new data attribute + img.SetAttributeValue("data-udi", udi.ToString()); + + // Get the new persisted image URL + _umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext); + IPublishedContent? mediaTyped = umbracoContext?.Media?.GetById(udi.Guid); + if (mediaTyped is null) + { + throw new PanicException( + $"Could not find media by id {udi.Guid} or there was no UmbracoContext available."); + } + + var location = mediaTyped.Url(_publishedUrlProvider); + + // Find the width & height attributes as we need to set the imageprocessor QueryString + var width = img.GetAttributeValue("width", int.MinValue); + var height = img.GetAttributeValue("height", int.MinValue); + + if (width != int.MinValue && height != int.MinValue) + { + location = _imageUrlGenerator.GetImageUrl(new ImageUrlGenerationOptions(location) + { + ImageCropMode = ImageCropMode.Max, + Width = width, + Height = height, + }); + } + + img.SetAttributeValue("src", location); + + // Remove the data attribute (so we do not re-process this) + img.Attributes.Remove(TemporaryImageDataAttribute); + + // Add to the dictionary to avoid dupes + if (_uploadedImages.ContainsKey(qualifiedTmpImgPath) is false) + { + _uploadedImages.Add(qualifiedTmpImgPath, udi); + + // Delete folder & image now its saved in media + // The folder should contain one image - as a unique guid folder created + // for each image uploaded from TinyMceController + var folderName = Path.GetDirectoryName(absoluteTempImagePath); + try + { + if (folderName is not null) + { + Directory.Delete(folderName, true); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not delete temp file or folder {FileName}", absoluteTempImagePath); + } + } } + + private bool IsValidPath(string imagePath) => imagePath.StartsWith(_tempFolderAbsolutePath); } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index 21313b3b77..c11498af7a 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -293,8 +293,10 @@ public class RichTextPropertyEditor : DataEditor return null; } + var parseAndSaveBase64Images = _pastedImages.FindAndPersistEmbeddedImages( + editorValue.Value.ToString()!, mediaParentId, userId); var parseAndSavedTempImages = - _pastedImages.FindAndPersistPastedTempImages(editorValue.Value.ToString()!, mediaParentId, userId, _imageUrlGenerator); + _pastedImages.FindAndPersistPastedTempImages(parseAndSaveBase64Images, mediaParentId, userId); var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages); var parsed = MacroTagParser.FormatRichTextContentForPersistence(editorValueWithMediaUrlsRemoved); var sanitized = _htmlSanitizer.Sanitize(parsed); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs index 70db8b3ce2..d1a3caa2ea 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs @@ -664,4 +664,87 @@ public class ContentControllerTests : UmbracoTestServerTestBase Assert.AreEqual(0, display.Notifications.Count(x => x.NotificationType == NotificationStyle.Warning)); }); } + + [TestCase( + @"

", + false)] + [TestCase( + @"

"">

", + false)] + [TestCase( + @"

", + false)] + [TestCase( + @"

", + true)] + public async Task PostSave_Simple_RichText_With_Base64(string html, bool shouldHaveDataUri) + { + var url = PrepareApiControllerUrl(x => x.PostSave(null)); + + var dataTypeService = GetRequiredService(); + var contentService = GetRequiredService(); + var contentTypeService = GetRequiredService(); + + var dataType = new DataTypeBuilder() + .WithId(0) + .WithoutIdentity() + .WithDatabaseType(ValueStorageType.Ntext) + .AddEditor() + .WithAlias(Constants.PropertyEditors.Aliases.TinyMce) + .Done() + .Build(); + + dataTypeService.Save(dataType); + + var contentType = new ContentTypeBuilder() + .WithId(0) + .AddPropertyType() + .WithDataTypeId(dataType.Id) + .WithAlias("richText") + .WithName("Rich Text") + .Done() + .WithContentVariation(ContentVariation.Nothing) + .Build(); + + contentTypeService.Save(contentType); + + var content = new ContentBuilder() + .WithId(0) + .WithName("Invariant") + .WithContentType(contentType) + .AddPropertyData() + .WithKeyValue("richText", html) + .Done() + .Build(); + contentService.SaveAndPublish(content); + var model = new ContentItemSaveBuilder() + .WithContent(content) + .Build(); + + // Act + var response = + await Client.PostAsync(url, new MultipartFormDataContent {{new StringContent(JsonConvert.SerializeObject(model)), "contentItem"}}); + + // Assert + var body = await response.Content.ReadAsStringAsync(); + + body = body.TrimStart(AngularJsonMediaTypeFormatter.XsrfPrefix); + + Assert.Multiple(() => + { + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, body); + var display = JsonConvert.DeserializeObject(body); + var bodyText = display.Variants.FirstOrDefault()?.Tabs.FirstOrDefault()?.Properties + ?.FirstOrDefault(x => x.Alias.Equals("richText"))?.Value?.ToString(); + Assert.NotNull(bodyText); + + var containsDataUri = bodyText.Contains("data:image"); + if (shouldHaveDataUri) + { + Assert.True(containsDataUri, $"Data URIs were expected to be found in the body: {bodyText}"); + } else { + Assert.False(containsDataUri, $"Data URIs were not expected to be found in the body: {bodyText}"); + } + }); + } } diff --git a/tests/Umbraco.Tests.Integration/appsettings.Tests.json b/tests/Umbraco.Tests.Integration/appsettings.Tests.json index 101b1a1aef..1d4bbf18ef 100644 --- a/tests/Umbraco.Tests.Integration/appsettings.Tests.json +++ b/tests/Umbraco.Tests.Integration/appsettings.Tests.json @@ -1,4 +1,5 @@ { + "$schema": "./appsettings-schema.json", "Logging": { "LogLevel": { "Default": "Warning", @@ -16,5 +17,12 @@ "EmptyDatabasesCount": 2, "SQLServerMasterConnectionString": "" } + }, + "Umbraco": { + "CMS": { + "Content": { + "AllowedUploadedFileExtensions": ["jpg", "png", "gif", "svg"] + } + } } } From ba423a010831844a542169a53a8f7eef36b1c611 Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Date: Tue, 18 Jul 2023 11:53:14 +0300 Subject: [PATCH 13/18] V12: Add ISO codes to make the migration from language IDs easier (#14567) * Change the obsoletion messages for language IDs to target V14 instead of V13. * Wrong Language file * Add ISO codes required to migrate custom code from language IDs * Population of the new language FallbackIsoCode prop * Changing obsoletion msgs from v13 to v14 * Fix breaking changes --- .../Models/ContentEditing/Language.cs | 2 +- src/Umbraco.Core/Models/DictionaryItem.cs | 2 +- .../Models/DictionaryItemExtensions.cs | 4 +-- .../Models/DictionaryTranslation.cs | 32 ++++++++++++++--- .../Models/IDictionaryTranslation.cs | 14 +++++--- src/Umbraco.Core/Models/ILanguage.cs | 19 +++++++++- src/Umbraco.Core/Models/Language.cs | 11 ++++-- .../Persistence/Factories/LanguageFactory.cs | 3 +- .../Implement/LanguageRepository.cs | 35 ++++++++++++++----- .../Builders/LanguageBuilder.cs | 2 +- 10 files changed, 98 insertions(+), 26 deletions(-) diff --git a/src/Umbraco.Core/Models/ContentEditing/Language.cs b/src/Umbraco.Core/Models/ContentEditing/Language.cs index 112aeb5aac..99c011d608 100644 --- a/src/Umbraco.Core/Models/ContentEditing/Language.cs +++ b/src/Umbraco.Core/Models/ContentEditing/Language.cs @@ -22,7 +22,7 @@ public class Language [DataMember(Name = "isMandatory")] public bool IsMandatory { get; set; } - [Obsolete("This will be replaced by fallback language ISO code in V13.")] + [Obsolete("This will be replaced by fallback language ISO code in V14.")] [DataMember(Name = "fallbackLanguageId")] public int? FallbackLanguageId { get; set; } } diff --git a/src/Umbraco.Core/Models/DictionaryItem.cs b/src/Umbraco.Core/Models/DictionaryItem.cs index 90576a85e3..b0e787de02 100644 --- a/src/Umbraco.Core/Models/DictionaryItem.cs +++ b/src/Umbraco.Core/Models/DictionaryItem.cs @@ -34,7 +34,7 @@ public class DictionaryItem : EntityBase, IDictionaryItem _translations = new List(); } - [Obsolete("This will be removed in V13.")] + [Obsolete("This will be removed in V14.")] public Func? GetLanguage { get; set; } /// diff --git a/src/Umbraco.Core/Models/DictionaryItemExtensions.cs b/src/Umbraco.Core/Models/DictionaryItemExtensions.cs index 09654d5137..341f185ff9 100644 --- a/src/Umbraco.Core/Models/DictionaryItemExtensions.cs +++ b/src/Umbraco.Core/Models/DictionaryItemExtensions.cs @@ -10,7 +10,7 @@ public static class DictionaryItemExtensions /// /// /// - [Obsolete("This will be replaced in V13 by a corresponding method accepting language ISO code instead of language ID.")] + [Obsolete("This will be replaced in V14 by a corresponding method accepting language ISO code instead of language ID.")] public static string? GetTranslatedValue(this IDictionaryItem d, int languageId) { IDictionaryTranslation? trans = d.Translations.FirstOrDefault(x => x.LanguageId == languageId); @@ -22,7 +22,7 @@ public static class DictionaryItemExtensions /// /// /// - [Obsolete("Warning: This method ONLY works in very specific scenarios. It will be removed in V13.")] + [Obsolete("Warning: This method ONLY works in very specific scenarios. It will be removed in V14.")] public static string? GetDefaultValue(this IDictionaryItem d) { IDictionaryTranslation? defaultTranslation = d.Translations.FirstOrDefault(x => x.Language?.Id == 1); diff --git a/src/Umbraco.Core/Models/DictionaryTranslation.cs b/src/Umbraco.Core/Models/DictionaryTranslation.cs index ab79b77e44..7f4471785c 100644 --- a/src/Umbraco.Core/Models/DictionaryTranslation.cs +++ b/src/Umbraco.Core/Models/DictionaryTranslation.cs @@ -1,5 +1,8 @@ using System.Runtime.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Core.Models; @@ -11,6 +14,7 @@ namespace Umbraco.Cms.Core.Models; public class DictionaryTranslation : EntityBase, IDictionaryTranslation { private ILanguage? _language; + private string? _languageIsoCode; // note: this will be memberwise cloned private string _value; @@ -20,6 +24,7 @@ public class DictionaryTranslation : EntityBase, IDictionaryTranslation _language = language ?? throw new ArgumentNullException("language"); LanguageId = _language.Id; _value = value; + LanguageIsoCode = language.IsoCode; } public DictionaryTranslation(ILanguage language, string value, Guid uniqueId) @@ -27,17 +32,18 @@ public class DictionaryTranslation : EntityBase, IDictionaryTranslation _language = language ?? throw new ArgumentNullException("language"); LanguageId = _language.Id; _value = value; + LanguageIsoCode = language.IsoCode; Key = uniqueId; } - [Obsolete("Please use constructor that accepts ILanguage. This will be removed in V13.")] + [Obsolete("Please use constructor that accepts ILanguage. This will be removed in V14.")] public DictionaryTranslation(int languageId, string value) { LanguageId = languageId; _value = value; } - [Obsolete("Please use constructor that accepts ILanguage. This will be removed in V13.")] + [Obsolete("Please use constructor that accepts ILanguage. This will be removed in V14.")] public DictionaryTranslation(int languageId, string value, Guid uniqueId) { LanguageId = languageId; @@ -58,7 +64,7 @@ public class DictionaryTranslation : EntityBase, IDictionaryTranslation /// returned /// on a callback. /// - [Obsolete("This will be removed in V13. From V13 onwards you should get languages by ISO code from ILanguageService.")] + [Obsolete("This will be removed in V14. From V14 onwards you should get languages by ISO code from ILanguageService.")] [DataMember] [DoNotClone] public ILanguage? Language @@ -86,7 +92,7 @@ public class DictionaryTranslation : EntityBase, IDictionaryTranslation } } - [Obsolete("This will be replaced by language ISO code in V13.")] + [Obsolete("This will be replaced by language ISO code in V14.")] public int LanguageId { get; private set; } /// @@ -99,6 +105,24 @@ public class DictionaryTranslation : EntityBase, IDictionaryTranslation set => SetPropertyValueAndDetectChanges(value, ref _value!, nameof(Value)); } + /// + public string LanguageIsoCode + { + get + { + // TODO: this won't be necessary after obsoleted ctors are removed in v14. + if (_languageIsoCode is null) + { + var _languageService = StaticServiceProvider.Instance.GetRequiredService(); + _languageIsoCode = _languageService.GetLanguageById(LanguageId)?.IsoCode ?? string.Empty; + } + + return _languageIsoCode; + } + + private set => SetPropertyValueAndDetectChanges(value, ref _languageIsoCode!, nameof(LanguageIsoCode)); + } + protected override void PerformDeepClone(object clone) { base.PerformDeepClone(clone); diff --git a/src/Umbraco.Core/Models/IDictionaryTranslation.cs b/src/Umbraco.Core/Models/IDictionaryTranslation.cs index 45d71e3f9b..8f8d9ffaa4 100644 --- a/src/Umbraco.Core/Models/IDictionaryTranslation.cs +++ b/src/Umbraco.Core/Models/IDictionaryTranslation.cs @@ -6,18 +6,24 @@ namespace Umbraco.Cms.Core.Models; public interface IDictionaryTranslation : IEntity, IRememberBeingDirty { /// - /// Gets or sets the for the translation + /// Gets or sets the for the translation. /// - [Obsolete("This will be removed in V13. From V13 onwards you should get languages by ISO code from ILanguageService.")] + [Obsolete("This will be removed in V14. From V14 onwards you should get languages by ISO code from ILanguageService.")] [DataMember] ILanguage? Language { get; set; } - [Obsolete("This will be replaced by language ISO code in V13.")] + [Obsolete("This will be replaced by language ISO code in V14.")] int LanguageId { get; } /// - /// Gets or sets the translated text + /// Gets or sets the translated text. /// [DataMember] string Value { get; set; } + + /// + /// Gets the ISO code of the language. + /// + [DataMember] + string LanguageIsoCode => Language?.IsoCode ?? string.Empty; } diff --git a/src/Umbraco.Core/Models/ILanguage.cs b/src/Umbraco.Core/Models/ILanguage.cs index 88c76ae7b0..885833cd5c 100644 --- a/src/Umbraco.Core/Models/ILanguage.cs +++ b/src/Umbraco.Core/Models/ILanguage.cs @@ -55,7 +55,24 @@ public interface ILanguage : IEntity, IRememberBeingDirty /// define fallback strategies when a value does not exist for a requested language. /// /// - [Obsolete("This will be replaced by fallback language ISO code in V13.")] + [Obsolete("This will be replaced by fallback language ISO code in V14.")] [DataMember] int? FallbackLanguageId { get; set; } + + + /// + /// Gets or sets the ISO code of a fallback language. + /// + /// + /// + /// The fallback language can be used in multi-lingual scenarios, to help + /// define fallback strategies when a value does not exist for a requested language. + /// + /// + [DataMember] + string? FallbackIsoCode + { + get => null; + set { } + } } diff --git a/src/Umbraco.Core/Models/Language.cs b/src/Umbraco.Core/Models/Language.cs index 9871cf3eed..62a65f086b 100644 --- a/src/Umbraco.Core/Models/Language.cs +++ b/src/Umbraco.Core/Models/Language.cs @@ -1,6 +1,5 @@ using System.Globalization; using System.Runtime.Serialization; -using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models.Entities; namespace Umbraco.Cms.Core.Models; @@ -14,6 +13,7 @@ public class Language : EntityBase, ILanguage { private string _cultureName; private int? _fallbackLanguageId; + private string? _fallbackLanguageIsoCode; private bool _isDefaultVariantLanguage; private string _isoCode; private bool _mandatory; @@ -74,10 +74,17 @@ public class Language : EntityBase, ILanguage } /// - [Obsolete("This will be replaced by fallback language ISO code in V13.")] + [Obsolete("This will be replaced by fallback language ISO code in V14.")] public int? FallbackLanguageId { get => _fallbackLanguageId; set => SetPropertyValueAndDetectChanges(value, ref _fallbackLanguageId, nameof(FallbackLanguageId)); } + + /// + public string? FallbackIsoCode + { + get => _fallbackLanguageIsoCode; + set => SetPropertyValueAndDetectChanges(value, ref _fallbackLanguageIsoCode, nameof(FallbackIsoCode)); + } } diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/LanguageFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/LanguageFactory.cs index 9ab958c306..6ef14238af 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/LanguageFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/LanguageFactory.cs @@ -6,7 +6,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories; internal static class LanguageFactory { - public static ILanguage BuildEntity(LanguageDto dto) + public static ILanguage BuildEntity(LanguageDto dto, string? fallbackIsoCode) { ArgumentNullException.ThrowIfNull(dto); if (dto.IsoCode is null) @@ -22,6 +22,7 @@ internal static class LanguageFactory IsDefault = dto.IsDefault, IsMandatory = dto.IsMandatory, FallbackLanguageId = dto.FallbackLanguageId, + FallbackIsoCode = fallbackIsoCode }; // Reset dirty initial properties diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs index 398a55ebaf..590fae26c0 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs @@ -120,7 +120,19 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu new FullDataSetRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, GetEntityId, /*expires:*/ false); protected ILanguage ConvertFromDto(LanguageDto dto) - => LanguageFactory.BuildEntity(dto); + { + // yes, we want to lock _codeIdMap + lock (_codeIdMap) + { + string? fallbackIsoCode = null; + if (dto.FallbackLanguageId.HasValue && _idCodeMap.TryGetValue(dto.FallbackLanguageId.Value, out fallbackIsoCode) == false) + { + throw new ArgumentException($"The ISO code map did not contain ISO code for fallback language ID: {dto.FallbackLanguageId}. Please reload the caches."); + } + + return LanguageFactory.BuildEntity(dto, fallbackIsoCode); + } + } // do NOT leak that language, it's not deep-cloned! private ILanguage GetDefault() @@ -172,20 +184,25 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu sql.OrderBy(x => x.Id); // get languages - var languages = Database.Fetch(sql).Select(ConvertFromDto).OrderBy(x => x.Id).ToList(); + List? languageDtos = Database.Fetch(sql) ?? new List(); - // initialize the code-id map - lock (_codeIdMap) + // initialize the code-id map if we've reloaded the entire set of languages + if (ids?.Any() == false) { - _codeIdMap.Clear(); - _idCodeMap.Clear(); - foreach (ILanguage language in languages) + lock (_codeIdMap) { - _codeIdMap[language.IsoCode] = language.Id; - _idCodeMap[language.Id] = language.IsoCode.ToLowerInvariant(); + _codeIdMap.Clear(); + _idCodeMap.Clear(); + foreach (LanguageDto languageDto in languageDtos) + { + ArgumentException.ThrowIfNullOrEmpty(languageDto.IsoCode, nameof(LanguageDto.IsoCode)); + _codeIdMap[languageDto.IsoCode] = languageDto.Id; + _idCodeMap[languageDto.Id] = languageDto.IsoCode; + } } } + var languages = languageDtos.Select(ConvertFromDto).OrderBy(x => x.Id).ToList(); return languages; } diff --git a/tests/Umbraco.Tests.Common/Builders/LanguageBuilder.cs b/tests/Umbraco.Tests.Common/Builders/LanguageBuilder.cs index 16283e0adf..0aa81a7381 100644 --- a/tests/Umbraco.Tests.Common/Builders/LanguageBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/LanguageBuilder.cs @@ -95,7 +95,7 @@ public class LanguageBuilder return this; } - [Obsolete("This will be replaced in V13 by a corresponding method accepting language ISO code instead of language ID.")] + [Obsolete("This will be replaced in V14 by a corresponding method accepting language ISO code instead of language ID.")] public LanguageBuilder WithFallbackLanguageId(int fallbackLanguageId) { _fallbackLanguageId = fallbackLanguageId; From d1aac3964c1b59cb75a0613daaab7d09d75ea3b4 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 18 Jul 2023 12:57:09 +0200 Subject: [PATCH 14/18] Restored order of operations on scope dispose to that used in Umbraco 11 before refactor into Scope inheriting CoreScope. (#14573) --- src/Umbraco.Core/Scoping/CoreScope.cs | 4 ++-- src/Umbraco.Infrastructure/Scoping/Scope.cs | 23 ++++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Umbraco.Core/Scoping/CoreScope.cs b/src/Umbraco.Core/Scoping/CoreScope.cs index a05b44f4a7..7fe6c400fb 100644 --- a/src/Umbraco.Core/Scoping/CoreScope.cs +++ b/src/Umbraco.Core/Scoping/CoreScope.cs @@ -231,7 +231,7 @@ public class CoreScope : ICoreScope } } - private void HandleScopedFileSystems() + protected void HandleScopedFileSystems() { if (_shouldScopeFileSystems == true) { @@ -250,7 +250,7 @@ public class CoreScope : ICoreScope _parentScope = coreScope; } - private void HandleScopedNotifications() => _notificationPublisher?.ScopeExit(Completed.HasValue && Completed.Value); + protected void HandleScopedNotifications() => _notificationPublisher?.ScopeExit(Completed.HasValue && Completed.Value); private void EnsureNotDisposed() { diff --git a/src/Umbraco.Infrastructure/Scoping/Scope.cs b/src/Umbraco.Infrastructure/Scoping/Scope.cs index 295c92a6d6..4455b01df3 100644 --- a/src/Umbraco.Infrastructure/Scoping/Scope.cs +++ b/src/Umbraco.Infrastructure/Scoping/Scope.cs @@ -357,7 +357,7 @@ namespace Umbraco.Cms.Infrastructure.Scoping } } - public void Dispose() + public override void Dispose() { EnsureNotDisposed(); @@ -402,16 +402,21 @@ namespace Umbraco.Cms.Infrastructure.Scoping Completed = true; } - if (ParentScope != null) - { - ParentScope.ChildCompleted(Completed); - } - else + // CoreScope.Dispose will handle file systems and notifications, as well as notifying any parent scope of the child scope's completion. + // In this overridden class, we re-use that functionality and also handle scope context (including enlisted actions) and detached scopes. + // We retain order of events behaviour from Umbraco 11: + // - handle file systems (in CoreScope) + // - handle scoped notifications (in CoreScope) + // - handle scope context (in Scope) + // - handle detatched scopes (in Scope) + if (ParentScope is null) { DisposeLastScope(); } - - base.Dispose(); + else + { + ParentScope.ChildCompleted(Completed); + } _disposed = true; } @@ -559,6 +564,8 @@ namespace Umbraco.Cms.Infrastructure.Scoping } TryFinally( + HandleScopedFileSystems, + HandleScopedNotifications, HandleScopeContext, HandleDetachedScopes); } From 43b44b100486ffde9822b36935997437b6dd8209 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Wed, 19 Jul 2023 10:06:12 +0200 Subject: [PATCH 15/18] remove submodule --- src/Umbraco.Web.UI.New.Client | 1 - 1 file changed, 1 deletion(-) delete mode 160000 src/Umbraco.Web.UI.New.Client diff --git a/src/Umbraco.Web.UI.New.Client b/src/Umbraco.Web.UI.New.Client deleted file mode 160000 index 40224f9d4b..0000000000 --- a/src/Umbraco.Web.UI.New.Client +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 40224f9d4be21da25d3844d1c642137194eb93fd From 4824fc88395a6dc11e7d1c69b1fce21c555e84c6 Mon Sep 17 00:00:00 2001 From: Mole Date: Wed, 19 Jul 2023 13:42:25 +0200 Subject: [PATCH 16/18] Supress execution context flow when queuing email task. (#14571) --- .../Services/NotificationService.cs | 69 +++++++++++-------- 1 file changed, 39 insertions(+), 30 deletions(-) diff --git a/src/Umbraco.Core/Services/NotificationService.cs b/src/Umbraco.Core/Services/NotificationService.cs index 72d46b2fb6..4c0661594c 100644 --- a/src/Umbraco.Core/Services/NotificationService.cs +++ b/src/Umbraco.Core/Services/NotificationService.cs @@ -565,49 +565,58 @@ public class NotificationService : INotificationService } } - private void Process(BlockingCollection notificationRequests) => - ThreadPool.QueueUserWorkItem(state => + private void Process(BlockingCollection notificationRequests) + { + // We need to suppress the flow of the ExecutionContext when starting a new thread. + // Otherwise our scope stack will leak into the context of the new thread, leading to disposing race conditions. + using (ExecutionContext.SuppressFlow()) { - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + ThreadPool.QueueUserWorkItem(state => { - _logger.LogDebug("Begin processing notifications."); - } - while (true) - { - // stay on for 8s - while (notificationRequests.TryTake(out NotificationRequest? request, 8 * 1000)) + if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) { - try + _logger.LogDebug("Begin processing notifications."); + } + + while (true) + { + // stay on for 8s + while (notificationRequests.TryTake(out NotificationRequest? request, 8 * 1000)) { - _emailSender.SendAsync(request.Mail, Constants.Web.EmailTypes.Notification).GetAwaiter() - .GetResult(); - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + try { - _logger.LogDebug("Notification '{Action}' sent to {Username} ({Email})", request.Action, request.UserName, request.Email); + _emailSender.SendAsync(request.Mail, Constants.Web.EmailTypes.Notification).GetAwaiter() + .GetResult(); + if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + { + _logger.LogDebug("Notification '{Action}' sent to {Username} ({Email})", request.Action, request.UserName, request.Email); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred sending notification"); } } - catch (Exception ex) + + lock (Locker) { - _logger.LogError(ex, "An error occurred sending notification"); + if (notificationRequests.Count > 0) + { + continue; // last chance + } + + _running = false; // going down + break; } } - lock (Locker) + if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) { - if (notificationRequests.Count > 0) - { - continue; // last chance - } - - _running = false; // going down - break; + _logger.LogDebug("Done processing notifications."); } - } - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) - { - _logger.LogDebug("Done processing notifications."); - } - }); + }); + } + } private class NotificationRequest { From 3a857ab58440e2d1fad5d1b8867194027f92c959 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 20 Jul 2023 08:33:49 +0200 Subject: [PATCH 17/18] Added tests for TopoGraph. (#14583) --- .../Collections/TopoGraphTests.cs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/Collections/TopoGraphTests.cs diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Collections/TopoGraphTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Collections/TopoGraphTests.cs new file mode 100644 index 0000000000..ce54c6e762 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Collections/TopoGraphTests.cs @@ -0,0 +1,126 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.Collections; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Collections; + +[TestFixture] +public class TopoGraphTests +{ + [Test] + public void CycleTest() + { + var graph = new TopoGraph(x => x.Id, x => x.Dependencies); + graph.AddItem(new Thing { Id = 1, Name = "One" }.Depends(3)); + graph.AddItem(new Thing { Id = 2, Name = "Two" }.Depends(1)); + graph.AddItem(new Thing { Id = 3, Name = "Three" }.Depends(2)); + + try + { + var ordered = graph.GetSortedItems().ToArray(); + Assert.Fail("Expected: Exception."); + } + catch (Exception e) + { + Assert.IsTrue(e.Message.StartsWith(TopoGraph.CycleDependencyError)); + } + } + + [Test] + public void IgnoreCycleTest() + { + var graph = new TopoGraph(x => x.Id, x => x.Dependencies); + graph.AddItem(new Thing { Id = 1, Name = "One" }.Depends(3)); + graph.AddItem(new Thing { Id = 2, Name = "Two" }.Depends(1)); + graph.AddItem(new Thing { Id = 3, Name = "Three" }.Depends(2)); + + var ordered = graph.GetSortedItems(throwOnCycle: false).ToArray(); + + // default order is dependencies before item + Assert.AreEqual(2, ordered[0].Id); // ignored cycle + Assert.AreEqual(3, ordered[1].Id); + Assert.AreEqual(1, ordered[2].Id); + } + + [Test] + public void MissingTest() + { + var graph = new TopoGraph(x => x.Id, x => x.Dependencies); + graph.AddItem(new Thing { Id = 1, Name = "One" }.Depends(4)); + graph.AddItem(new Thing { Id = 2, Name = "Two" }.Depends(1)); + graph.AddItem(new Thing { Id = 3, Name = "Three" }.Depends(2)); + + try + { + var ordered = graph.GetSortedItems().ToArray(); + Assert.Fail("Expected: Exception."); + } + catch (Exception e) + { + Assert.IsTrue(e.Message.StartsWith(TopoGraph.MissingDependencyError)); + } + } + + [Test] + public void IgnoreMissingTest() + { + var graph = new TopoGraph(x => x.Id, x => x.Dependencies); + graph.AddItem(new Thing { Id = 1, Name = "One" }.Depends(4)); + graph.AddItem(new Thing { Id = 2, Name = "Two" }.Depends(1)); + graph.AddItem(new Thing { Id = 3, Name = "Three" }.Depends(2)); + + var ordered = graph.GetSortedItems(throwOnMissing: false).ToArray(); + + // default order is dependencies before item + Assert.AreEqual(1, ordered[0].Id); // ignored dependency + Assert.AreEqual(2, ordered[1].Id); + Assert.AreEqual(3, ordered[2].Id); + } + + [Test] + public void OrderTest() + { + var graph = new TopoGraph(x => x.Id, x => x.Dependencies); + graph.AddItem(new Thing { Id = 1, Name = "One" }); + graph.AddItem(new Thing { Id = 2, Name = "Two" }.Depends(1)); + graph.AddItem(new Thing { Id = 3, Name = "Three" }.Depends(2)); + + var ordered = graph.GetSortedItems().ToArray(); + + // default order is dependencies before item + Assert.AreEqual(1, ordered[0].Id); + Assert.AreEqual(2, ordered[1].Id); + Assert.AreEqual(3, ordered[2].Id); + } + + [Test] + public void ReverseTest() + { + var graph = new TopoGraph(x => x.Id, x => x.Dependencies); + graph.AddItem(new Thing { Id = 1, Name = "One" }); + graph.AddItem(new Thing { Id = 2, Name = "Two" }.Depends(1)); + graph.AddItem(new Thing { Id = 3, Name = "Three" }.Depends(2)); + + var ordered = graph.GetSortedItems(reverse: true).ToArray(); + + // reverse order is item before dependencies + Assert.AreEqual(3, ordered[0].Id); + Assert.AreEqual(2, ordered[1].Id); + Assert.AreEqual(1, ordered[2].Id); + } + + public class Thing + { + public int Id { get; set; } + + public string Name { get; set; } + + public List Dependencies { get; } = new List(); + + public Thing Depends(params int[] dependencies) + { + Dependencies.AddRange(dependencies); + return this; + } + } + +} From 0ae404ce037b582854101dffc419aff262768b23 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 20 Jul 2023 13:51:05 +0200 Subject: [PATCH 18/18] Extends JSON schema/Intellisense to full details of InstallDefaultData settings. (#14568) * Fixes an incorrect property name used for generating JSON schema. * Added JSON schema/intellisense for default data dictionary keys. * Renamed private variable to match setting name. --- src/JsonSchema/AppSettings.cs | 16 +++++++++++++++- .../Migrations/Install/DatabaseSchemaCreator.cs | 8 ++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/JsonSchema/AppSettings.cs b/src/JsonSchema/AppSettings.cs index 717f826859..b491ed2052 100644 --- a/src/JsonSchema/AppSettings.cs +++ b/src/JsonSchema/AppSettings.cs @@ -88,13 +88,27 @@ namespace JsonSchema public HelpPageSettings? HelpPage { get; set; } - public InstallDefaultDataSettings? InstallDefaultData { get; set; } + public InstallDefaultData? InstallDefaultData { get; set; } public DataTypesSettings? DataTypes { get; set; } public MarketplaceSettings? Marketplace { get; set; } } + /// + /// Configurations for the Umbraco CMS InstallDefaultData configuration. + /// + public class InstallDefaultData + { + public InstallDefaultDataSettings? Languages { get; set; } + + public InstallDefaultDataSettings? DataTypes { get; set; } + + public InstallDefaultDataSettings? MediaTypes { get; set; } + + public InstallDefaultDataSettings? MemberTypes { get; set; } + } + /// /// Configurations for the Umbraco Forms package to Umbraco CMS /// diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index 3f12fd7d0d..a2f7374b48 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -87,7 +87,7 @@ public class DatabaseSchemaCreator }; private readonly IUmbracoDatabase _database; - private readonly IOptionsMonitor _defaultDataCreationSettings; + private readonly IOptionsMonitor _installDefaultDataSettings; private readonly IEventAggregator _eventAggregator; private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; @@ -118,7 +118,7 @@ public class DatabaseSchemaCreator _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); _umbracoVersion = umbracoVersion ?? throw new ArgumentNullException(nameof(umbracoVersion)); _eventAggregator = eventAggregator; - _defaultDataCreationSettings = defaultDataCreationSettings; + _installDefaultDataSettings = defaultDataCreationSettings; // TODO (V13): Rename this parameter to installDefaultDataSettings. if (_database?.SqlContext?.SqlSyntax == null) { @@ -178,7 +178,7 @@ public class DatabaseSchemaCreator var dataCreation = new DatabaseDataCreator( _database, _loggerFactory.CreateLogger(), _umbracoVersion, - _defaultDataCreationSettings); + _installDefaultDataSettings); foreach (Type table in _orderedTables) { CreateTable(false, table, dataCreation); @@ -455,7 +455,7 @@ public class DatabaseSchemaCreator _database, _loggerFactory.CreateLogger(), _umbracoVersion, - _defaultDataCreationSettings)); + _installDefaultDataSettings)); } ///