From dd94dabd61887e1ed5c1eff3ebcfa8d31138dc64 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 10 Aug 2023 10:35:12 +0200 Subject: [PATCH 01/26] update UI docs title to Umbraco 12 --- src/Umbraco.Web.UI.Docs/gulpfile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Docs/gulpfile.js b/src/Umbraco.Web.UI.Docs/gulpfile.js index 0d058d684c..e595eabe5d 100644 --- a/src/Umbraco.Web.UI.Docs/gulpfile.js +++ b/src/Umbraco.Web.UI.Docs/gulpfile.js @@ -18,7 +18,7 @@ gulp.task('docs', [], function (cb) { var options = { html5Mode: false, startPage: '/api', - title: "Umbraco 11 Backoffice UI API Documentation", + title: "Umbraco 12 Backoffice UI API Documentation", dest: './api', styles: ['./umb-docs.css'], image: "https://our.umbraco.com/assets/images/logo.svg" From b743e715d419f95d70bfd1a74889bc56047808c5 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Tue, 2 May 2023 09:46:43 +0200 Subject: [PATCH 02/26] Add DynamicRequestCultureProviderBase and improve locking (#14064) --- .../Security/AuthenticationExtensions.cs | 40 +++++++-- .../DynamicRequestCultureProviderBase.cs | 86 +++++++++++++++++++ ...mbracoBackOfficeIdentityCultureProvider.cs | 52 +++-------- .../UmbracoPublishedContentCultureProvider.cs | 64 +++----------- .../UmbracoRequestLocalizationOptions.cs | 14 +-- 5 files changed, 150 insertions(+), 106 deletions(-) create mode 100644 src/Umbraco.Web.Common/Localization/DynamicRequestCultureProviderBase.cs diff --git a/src/Umbraco.Core/Security/AuthenticationExtensions.cs b/src/Umbraco.Core/Security/AuthenticationExtensions.cs index 2b8294e8db..7e66a05b2e 100644 --- a/src/Umbraco.Core/Security/AuthenticationExtensions.cs +++ b/src/Umbraco.Core/Security/AuthenticationExtensions.cs @@ -7,26 +7,56 @@ using System.Security.Principal; namespace Umbraco.Extensions; +/// +/// Extension methods for . +/// public static class AuthenticationExtensions { /// - /// Ensures that the thread culture is set based on the back office user's culture + /// Ensures that the thread culture is set based on the back office user's culture. /// + /// The identity. public static void EnsureCulture(this IIdentity identity) { CultureInfo? culture = GetCulture(identity); - if (!(culture is null)) + if (culture is not null) { Thread.CurrentThread.CurrentUICulture = Thread.CurrentThread.CurrentCulture = culture; } } + /// + /// Gets the culture string from the back office user. + /// + /// The identity. + /// + /// The culture string. + /// + public static string? GetCultureString(this IIdentity identity) + { + if (identity is ClaimsIdentity umbIdentity && + umbIdentity.VerifyBackOfficeIdentity(out _) && + umbIdentity.IsAuthenticated) + { + return umbIdentity.GetCultureString(); + } + + return null; + } + + /// + /// Gets the culture from the back office user. + /// + /// The identity. + /// + /// The culture. + /// public static CultureInfo? GetCulture(this IIdentity identity) { - if (identity is ClaimsIdentity umbIdentity && umbIdentity.VerifyBackOfficeIdentity(out _) && - umbIdentity.IsAuthenticated && umbIdentity.GetCultureString() is not null) + string? culture = identity.GetCultureString(); + if (!string.IsNullOrEmpty(culture)) { - return CultureInfo.GetCultureInfo(umbIdentity.GetCultureString()!); + return CultureInfo.GetCultureInfo(culture); } return null; diff --git a/src/Umbraco.Web.Common/Localization/DynamicRequestCultureProviderBase.cs b/src/Umbraco.Web.Common/Localization/DynamicRequestCultureProviderBase.cs new file mode 100644 index 0000000000..84bf1531b7 --- /dev/null +++ b/src/Umbraco.Web.Common/Localization/DynamicRequestCultureProviderBase.cs @@ -0,0 +1,86 @@ +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; +using Microsoft.Extensions.Primitives; + +namespace Umbraco.Cms.Web.Common.Localization; + +/// +/// Base implementation that dynamically adds the determined cultures to the supported cultures. +/// +public abstract class DynamicRequestCultureProviderBase : RequestCultureProvider +{ + private readonly RequestLocalizationOptions _options; + private readonly object _lockerSupportedCultures = new(); + private readonly object _lockerSupportedUICultures = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The request localization options. + protected DynamicRequestCultureProviderBase(RequestLocalizationOptions requestLocalizationOptions) + => _options = Options = requestLocalizationOptions; + + /// + public override Task DetermineProviderCultureResult(HttpContext httpContext) + { + ProviderCultureResult? result = GetProviderCultureResult(httpContext); + if (result is not null) + { + // We need to dynamically change the supported cultures since we won't ever know what languages are used since + // they are dynamic within Umbraco. We have to handle this for both UI and Region cultures, in case people run different region and UI languages + // This code to check existence is borrowed from aspnetcore to avoid creating a CultureInfo + // https://github.com/dotnet/aspnetcore/blob/b795ac3546eb3e2f47a01a64feb3020794ca33bb/src/Middleware/Localization/src/RequestLocalizationMiddleware.cs#L165 + TryAddLocked(_options.SupportedCultures, result.Cultures, _lockerSupportedCultures, (culture) => + { + _options.SupportedCultures ??= new List(); + _options.SupportedCultures.Add(CultureInfo.GetCultureInfo(culture.ToString())); + }); + + TryAddLocked(_options.SupportedUICultures, result.UICultures, _lockerSupportedUICultures, (culture) => + { + _options.SupportedUICultures ??= new List(); + _options.SupportedUICultures.Add(CultureInfo.GetCultureInfo(culture.ToString())); + }); + + return Task.FromResult(result); + } + + return NullProviderCultureResult; + } + + /// + /// Gets the provider culture result. + /// + /// The HTTP context. + /// + /// The provider culture result. + /// + protected abstract ProviderCultureResult? GetProviderCultureResult(HttpContext httpContext); + + /// + /// Executes the within a double-checked lock when the a culture in does not exist in . + /// + /// The supported cultures. + /// The cultures. + /// The locker object to use. + /// The add action to execute. + private static void TryAddLocked(IEnumerable? supportedCultures, IEnumerable cultures, object locker, Action addAction) + { + foreach (StringSegment culture in cultures) + { + Func predicate = x => culture.Equals(x.Name, StringComparison.OrdinalIgnoreCase); + if (supportedCultures?.Any(predicate) is not true) + { + lock (locker) + { + if (supportedCultures?.Any(predicate) is not true) + { + addAction(culture); + } + } + } + } + } +} diff --git a/src/Umbraco.Web.Common/Localization/UmbracoBackOfficeIdentityCultureProvider.cs b/src/Umbraco.Web.Common/Localization/UmbracoBackOfficeIdentityCultureProvider.cs index 3e84774c7b..95b5a8d13e 100644 --- a/src/Umbraco.Web.Common/Localization/UmbracoBackOfficeIdentityCultureProvider.cs +++ b/src/Umbraco.Web.Common/Localization/UmbracoBackOfficeIdentityCultureProvider.cs @@ -1,7 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Globalization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Localization; @@ -10,50 +9,21 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Web.Common.Localization; /// -/// Sets the request culture to the culture of the back office user if one is determined to be in the request +/// Sets the request culture to the culture of the back office user, if one is determined to be in the request. /// -public class UmbracoBackOfficeIdentityCultureProvider : RequestCultureProvider +public class UmbracoBackOfficeIdentityCultureProvider : DynamicRequestCultureProviderBase { - private readonly RequestLocalizationOptions _localizationOptions; - private readonly object _locker = new(); - /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public UmbracoBackOfficeIdentityCultureProvider(RequestLocalizationOptions localizationOptions) => - _localizationOptions = localizationOptions; + /// The localization options. + public UmbracoBackOfficeIdentityCultureProvider(RequestLocalizationOptions localizationOptions) + : base(localizationOptions) + { } /// - public override Task DetermineProviderCultureResult(HttpContext httpContext) - { - CultureInfo? culture = httpContext.User.Identity?.GetCulture(); - - if (culture is null) - { - return NullProviderCultureResult; - } - - lock (_locker) - { - // We need to dynamically change the supported cultures since we won't ever know what languages are used since - // they are dynamic within Umbraco. We have to handle this for both UI and Region cultures, in case people run different region and UI languages - var cultureExists = _localizationOptions.SupportedCultures?.Contains(culture) ?? false; - - if (!cultureExists) - { - // add this as a supporting culture - _localizationOptions.SupportedCultures?.Add(culture); - } - - var uiCultureExists = _localizationOptions.SupportedCultures?.Contains(culture) ?? false; - - if (!uiCultureExists) - { - // add this as a supporting culture - _localizationOptions.SupportedUICultures?.Add(culture); - } - - return Task.FromResult(new ProviderCultureResult(culture.Name)); - } - } + protected sealed override ProviderCultureResult? GetProviderCultureResult(HttpContext httpContext) + => httpContext.User.Identity?.GetCultureString() is string culture + ? new ProviderCultureResult(culture) + : null; } diff --git a/src/Umbraco.Web.Common/Localization/UmbracoPublishedContentCultureProvider.cs b/src/Umbraco.Web.Common/Localization/UmbracoPublishedContentCultureProvider.cs index a3252c66e6..c2ad4805c0 100644 --- a/src/Umbraco.Web.Common/Localization/UmbracoPublishedContentCultureProvider.cs +++ b/src/Umbraco.Web.Common/Localization/UmbracoPublishedContentCultureProvider.cs @@ -1,69 +1,27 @@ -using System.Globalization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Localization; -using Microsoft.Extensions.Primitives; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Web.Common.Routing; namespace Umbraco.Cms.Web.Common.Localization; /// -/// Sets the request culture to the culture of the if one is found in the request +/// Sets the request culture to the culture of the , if one is found in the request. /// -public class UmbracoPublishedContentCultureProvider : RequestCultureProvider +public class UmbracoPublishedContentCultureProvider : DynamicRequestCultureProviderBase { - private readonly RequestLocalizationOptions _localizationOptions; - private readonly object _locker = new(); - /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public UmbracoPublishedContentCultureProvider(RequestLocalizationOptions localizationOptions) => - _localizationOptions = localizationOptions; + /// The localization options. + public UmbracoPublishedContentCultureProvider(RequestLocalizationOptions localizationOptions) + : base(localizationOptions) + { } /// - public override Task DetermineProviderCultureResult(HttpContext httpContext) - { - UmbracoRouteValues? routeValues = httpContext.Features.Get(); - if (routeValues != null) - { - var culture = routeValues.PublishedRequest.Culture; - if (culture != null) - { - lock (_locker) - { - // We need to dynamically change the supported cultures since we won't ever know what languages are used since - // they are dynamic within Umbraco. We have to handle this for both UI and Region cultures, in case people run different region and UI languages - // This code to check existence is borrowed from aspnetcore to avoid creating a CultureInfo - // https://github.com/dotnet/aspnetcore/blob/b795ac3546eb3e2f47a01a64feb3020794ca33bb/src/Middleware/Localization/src/RequestLocalizationMiddleware.cs#L165 - CultureInfo? existingCulture = _localizationOptions.SupportedCultures?.FirstOrDefault( - supportedCulture => - StringSegment.Equals(supportedCulture.Name, culture, StringComparison.OrdinalIgnoreCase)); - - if (existingCulture == null) - { - // add this as a supporting culture - var ci = CultureInfo.GetCultureInfo(culture); - _localizationOptions.SupportedCultures?.Add(ci); - } - - CultureInfo? existingUICulture = _localizationOptions.SupportedUICultures?.FirstOrDefault( - supportedCulture => - StringSegment.Equals(supportedCulture.Name, culture, StringComparison.OrdinalIgnoreCase)); - - if (existingUICulture == null) - { - // add this as a supporting culture - var ci = CultureInfo.GetCultureInfo(culture); - _localizationOptions.SupportedUICultures?.Add(ci); - } - } - - return Task.FromResult(new ProviderCultureResult(culture)); - } - } - - return NullProviderCultureResult; - } + protected sealed override ProviderCultureResult? GetProviderCultureResult(HttpContext httpContext) + => httpContext.Features.Get()?.PublishedRequest.Culture is string culture + ? new ProviderCultureResult(culture) + : null; } diff --git a/src/Umbraco.Web.Common/Localization/UmbracoRequestLocalizationOptions.cs b/src/Umbraco.Web.Common/Localization/UmbracoRequestLocalizationOptions.cs index 802a68607d..59c11dc986 100644 --- a/src/Umbraco.Web.Common/Localization/UmbracoRequestLocalizationOptions.cs +++ b/src/Umbraco.Web.Common/Localization/UmbracoRequestLocalizationOptions.cs @@ -1,28 +1,28 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Localization; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; namespace Umbraco.Cms.Web.Common.Localization; /// -/// Custom Umbraco options configuration for +/// Custom Umbraco options configuration for . /// public class UmbracoRequestLocalizationOptions : IConfigureOptions { private readonly GlobalSettings _globalSettings; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public UmbracoRequestLocalizationOptions(IOptions globalSettings) => - _globalSettings = globalSettings.Value; + /// The global settings. + public UmbracoRequestLocalizationOptions(IOptions globalSettings) + => _globalSettings = globalSettings.Value; /// public void Configure(RequestLocalizationOptions options) { - // set the default culture to what is in config - options.DefaultRequestCulture = new RequestCulture(_globalSettings.DefaultUILanguage); + // Set the default culture to what is in config + options.SetDefaultCulture(_globalSettings.DefaultUILanguage); options.RequestCultureProviders.Insert(0, new UmbracoBackOfficeIdentityCultureProvider(options)); options.RequestCultureProviders.Insert(1, new UmbracoPublishedContentCultureProvider(options)); From 7abfd146ad5e8a974ba59f2ceafa9e1040b0fc9a Mon Sep 17 00:00:00 2001 From: Mole Date: Mon, 14 Aug 2023 15:15:44 +0200 Subject: [PATCH 03/26] Ensure EfCore is known regardless of DeliveryApi (#14678) --- src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj | 7 ++----- .../DependencyInjection/UmbracoBuilderExtensions.cs | 1 - .../Composition/UmbracoEFCoreComposer.cs | 9 +++++++++ .../BackOfficeAuthBuilderOpenIddictExtensions.cs | 1 + src/Umbraco.Cms/Umbraco.Cms.csproj | 3 ++- .../Umbraco.Tests.Integration.csproj | 1 - 6 files changed, 14 insertions(+), 8 deletions(-) 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 e2d7c8e38d..c739e23335 100644 --- a/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj +++ b/src/Umbraco.Cms.Api.Common/Umbraco.Cms.Api.Common.csproj @@ -7,17 +7,14 @@ - + - + - - - diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 3c10318b07..34c6c37d18 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -32,7 +32,6 @@ public static class UmbracoBuilderExtensions builder.Services.ConfigureOptions(); builder.AddUmbracoApiOpenApiUI(); - builder.AddUmbracoEFCoreDbContext(); builder .Services .AddControllers() diff --git a/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs b/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs index f7cd21d3e7..8b8627df47 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.DependencyInjection; @@ -6,6 +7,7 @@ using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Infrastructure.Migrations; using Umbraco.Cms.Infrastructure.Migrations.Notifications; using Umbraco.Cms.Persistence.EFCore; +using Umbraco.Extensions; namespace Umbraco.Cms.Persistence.EFCore.Composition; @@ -17,6 +19,13 @@ public class UmbracoEFCoreComposer : IComposer builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); + + builder.Services.AddUmbracoEFCoreContext((options, connectionString, providerName) => + { + // Register the entity sets needed by OpenIddict. + options.UseOpenIddict(); + }); + builder.Services.AddOpenIddict() // Register the OpenIddict core components. diff --git a/src/Umbraco.Cms.Persistence.EFCore/Extensions/BackOfficeAuthBuilderOpenIddictExtensions.cs b/src/Umbraco.Cms.Persistence.EFCore/Extensions/BackOfficeAuthBuilderOpenIddictExtensions.cs index 2672b3b849..3764537988 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Extensions/BackOfficeAuthBuilderOpenIddictExtensions.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Extensions/BackOfficeAuthBuilderOpenIddictExtensions.cs @@ -6,6 +6,7 @@ namespace Umbraco.Extensions; public static class BackOfficeAuthBuilderOpenIddictExtensions { + [Obsolete("This is no longer used and will be removed in V15. Instead use the overload that specifies the DbContext type.")] public static IUmbracoBuilder AddUmbracoEFCoreDbContext(this IUmbracoBuilder builder) { builder.Services.AddUmbracoEFCoreContext((options, connectionString, providerName) => diff --git a/src/Umbraco.Cms/Umbraco.Cms.csproj b/src/Umbraco.Cms/Umbraco.Cms.csproj index 034c42356c..39a3b03c56 100644 --- a/src/Umbraco.Cms/Umbraco.Cms.csproj +++ b/src/Umbraco.Cms/Umbraco.Cms.csproj @@ -11,7 +11,8 @@ - + + diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 069fb7e5a7..0c675d2654 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -21,7 +21,6 @@ - From 6cdd56176fcfaa5b20c4f1443a2f1a4f9d70f89a Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Mon, 14 Aug 2023 18:49:56 +0200 Subject: [PATCH 04/26] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index df5a8f10bc..c4ce657196 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "12.1.0", + "version": "12.1.1", "assemblyVersion": { "precision": "build" }, From 30f0cfc15df9425329ed5f780034f82b1cc0ffe7 Mon Sep 17 00:00:00 2001 From: Anders Reus <88318565+andersreus@users.noreply.github.com> Date: Tue, 15 Aug 2023 09:46:27 +0200 Subject: [PATCH 05/26] Add exclusion filter setting to typefinder settings (#14426) * Obsolete constructor with deprecated IScopeProvider * Add exclusion setting to typefinder settings * The old TypeFinder constructor calls the new constructor now instead and excluded duplicates by using Union instead of Concat. * Revert "The old TypeFinder constructor calls the new constructor now instead and excluded duplicates by using Union instead of Concat." This reverts commit 87801c6c1cbaa6adab6f29dba1e876a586e05885. * Add changes to TypeFinder * Do not use null when type is not nullable --------- Co-authored-by: Bjarke Berg --- src/Umbraco.Core/Composing/TypeFinder.cs | 11 ++++++++++- .../Configuration/Models/TypeFinderSettings.cs | 5 +++++ .../Extensions/ServiceCollectionExtensions.cs | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Core/Composing/TypeFinder.cs b/src/Umbraco.Core/Composing/TypeFinder.cs index 3ac826880c..5925d3dab1 100644 --- a/src/Umbraco.Core/Composing/TypeFinder.cs +++ b/src/Umbraco.Core/Composing/TypeFinder.cs @@ -21,7 +21,7 @@ public class TypeFinder : ITypeFinder /// its an exact name match /// NOTE this means that "foo." will NOT exclude "foo.dll" but only "foo.*.dll" /// - internal static readonly string[] KnownAssemblyExclusionFilter = + internal static string[] KnownAssemblyExclusionFilter = { "mscorlib,", "netstandard,", "System,", "Antlr3.", "AutoMapper,", "AutoMapper.", "Autofac,", // DI "Autofac.", "AzureDirectory,", "Castle.", // DI, tests @@ -49,11 +49,20 @@ public class TypeFinder : ITypeFinder private string[]? _assembliesAcceptingLoadExceptions; private volatile HashSet? _localFilteredAssemblyCache; + [Obsolete("Please use the constructor taking all parameters. This constructor will be removed in V14.")] public TypeFinder(ILogger logger, IAssemblyProvider assemblyProvider, ITypeFinderConfig? typeFinderConfig = null) + : this(logger, assemblyProvider, null, typeFinderConfig) + { } + + public TypeFinder(ILogger logger, IAssemblyProvider assemblyProvider, string[]? additionalExlusionAssemblies, ITypeFinderConfig? typeFinderConfig = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _assemblyProvider = assemblyProvider; _typeFinderConfig = typeFinderConfig; + if (additionalExlusionAssemblies is not null) + { + KnownAssemblyExclusionFilter = KnownAssemblyExclusionFilter.Union(additionalExlusionAssemblies).ToArray(); + } } /// diff --git a/src/Umbraco.Core/Configuration/Models/TypeFinderSettings.cs b/src/Umbraco.Core/Configuration/Models/TypeFinderSettings.cs index f281bbc31a..70df241ca1 100644 --- a/src/Umbraco.Core/Configuration/Models/TypeFinderSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/TypeFinderSettings.cs @@ -22,4 +22,9 @@ public class TypeFinderSettings /// scanning for plugins based on different root referenced assemblies you can add the assembly name to this list. /// public IEnumerable? AdditionalEntryAssemblies { get; set; } + + /// + /// Gets or sets a value for the assemblies that will be excluded from scanning. + /// + public string[] AdditionalAssemblyExclusionEntries { get; set; } = Array.Empty(); } diff --git a/src/Umbraco.Web.Common/Extensions/ServiceCollectionExtensions.cs b/src/Umbraco.Web.Common/Extensions/ServiceCollectionExtensions.cs index c9cd08516d..4c91d113de 100644 --- a/src/Umbraco.Web.Common/Extensions/ServiceCollectionExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/ServiceCollectionExtensions.cs @@ -200,6 +200,7 @@ public static class ServiceCollectionExtensions var typeFinder = new TypeFinder( loggerFactory.CreateLogger(), assemblyProvider, + typeFinderSettings.AdditionalAssemblyExclusionEntries, typeFinderConfig); var typeLoader = new TypeLoader(typeFinder, loggerFactory.CreateLogger()); From e7af98027daf1a87a413b3fb70147b3dd11c90c8 Mon Sep 17 00:00:00 2001 From: Andreas Zerbst <73799582+andr317c@users.noreply.github.com> Date: Fri, 18 Aug 2023 12:36:54 +0200 Subject: [PATCH 06/26] Updated the name of our Acceptance test project in the pipeline (#14691) * Updated the name of our Acceptance test project. The reason is because we want the naming to be clear and concise. * Changed the name of the e2e pipeline project name for linux * Updated dll file --- build/azure-pipelines.yml | 4 ++-- tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 068d55107c..48f38966f4 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -368,7 +368,7 @@ stages: Umbraco__CMS__Unattended__InstallUnattended: true Umbraco__CMS__Global__InstallMissingDatabase: true UmbracoDatabaseServer: (LocalDB)\MSSQLLocalDB - UmbracoDatabaseName: Playwright + UmbracoDatabaseName: AcceptanceTestDB ConnectionStrings__umbracoDbDSN: Server=$(UmbracoDatabaseServer);Database=$(UmbracoDatabaseName);Integrated Security=true; # Custom Umbraco settings Umbraco__CMS__Global__VersionCheckPeriod: 0 @@ -429,7 +429,7 @@ stages: workingDirectory: tests/Umbraco.Tests.AcceptanceTest/misc - pwsh: | dotnet new --install ./nupkg/Umbraco.Templates.*.nupkg - dotnet new umbraco --name Playwright --no-restore --output . + dotnet new umbraco --name AcceptanceTestProject --no-restore --output . dotnet restore --configfile ./nuget.config dotnet build --configuration $(buildConfiguration) --no-restore dotnet dev-certs https diff --git a/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker b/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker index 90b0b96ad2..10c49d71f9 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker +++ b/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker @@ -11,7 +11,7 @@ COPY nupkg . WORKDIR /build RUN dotnet new --install /nupkg/Umbraco.Templates.*.nupkg -RUN dotnet new umbraco --name Playwright --no-restore --output . +RUN dotnet new umbraco --name AcceptanceTestProject --no-restore --output . RUN dotnet restore --configfile /nuget.config RUN dotnet build --configuration Release --no-restore RUN dotnet publish --configuration Release --no-build --output /dist @@ -46,4 +46,4 @@ ENV Umbraco__CMS__KeepAlive__DisableKeepAliveTask="true" # Set application URL ENV ASPNETCORE_URLS="http://0.0.0.0:5000;https://0.0.0.0:5001" -CMD dotnet Playwright.dll +CMD dotnet AcceptanceTestProject.dll From 311d322129d6db5ed5c3f1be98ee58e4e35ed2d4 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 21 Aug 2023 13:08:26 +0200 Subject: [PATCH 07/26] Add code infrastructure to validate file content (#14657) * Implemented modular architecture for filestream security sanitization with an svg-html example * 31440: Refactoring, applied to more entry points and removed test analyzer * 31440 Added Unittests for FileStreamSecurityValidator * PR fixes and better unittest mock names --------- Co-authored-by: Sven Geusens --- .../DependencyInjection/UmbracoBuilder.cs | 3 + .../EmbeddedResources/Lang/en.xml | 1 + .../EmbeddedResources/Lang/en_us.xml | 1 + .../EmbeddedResources/Lang/nl.xml | 1 + .../Security/FileStreamSecurityValidator.cs | 38 ++++++ .../Security/IFileStreamSecurityAnalyzer.cs | 20 +++ .../Security/IFileStreamSecurityValidator.cs | 11 ++ .../FileUploadPropertyValueEditor.cs | 11 +- .../ImageCropperPropertyValueEditor.cs | 11 +- .../Controllers/CurrentUserController.cs | 35 +++++ .../Controllers/MediaController.cs | 68 +++++++++- .../Controllers/UsersController.cs | 68 +++++++++- .../FileStreamSecurityValidatorTests.cs | 123 ++++++++++++++++++ 13 files changed, 382 insertions(+), 9 deletions(-) create mode 100644 src/Umbraco.Core/Security/FileStreamSecurityValidator.cs create mode 100644 src/Umbraco.Core/Security/IFileStreamSecurityAnalyzer.cs create mode 100644 src/Umbraco.Core/Security/IFileStreamSecurityValidator.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/Security/FileStreamSecurityValidatorTests.cs diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index a850a8f371..dcf1ee5183 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -327,6 +327,9 @@ namespace Umbraco.Cms.Core.DependencyInjection Services.AddUnique(provider => new CultureImpactFactory(provider.GetRequiredService>())); Services.AddUnique(); Services.AddUnique(); + + // Register filestream security analyzers + Services.AddUnique(); } } } diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 3b7bf261bd..a2ab932701 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -341,6 +341,7 @@ Failed to create a folder under parent id %0% Failed to rename the folder with id %0% Drag and drop your file(s) into the area + One or more file security validations have failed Create a new member diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index 44d07b2511..699bf69ebd 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -352,6 +352,7 @@ Failed to rename the folder with id %0% Drag and drop your file(s) into the area Upload is not allowed in this location. + One or more file security validations have failed Create a new member diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml b/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml index eab1f856d9..847f6807d4 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml @@ -340,6 +340,7 @@ Kan de map met id %0% niet hernoemen Sleep en zet je bestand(en) neer in dit gebied Upload is niet toegelaten in deze locatie. + Een of meerdere veiligheid validaties zijn gefaald voor het bestand Maak nieuw lid aan diff --git a/src/Umbraco.Core/Security/FileStreamSecurityValidator.cs b/src/Umbraco.Core/Security/FileStreamSecurityValidator.cs new file mode 100644 index 0000000000..764ea37d3d --- /dev/null +++ b/src/Umbraco.Core/Security/FileStreamSecurityValidator.cs @@ -0,0 +1,38 @@ +namespace Umbraco.Cms.Core.Security; + +public class FileStreamSecurityValidator : IFileStreamSecurityValidator +{ + private readonly IEnumerable _fileAnalyzers; + + public FileStreamSecurityValidator(IEnumerable fileAnalyzers) + { + _fileAnalyzers = fileAnalyzers; + } + + /// + /// Analyzes whether the file content is considered safe with registered IFileStreamSecurityAnalyzers + /// + /// Needs to be a Read seekable stream + /// Whether the file is considered safe after running the necessary analyzers + public bool IsConsideredSafe(Stream fileStream) + { + foreach (var fileAnalyzer in _fileAnalyzers) + { + fileStream.Seek(0, SeekOrigin.Begin); + if (!fileAnalyzer.ShouldHandle(fileStream)) + { + continue; + } + + fileStream.Seek(0, SeekOrigin.Begin); + if (fileAnalyzer.IsConsideredSafe(fileStream) == false) + { + return false; + } + } + fileStream.Seek(0, SeekOrigin.Begin); + // If no analyzer we consider the file to be safe as the implementer has the possibility to add additional analyzers + // Or all analyzers deem te file to be safe + return true; + } +} diff --git a/src/Umbraco.Core/Security/IFileStreamSecurityAnalyzer.cs b/src/Umbraco.Core/Security/IFileStreamSecurityAnalyzer.cs new file mode 100644 index 0000000000..408f161f21 --- /dev/null +++ b/src/Umbraco.Core/Security/IFileStreamSecurityAnalyzer.cs @@ -0,0 +1,20 @@ +namespace Umbraco.Cms.Core.Security; + +public interface IFileStreamSecurityAnalyzer +{ + + /// + /// Indicates whether the analyzer should process the file + /// The implementation should be considerably faster than IsConsideredSafe + /// + /// + /// + bool ShouldHandle(Stream fileStream); + + /// + /// Analyzes whether the file content is considered safe + /// + /// Needs to be a Read/Write seekable stream + /// Whether the file is considered safe + bool IsConsideredSafe(Stream fileStream); +} diff --git a/src/Umbraco.Core/Security/IFileStreamSecurityValidator.cs b/src/Umbraco.Core/Security/IFileStreamSecurityValidator.cs new file mode 100644 index 0000000000..9dc60507ac --- /dev/null +++ b/src/Umbraco.Core/Security/IFileStreamSecurityValidator.cs @@ -0,0 +1,11 @@ +namespace Umbraco.Cms.Core.Security; + +public interface IFileStreamSecurityValidator +{ + /// + /// Analyzes wether the file content is considered safe with registered IFileStreamSecurityAnalyzers + /// + /// Needs to be a Read seekable stream + /// Whether the file is considered safe after running the necessary analyzers + bool IsConsideredSafe(Stream fileStream); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs index 5a14a1afc1..34a4d33fd9 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; @@ -18,6 +19,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; internal class FileUploadPropertyValueEditor : DataValueEditor { private readonly MediaFileManager _mediaFileManager; + private readonly IFileStreamSecurityValidator _fileStreamSecurityValidator; private ContentSettings _contentSettings; public FileUploadPropertyValueEditor( @@ -27,10 +29,12 @@ internal class FileUploadPropertyValueEditor : DataValueEditor IShortStringHelper shortStringHelper, IOptionsMonitor contentSettings, IJsonSerializer jsonSerializer, - IIOHelper ioHelper) + IIOHelper ioHelper, + IFileStreamSecurityValidator fileStreamSecurityValidator) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _mediaFileManager = mediaFileManager ?? throw new ArgumentNullException(nameof(mediaFileManager)); + _fileStreamSecurityValidator = fileStreamSecurityValidator; _contentSettings = contentSettings.CurrentValue ?? throw new ArgumentNullException(nameof(contentSettings)); contentSettings.OnChange(x => _contentSettings = x); } @@ -147,6 +151,11 @@ internal class FileUploadPropertyValueEditor : DataValueEditor using (FileStream filestream = File.OpenRead(file.TempFilePath)) { + if (_fileStreamSecurityValidator.IsConsideredSafe(filestream) == false) + { + return null; + } + // TODO: Here it would make sense to do the auto-fill properties stuff but the API doesn't allow us to do that right // since we'd need to be able to return values for other properties from these methods _mediaFileManager.FileSystem.AddFile(filepath, filestream, true); // must overwrite! diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs index 223e62d5a3..2c44ef57e3 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; @@ -24,6 +25,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core vs web? { private readonly IDataTypeService _dataTypeService; + private readonly IFileStreamSecurityValidator _fileStreamSecurityValidator; private readonly ILogger _logger; private readonly MediaFileManager _mediaFileManager; private ContentSettings _contentSettings; @@ -37,13 +39,15 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v IOptionsMonitor contentSettings, IJsonSerializer jsonSerializer, IIOHelper ioHelper, - IDataTypeService dataTypeService) + IDataTypeService dataTypeService, + IFileStreamSecurityValidator fileStreamSecurityValidator) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _mediaFileManager = mediaFileSystem ?? throw new ArgumentNullException(nameof(mediaFileSystem)); _contentSettings = contentSettings.CurrentValue; _dataTypeService = dataTypeService; + _fileStreamSecurityValidator = fileStreamSecurityValidator; contentSettings.OnChange(x => _contentSettings = x); } @@ -236,6 +240,11 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v using (FileStream filestream = File.OpenRead(file.TempFilePath)) { + if (_fileStreamSecurityValidator.IsConsideredSafe(filestream) == false) + { + return null; + } + // TODO: Here it would make sense to do the auto-fill properties stuff but the API doesn't allow us to do that right // since we'd need to be able to return values for other properties from these methods _mediaFileManager.FileSystem.AddFile(filepath, filestream, true); // must overwrite! diff --git a/src/Umbraco.Web.BackOffice/Controllers/CurrentUserController.cs b/src/Umbraco.Web.BackOffice/Controllers/CurrentUserController.cs index f867ccc5a1..4992c4921e 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/CurrentUserController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/CurrentUserController.cs @@ -48,9 +48,43 @@ public class CurrentUserController : UmbracoAuthorizedJsonController private readonly IShortStringHelper _shortStringHelper; private readonly IUmbracoMapper _umbracoMapper; private readonly IUserDataService _userDataService; + private readonly IFileStreamSecurityValidator? _fileStreamSecurityValidator; // make non nullable in v14 private readonly IUserService _userService; [ActivatorUtilitiesConstructor] + public CurrentUserController( + MediaFileManager mediaFileManager, + IOptionsSnapshot contentSettings, + IHostingEnvironment hostingEnvironment, + IImageUrlGenerator imageUrlGenerator, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IUserService userService, + IUmbracoMapper umbracoMapper, + IBackOfficeUserManager backOfficeUserManager, + ILocalizedTextService localizedTextService, + AppCaches appCaches, + IShortStringHelper shortStringHelper, + IPasswordChanger passwordChanger, + IUserDataService userDataService, + IFileStreamSecurityValidator fileStreamSecurityValidator) + { + _mediaFileManager = mediaFileManager; + _contentSettings = contentSettings.Value; + _hostingEnvironment = hostingEnvironment; + _imageUrlGenerator = imageUrlGenerator; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _userService = userService; + _umbracoMapper = umbracoMapper; + _backOfficeUserManager = backOfficeUserManager; + _localizedTextService = localizedTextService; + _appCaches = appCaches; + _shortStringHelper = shortStringHelper; + _passwordChanger = passwordChanger; + _userDataService = userDataService; + _fileStreamSecurityValidator = fileStreamSecurityValidator; + } + + [Obsolete("Use constructor overload that has fileStreamSecurityValidator, scheduled for removal in v14")] public CurrentUserController( MediaFileManager mediaFileManager, IOptionsSnapshot contentSettings, @@ -285,6 +319,7 @@ public class CurrentUserController : UmbracoAuthorizedJsonController _contentSettings, _hostingEnvironment, _imageUrlGenerator, + _fileStreamSecurityValidator, _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? 0); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs index a26934b3c1..807061e5aa 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -50,6 +51,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers; public class MediaController : ContentControllerBase { private readonly AppCaches _appCaches; + private readonly IFileStreamSecurityValidator? _fileStreamSecurityValidator; // make non nullable in v14 private readonly IAuthorizationService _authorizationService; private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; private readonly ContentSettings _contentSettings; @@ -66,6 +68,58 @@ public class MediaController : ContentControllerBase private readonly ISqlContext _sqlContext; private readonly IUmbracoMapper _umbracoMapper; + [ActivatorUtilitiesConstructor] + public MediaController( + ICultureDictionary cultureDictionary, + ILoggerFactory loggerFactory, + IShortStringHelper shortStringHelper, + IEventMessagesFactory eventMessages, + ILocalizedTextService localizedTextService, + IOptionsSnapshot contentSettings, + IMediaTypeService mediaTypeService, + IMediaService mediaService, + IEntityService entityService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + IUmbracoMapper umbracoMapper, + IDataTypeService dataTypeService, + ISqlContext sqlContext, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + IRelationService relationService, + PropertyEditorCollection propertyEditors, + MediaFileManager mediaFileManager, + MediaUrlGeneratorCollection mediaUrlGenerators, + IHostingEnvironment hostingEnvironment, + IImageUrlGenerator imageUrlGenerator, + IJsonSerializer serializer, + IAuthorizationService authorizationService, + AppCaches appCaches, + IFileStreamSecurityValidator streamSecurityValidator) + : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, serializer) + { + _shortStringHelper = shortStringHelper; + _contentSettings = contentSettings.Value; + _mediaTypeService = mediaTypeService; + _mediaService = mediaService; + _entityService = entityService; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _umbracoMapper = umbracoMapper; + _dataTypeService = dataTypeService; + _localizedTextService = localizedTextService; + _sqlContext = sqlContext; + _contentTypeBaseServiceProvider = contentTypeBaseServiceProvider; + _relationService = relationService; + _propertyEditors = propertyEditors; + _mediaFileManager = mediaFileManager; + _mediaUrlGenerators = mediaUrlGenerators; + _hostingEnvironment = hostingEnvironment; + _logger = loggerFactory.CreateLogger(); + _imageUrlGenerator = imageUrlGenerator; + _authorizationService = authorizationService; + _appCaches = appCaches; + _fileStreamSecurityValidator = streamSecurityValidator; + } + + [Obsolete("Use constructor overload that has fileStreamSecurityValidator, scheduled for removal in v14")] public MediaController( ICultureDictionary cultureDictionary, ILoggerFactory loggerFactory, @@ -724,6 +778,17 @@ public class MediaController : ContentControllerBase continue; } + using var stream = new MemoryStream(); + await formFile.CopyToAsync(stream); + if (_fileStreamSecurityValidator != null && _fileStreamSecurityValidator.IsConsideredSafe(stream) == false) + { + tempFiles.Notifications.Add(new BackOfficeNotification( + _localizedTextService.Localize("speechBubbles", "operationFailedHeader"), + _localizedTextService.Localize("media", "fileSecurityValidationFailure"), + NotificationStyle.Warning)); + continue; + } + if (string.IsNullOrEmpty(mediaTypeAlias)) { mediaTypeAlias = Constants.Conventions.MediaTypes.File; @@ -801,11 +866,8 @@ public class MediaController : ContentControllerBase IMedia createdMediaItem = _mediaService.CreateMedia(mediaItemName, parentId.Value, mediaTypeAlias, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - await using (Stream stream = formFile.OpenReadStream()) - { createdMediaItem.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper, _contentTypeBaseServiceProvider, Constants.Conventions.Media.File, fileName, stream); - } Attempt saveResult = _mediaService.Save(createdMediaItem, _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index 1a8be10a5c..d8e6aa8924 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -55,6 +55,7 @@ public class UsersController : BackOfficeNotificationsController private readonly GlobalSettings _globalSettings; private readonly IHostingEnvironment _hostingEnvironment; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IFileStreamSecurityValidator? _fileStreamSecurityValidator; // make non nullable in v14 private readonly IImageUrlGenerator _imageUrlGenerator; private readonly LinkGenerator _linkGenerator; private readonly ILocalizedTextService _localizedTextService; @@ -72,6 +73,58 @@ public class UsersController : BackOfficeNotificationsController private readonly WebRoutingSettings _webRoutingSettings; [ActivatorUtilitiesConstructor] + public UsersController( + MediaFileManager mediaFileManager, + IOptionsSnapshot contentSettings, + IHostingEnvironment hostingEnvironment, + ISqlContext sqlContext, + IImageUrlGenerator imageUrlGenerator, + IOptionsSnapshot securitySettings, + IEmailSender emailSender, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + AppCaches appCaches, + IShortStringHelper shortStringHelper, + IUserService userService, + ILocalizedTextService localizedTextService, + IUmbracoMapper umbracoMapper, + IOptionsSnapshot globalSettings, + IBackOfficeUserManager backOfficeUserManager, + ILoggerFactory loggerFactory, + LinkGenerator linkGenerator, + IBackOfficeExternalLoginProviders externalLogins, + UserEditorAuthorizationHelper userEditorAuthorizationHelper, + IPasswordChanger passwordChanger, + IHttpContextAccessor httpContextAccessor, + IOptions webRoutingSettings, + IFileStreamSecurityValidator fileStreamSecurityValidator) + { + _mediaFileManager = mediaFileManager; + _contentSettings = contentSettings.Value; + _hostingEnvironment = hostingEnvironment; + _sqlContext = sqlContext; + _imageUrlGenerator = imageUrlGenerator; + _securitySettings = securitySettings.Value; + _emailSender = emailSender; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _appCaches = appCaches; + _shortStringHelper = shortStringHelper; + _userService = userService; + _localizedTextService = localizedTextService; + _umbracoMapper = umbracoMapper; + _globalSettings = globalSettings.Value; + _userManager = backOfficeUserManager; + _loggerFactory = loggerFactory; + _linkGenerator = linkGenerator; + _externalLogins = externalLogins; + _userEditorAuthorizationHelper = userEditorAuthorizationHelper; + _passwordChanger = passwordChanger; + _logger = _loggerFactory.CreateLogger(); + _httpContextAccessor = httpContextAccessor; + _fileStreamSecurityValidator = fileStreamSecurityValidator; + _webRoutingSettings = webRoutingSettings.Value; + } + + [Obsolete("Use constructor overload that has fileStreamSecurityValidator, scheduled for removal in v14")] public UsersController( MediaFileManager mediaFileManager, IOptionsSnapshot contentSettings, @@ -139,13 +192,15 @@ public class UsersController : BackOfficeNotificationsController [AppendUserModifiedHeader("id")] [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public IActionResult PostSetAvatar(int id, IList file) => PostSetAvatarInternal(file, _userService, + public IActionResult PostSetAvatar(int id, IList file) + => PostSetAvatarInternal(file, _userService, _appCaches.RuntimeCache, _mediaFileManager, _shortStringHelper, _contentSettings, _hostingEnvironment, - _imageUrlGenerator, id); + _imageUrlGenerator,_fileStreamSecurityValidator, id); internal static IActionResult PostSetAvatarInternal(IList files, IUserService userService, IAppCache cache, MediaFileManager mediaFileManager, IShortStringHelper shortStringHelper, ContentSettings contentSettings, IHostingEnvironment hostingEnvironment, IImageUrlGenerator imageUrlGenerator, + IFileStreamSecurityValidator? fileStreamSecurityValidator, int id) { if (files is null) @@ -187,9 +242,14 @@ public class UsersController : BackOfficeNotificationsController //generate a path of known data, we don't want this path to be guessable user.Avatar = "UserAvatars/" + (user.Id + safeFileName).GenerateHash() + "." + ext; - using (Stream fs = file.OpenReadStream()) + //todo implement Filestreamsecurity + using (var ms = new MemoryStream()) { - mediaFileManager.FileSystem.AddFile(user.Avatar, fs, true); + file.CopyTo(ms); + if(fileStreamSecurityValidator != null && fileStreamSecurityValidator.IsConsideredSafe(ms) == false) + return new ValidationErrorResult("One or more file security analyzers deemed the contents of the file to be unsafe"); + + mediaFileManager.FileSystem.AddFile(user.Avatar, ms, true); } userService.Save(user); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Security/FileStreamSecurityValidatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Security/FileStreamSecurityValidatorTests.cs new file mode 100644 index 0000000000..7cdee69daa --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Security/FileStreamSecurityValidatorTests.cs @@ -0,0 +1,123 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Security; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Security; + +public class FileStreamSecurityValidatorTests +{ + [Test] + public void IsConsideredSafe_True_NoAnalyzersPresent() + { + // Arrange + var sut = new FileStreamSecurityValidator(Enumerable.Empty()); + + using var memoryStream = new MemoryStream(); + using var streamWriter = new StreamWriter(memoryStream); + streamWriter.Write("TestContent"); + streamWriter.Flush(); + memoryStream.Seek(0, SeekOrigin.Begin); + + // Act + var validationResult = sut.IsConsideredSafe(memoryStream); + + // Assert + Assert.IsTrue(validationResult); + } + + [Test] + public void IsConsideredSafe_True_NoAnalyzerMatchesType() + { + // Arrange + var analyzerOne = new Mock(); + analyzerOne.Setup(analyzer => analyzer.ShouldHandle(It.IsAny())) + .Returns(false); + var analyzerTwo = new Mock(); + analyzerTwo.Setup(analyzer => analyzer.ShouldHandle(It.IsAny())) + .Returns(false); + + var sut = new FileStreamSecurityValidator(new List{analyzerOne.Object,analyzerTwo.Object}); + + using var memoryStream = new MemoryStream(); + using var streamWriter = new StreamWriter(memoryStream); + streamWriter.Write("TestContent"); + streamWriter.Flush(); + memoryStream.Seek(0, SeekOrigin.Begin); + + // Act + var validationResult = sut.IsConsideredSafe(memoryStream); + + // Assert + Assert.IsTrue(validationResult); + } + + [Test] + public void IsConsideredSafe_True_AllMatchingAnalyzersReturnTrue() + { + // Arrange + var matchingAnalyzerOne = new Mock(); + matchingAnalyzerOne.Setup(analyzer => analyzer.ShouldHandle(It.IsAny())) + .Returns(true); + matchingAnalyzerOne.Setup(analyzer => analyzer.IsConsideredSafe(It.IsAny())) + .Returns(true); + + var matchingAnalyzerTwo = new Mock(); + matchingAnalyzerTwo.Setup(analyzer => analyzer.ShouldHandle(It.IsAny())) + .Returns(true); + matchingAnalyzerTwo.Setup(analyzer => analyzer.IsConsideredSafe(It.IsAny())) + .Returns(true); + + var unmatchedAnalyzer = new Mock(); + unmatchedAnalyzer.Setup(analyzer => analyzer.ShouldHandle(It.IsAny())) + .Returns(false); + + var sut = new FileStreamSecurityValidator(new List{matchingAnalyzerOne.Object,matchingAnalyzerTwo.Object}); + + using var memoryStream = new MemoryStream(); + using var streamWriter = new StreamWriter(memoryStream); + streamWriter.Write("TestContent"); + streamWriter.Flush(); + memoryStream.Seek(0, SeekOrigin.Begin); + + // Act + var validationResult = sut.IsConsideredSafe(memoryStream); + + // Assert + Assert.IsTrue(validationResult); + } + + [Test] + public void IsConsideredSafe_False_AnyMatchingAnalyzersReturnFalse() + { + // Arrange + var saveMatchingAnalyzer = new Mock(); + saveMatchingAnalyzer.Setup(analyzer => analyzer.ShouldHandle(It.IsAny())) + .Returns(true); + saveMatchingAnalyzer.Setup(analyzer => analyzer.IsConsideredSafe(It.IsAny())) + .Returns(true); + + var unsafeMatchingAnalyzer = new Mock(); + unsafeMatchingAnalyzer.Setup(analyzer => analyzer.ShouldHandle(It.IsAny())) + .Returns(true); + unsafeMatchingAnalyzer.Setup(analyzer => analyzer.IsConsideredSafe(It.IsAny())) + .Returns(false); + + var unmatchedAnalyzer = new Mock(); + unmatchedAnalyzer.Setup(analyzer => analyzer.ShouldHandle(It.IsAny())) + .Returns(false); + + var sut = new FileStreamSecurityValidator(new List{saveMatchingAnalyzer.Object,unsafeMatchingAnalyzer.Object}); + + using var memoryStream = new MemoryStream(); + using var streamWriter = new StreamWriter(memoryStream); + streamWriter.Write("TestContent"); + streamWriter.Flush(); + memoryStream.Seek(0, SeekOrigin.Begin); + + // Act + var validationResult = sut.IsConsideredSafe(memoryStream); + + // Assert + Assert.IsFalse(validationResult); + } +} From 0dc18982ee9f2e078334b5a28de0908e79195176 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Mon, 21 Aug 2023 13:17:15 +0200 Subject: [PATCH 08/26] Do not allow content type property aliases that conflict with IPublishedElement (#14683) --- src/Umbraco.Core/Extensions/TypeExtensions.cs | 159 +++++++++--------- .../ContentTypeModelValidatorBase.cs | 6 +- 2 files changed, 82 insertions(+), 83 deletions(-) diff --git a/src/Umbraco.Core/Extensions/TypeExtensions.cs b/src/Umbraco.Core/Extensions/TypeExtensions.cs index e3da8d9ee1..1416888e73 100644 --- a/src/Umbraco.Core/Extensions/TypeExtensions.cs +++ b/src/Umbraco.Core/Extensions/TypeExtensions.cs @@ -219,96 +219,52 @@ public static class TypeExtensions /// public static PropertyInfo[] GetAllProperties(this Type type) { - if (type.IsInterface) - { - var propertyInfos = new List(); - - var considered = new List(); - var queue = new Queue(); - considered.Add(type); - queue.Enqueue(type); - while (queue.Count > 0) - { - Type subType = queue.Dequeue(); - foreach (Type subInterface in subType.GetInterfaces()) - { - if (considered.Contains(subInterface)) - { - continue; - } - - considered.Add(subInterface); - queue.Enqueue(subInterface); - } - - PropertyInfo[] typeProperties = subType.GetProperties( - BindingFlags.FlattenHierarchy - | BindingFlags.Public - | BindingFlags.NonPublic - | BindingFlags.Instance); - - IEnumerable newPropertyInfos = typeProperties - .Where(x => !propertyInfos.Contains(x)); - - propertyInfos.InsertRange(0, newPropertyInfos); - } - - return propertyInfos.ToArray(); - } - - return type.GetProperties(BindingFlags.FlattenHierarchy - | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + const BindingFlags bindingFlags = BindingFlags.FlattenHierarchy + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Instance; + return type.GetAllMemberInfos(t => t.GetProperties(bindingFlags)); } /// - /// Returns all public properties including inherited properties even for interfaces + /// Returns public properties including inherited properties even for interfaces /// /// /// - /// - /// taken from - /// http://stackoverflow.com/questions/358835/getproperties-to-return-all-properties-for-an-interface-inheritance-hierarchy - /// public static PropertyInfo[] GetPublicProperties(this Type type) { - if (type.IsInterface) - { - var propertyInfos = new List(); + const BindingFlags bindingFlags = BindingFlags.FlattenHierarchy + | BindingFlags.Public + | BindingFlags.Instance; + return type.GetAllMemberInfos(t => t.GetProperties(bindingFlags)); + } - var considered = new List(); - var queue = new Queue(); - considered.Add(type); - queue.Enqueue(type); - while (queue.Count > 0) - { - Type subType = queue.Dequeue(); - foreach (Type subInterface in subType.GetInterfaces()) - { - if (considered.Contains(subInterface)) - { - continue; - } + /// + /// Returns public methods including inherited methods even for interfaces + /// + /// + /// + public static MethodInfo[] GetPublicMethods(this Type type) + { + const BindingFlags bindingFlags = BindingFlags.FlattenHierarchy + | BindingFlags.Public + | BindingFlags.Instance; + return type.GetAllMemberInfos(t => t.GetMethods(bindingFlags)); + } - considered.Add(subInterface); - queue.Enqueue(subInterface); - } - - PropertyInfo[] typeProperties = subType.GetProperties( - BindingFlags.FlattenHierarchy - | BindingFlags.Public - | BindingFlags.Instance); - - IEnumerable newPropertyInfos = typeProperties - .Where(x => !propertyInfos.Contains(x)); - - propertyInfos.InsertRange(0, newPropertyInfos); - } - - return propertyInfos.ToArray(); - } - - return type.GetProperties(BindingFlags.FlattenHierarchy - | BindingFlags.Public | BindingFlags.Instance); + /// + /// Returns all methods including inherited methods even for interfaces + /// + /// Includes both Public and Non-Public methods + /// + /// + public static MethodInfo[] GetAllMethods(this Type type) + { + const BindingFlags bindingFlags = BindingFlags.FlattenHierarchy + | BindingFlags.Public + | BindingFlags.NonPublic + | BindingFlags.Instance; + return type.GetAllMemberInfos(t => t.GetMethods(bindingFlags)); } /// @@ -512,4 +468,47 @@ public static class TypeExtensions return attempt; } + + /// + /// taken from + /// http://stackoverflow.com/questions/358835/getproperties-to-return-all-properties-for-an-interface-inheritance-hierarchy + /// + private static T[] GetAllMemberInfos(this Type type, Func getMemberInfos) + where T : MemberInfo + { + if (type.IsInterface is false) + { + return getMemberInfos(type); + } + + var memberInfos = new List(); + + var considered = new List(); + var queue = new Queue(); + considered.Add(type); + queue.Enqueue(type); + while (queue.Count > 0) + { + Type subType = queue.Dequeue(); + foreach (Type subInterface in subType.GetInterfaces()) + { + if (considered.Contains(subInterface)) + { + continue; + } + + considered.Add(subInterface); + queue.Enqueue(subInterface); + } + + T[] typeMethodInfos = getMemberInfos(subType); + + IEnumerable newMethodInfos = typeMethodInfos + .Where(x => !memberInfos.Contains(x)); + + memberInfos.InsertRange(0, newMethodInfos); + } + + return memberInfos.ToArray(); + } } diff --git a/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidatorBase.cs b/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidatorBase.cs index ef59126c1c..47ad9e4e80 100644 --- a/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidatorBase.cs +++ b/src/Umbraco.Web.BackOffice/ModelsBuilder/ContentTypeModelValidatorBase.cs @@ -68,10 +68,10 @@ public abstract class ContentTypeModelValidatorBase : EditorV private ValidationResult? ValidateProperty(PropertyTypeBasic property, int groupIndex, int propertyIndex) { - // don't let them match any properties or methods in IPublishedContent + // don't let them match any properties or methods in IPublishedContent (including those defined in any base interfaces like IPublishedElement) // TODO: There are probably more! - var reservedProperties = typeof(IPublishedContent).GetProperties().Select(x => x.Name).ToArray(); - var reservedMethods = typeof(IPublishedContent).GetMethods().Select(x => x.Name).ToArray(); + var reservedProperties = typeof(IPublishedContent).GetPublicProperties().Select(x => x.Name).ToArray(); + var reservedMethods = typeof(IPublishedContent).GetPublicMethods().Select(x => x.Name).ToArray(); var alias = property.Alias; From 66bbad33799b7e98d2235715f6dc2086a4ebfd1b Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Mon, 21 Aug 2023 13:57:36 +0200 Subject: [PATCH 09/26] Media Delivery API (#14692) * Introduce media API - controllers, services, tests, Swagger docs * Add path to media API response + add "by path" endpoint * Review comments * Implement filtering and sorting * Add explicit media access configuration * Cleanup * Adding default case as in the MediaApiControllerBase * Update src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> * Swap sort order calculation to align with Content API * Add CreateDate and UpdateDate to media responses * Mirror Content Delivery API behavior for empty children selector --------- Co-authored-by: Elitsa Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> --- ...gureUmbracoDeliveryApiSwaggerGenOptions.cs | 8 +- .../Configuration/DeliveryApiConfiguration.cs | 5 +- .../Controllers/ByIdMediaApiController.cs | 39 ++++ .../Controllers/ByPathMediaApiController.cs | 45 ++++ .../ByRouteContentApiController.cs | 10 +- .../Controllers/ContentApiControllerBase.cs | 6 +- .../Controllers/DeliveryApiControllerBase.cs | 18 +- .../Controllers/MediaApiControllerBase.cs | 56 +++++ .../Controllers/QueryMediaApiController.cs | 67 ++++++ .../UmbracoBuilderExtensions.cs | 1 + .../DeliveryApiMediaAccessAttribute.cs | 35 +++ .../SwaggerContentDocumentationFilter.cs | 171 +++++++++++++++ .../Filters/SwaggerDocumentationFilter.cs | 205 +----------------- .../Filters/SwaggerDocumentationFilterBase.cs | 82 +++++++ .../SwaggerMediaDocumentationFilter.cs | 119 ++++++++++ .../Services/ApiAccessService.cs | 5 + .../Services/ApiMediaQueryService.cs | 191 ++++++++++++++++ .../Models/DeliveryApiSettings.cs | 36 +++ .../DeliveryApi/IApiAccessService.cs | 5 + .../DeliveryApi/IApiMediaQueryService.cs | 29 +++ .../DeliveryApi/NoopApiMediaQueryService.cs | 15 ++ .../ApiMediaQueryOperationStatus.cs | 9 + .../DeliveryApi/ApiMediaWithCropsBuilder.cs | 21 ++ .../ApiMediaWithCropsBuilderBase.cs | 49 +++++ .../ApiMediaWithCropsResponseBuilder.cs | 34 +++ .../DeliveryApi/IApiMediaWithCropsBuilder.cs | 12 + .../IApiMediaWithCropsResponseBuilder.cs | 9 + .../UmbracoBuilder.CoreServices.cs | 3 + .../Models/DeliveryApi/ApiMediaWithCrops.cs | 2 +- .../DeliveryApi/ApiMediaWithCropsResponse.cs | 26 +++ .../MediaPickerWithCropsValueConverter.cs | 54 +++-- ...MediaPickerWithCropsValueConverterTests.cs | 14 +- .../OutputExpansionStrategyTests.cs | 6 +- 33 files changed, 1142 insertions(+), 245 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/ByIdMediaApiController.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/ByPathMediaApiController.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/MediaApiControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/QueryMediaApiController.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Filters/DeliveryApiMediaAccessAttribute.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IApiMediaQueryService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/NoopApiMediaQueryService.cs create mode 100644 src/Umbraco.Core/Services/OperationStatus/ApiMediaQueryOperationStatus.cs create mode 100644 src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilder.cs create mode 100644 src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilderBase.cs create mode 100644 src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsResponseBuilder.cs create mode 100644 src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsBuilder.cs create mode 100644 src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsResponseBuilder.cs create mode 100644 src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCropsResponse.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs index 2cc9ffca0b..7fb301c87e 100644 --- a/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs +++ b/src/Umbraco.Cms.Api.Delivery/Configuration/ConfigureUmbracoDeliveryApiSwaggerGenOptions.cs @@ -17,12 +17,14 @@ public class ConfigureUmbracoDeliveryApiSwaggerGenOptions: IConfigureOptions(DeliveryApiConfiguration.ApiName); - swaggerGenOptions.OperationFilter(); - swaggerGenOptions.ParameterFilter(); + swaggerGenOptions.OperationFilter(); + swaggerGenOptions.OperationFilter(); + swaggerGenOptions.ParameterFilter(); + swaggerGenOptions.ParameterFilter(); } } diff --git a/src/Umbraco.Cms.Api.Delivery/Configuration/DeliveryApiConfiguration.cs b/src/Umbraco.Cms.Api.Delivery/Configuration/DeliveryApiConfiguration.cs index b1f4a15973..83a2d1e9d9 100644 --- a/src/Umbraco.Cms.Api.Delivery/Configuration/DeliveryApiConfiguration.cs +++ b/src/Umbraco.Cms.Api.Delivery/Configuration/DeliveryApiConfiguration.cs @@ -6,5 +6,8 @@ internal static class DeliveryApiConfiguration internal const string ApiName = "delivery"; - internal const string ApiDocumentationArticleLink = "https://docs.umbraco.com/umbraco-cms/v/12.latest/reference/content-delivery-api"; + internal const string ApiDocumentationContentArticleLink = "https://docs.umbraco.com/umbraco-cms/reference/content-delivery-api"; + + // TODO: update this when the Media article is out + internal const string ApiDocumentationMediaArticleLink = "https://docs.umbraco.com/umbraco-cms/reference/content-delivery-api"; } diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdMediaApiController.cs new file mode 100644 index 0000000000..423d70fd5b --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdMediaApiController.cs @@ -0,0 +1,39 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[ApiVersion("1.0")] +public class ByIdMediaApiController : MediaApiControllerBase +{ + public ByIdMediaApiController(IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder) + : base(publishedSnapshotAccessor, apiMediaWithCropsResponseBuilder) + { + } + + /// + /// Gets a media item by id. + /// + /// The unique identifier of the media item. + /// The media item or not found result. + [HttpGet("item/{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(ApiMediaWithCropsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ById(Guid id) + { + IPublishedContent? media = PublishedMediaCache.GetById(id); + + if (media is null) + { + return await Task.FromResult(NotFound()); + } + + return Ok(BuildApiMediaWithCrops(media)); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ByPathMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ByPathMediaApiController.cs new file mode 100644 index 0000000000..947dd820a1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ByPathMediaApiController.cs @@ -0,0 +1,45 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[ApiVersion("1.0")] +public class ByPathMediaApiController : MediaApiControllerBase +{ + private readonly IApiMediaQueryService _apiMediaQueryService; + + public ByPathMediaApiController( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder, + IApiMediaQueryService apiMediaQueryService) + : base(publishedSnapshotAccessor, apiMediaWithCropsResponseBuilder) + => _apiMediaQueryService = apiMediaQueryService; + + /// + /// Gets a media item by its path. + /// + /// The path of the media item. + /// The media item or not found result. + [HttpGet("item/{*path}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(ApiMediaWithCropsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ByPath(string path) + { + path = DecodePath(path); + + IPublishedContent? media = _apiMediaQueryService.GetByPath(path); + if (media is null) + { + return await Task.FromResult(NotFound()); + } + + return Ok(BuildApiMediaWithCrops(media)); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs index 02181f6129..04900f52d2 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ByRouteContentApiController.cs @@ -48,15 +48,7 @@ public class ByRouteContentApiController : ContentApiItemControllerBase [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task ByRoute(string path = "") { - // OpenAPI does not allow reserved chars as "in:path" parameters, so clients based on the Swagger JSON will URL - // encode the path. Normally, ASP.NET Core handles that encoding with an automatic decoding - apparently just not - // for forward slashes, for whatever reason... so we need to deal with those. Hopefully this will be addressed in - // an upcoming version of ASP.NET Core. - // See also https://github.com/dotnet/aspnetcore/issues/11544 - if (path.Contains("%2F", StringComparison.OrdinalIgnoreCase)) - { - path = WebUtility.UrlDecode(path); - } + path = DecodePath(path); path = path.TrimStart("/"); path = path.Length == 0 ? "/" : path; diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs index e260200d5e..07439505e0 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ContentApiControllerBase.cs @@ -2,12 +2,12 @@ using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Delivery.Filters; using Umbraco.Cms.Api.Delivery.Routing; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Delivery.Controllers; +[DeliveryApiAccess] [VersionedDeliveryApiRoute("content")] [ApiExplorerSettings(GroupName = "Content")] [LocalizeFromAcceptLanguageHeader] @@ -39,5 +39,9 @@ public abstract class ContentApiControllerBase : DeliveryApiControllerBase .WithTitle("Sort option not found") .WithDetail("One of the attempted 'sort' options does not exist") .Build()), + _ => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Unknown content query status") + .WithDetail($"Content query status \"{status}\" was not expected here") + .Build()), }; } diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs index 552e2e2f8b..4160cc1fa8 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/DeliveryApiControllerBase.cs @@ -1,17 +1,29 @@ -using Asp.Versioning; +using System.Net; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.Attributes; using Umbraco.Cms.Api.Common.Filters; using Umbraco.Cms.Api.Delivery.Configuration; -using Umbraco.Cms.Api.Delivery.Filters; using Umbraco.Cms.Core; namespace Umbraco.Cms.Api.Delivery.Controllers; [ApiController] -[DeliveryApiAccess] [JsonOptionsName(Constants.JsonOptionsNames.DeliveryApi)] [MapToApi(DeliveryApiConfiguration.ApiName)] public abstract class DeliveryApiControllerBase : Controller { + protected string DecodePath(string path) + { + // OpenAPI does not allow reserved chars as "in:path" parameters, so clients based on the Swagger JSON will URL + // encode the path. Normally, ASP.NET Core handles that encoding with an automatic decoding - apparently just not + // for forward slashes, for whatever reason... so we need to deal with those. Hopefully this will be addressed in + // an upcoming version of ASP.NET Core. + // See also https://github.com/dotnet/aspnetcore/issues/11544 + if (path.Contains("%2F", StringComparison.OrdinalIgnoreCase)) + { + path = WebUtility.UrlDecode(path); + } + + return path; + } } diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/MediaApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/MediaApiControllerBase.cs new file mode 100644 index 0000000000..dc279cf703 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/MediaApiControllerBase.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Delivery.Filters; +using Umbraco.Cms.Api.Delivery.Routing; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.DeliveryApi; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[DeliveryApiMediaAccess] +[VersionedDeliveryApiRoute("media")] +[ApiExplorerSettings(GroupName = "Media")] +public abstract class MediaApiControllerBase : DeliveryApiControllerBase +{ + private readonly IApiMediaWithCropsResponseBuilder _apiMediaWithCropsResponseBuilder; + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private IPublishedMediaCache? _publishedMediaCache; + + protected MediaApiControllerBase(IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder) + { + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _apiMediaWithCropsResponseBuilder = apiMediaWithCropsResponseBuilder; + } + + protected IPublishedMediaCache PublishedMediaCache => _publishedMediaCache + ??= _publishedSnapshotAccessor.GetRequiredPublishedSnapshot().Media + ?? throw new InvalidOperationException("Could not obtain the published media cache"); + + protected ApiMediaWithCropsResponse BuildApiMediaWithCrops(IPublishedContent media) + => _apiMediaWithCropsResponseBuilder.Build(media); + + protected IActionResult ApiMediaQueryOperationStatusResult(ApiMediaQueryOperationStatus status) => + status switch + { + ApiMediaQueryOperationStatus.FilterOptionNotFound => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Filter option not found") + .WithDetail("One of the attempted 'filter' options does not exist") + .Build()), + ApiMediaQueryOperationStatus.SelectorOptionNotFound => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Selector option not found") + .WithDetail("The attempted 'fetch' option does not exist") + .Build()), + ApiMediaQueryOperationStatus.SortOptionNotFound => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Sort option not found") + .WithDetail("One of the attempted 'sort' options does not exist") + .Build()), + _ => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Unknown media query status") + .WithDetail($"Media query status \"{status}\" was not expected here") + .Build()), + }; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/QueryMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/QueryMediaApiController.cs new file mode 100644 index 0000000000..98110e9589 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/QueryMediaApiController.cs @@ -0,0 +1,67 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.DeliveryApi; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[ApiVersion("1.0")] +public class QueryMediaApiController : MediaApiControllerBase +{ + private readonly IApiMediaQueryService _apiMediaQueryService; + + public QueryMediaApiController( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder, + IApiMediaQueryService apiMediaQueryService) + : base(publishedSnapshotAccessor, apiMediaWithCropsResponseBuilder) + => _apiMediaQueryService = apiMediaQueryService; + + /// + /// Gets a paginated list of media item(s) from query. + /// + /// Optional fetch query parameter value. + /// Optional filter query parameters values. + /// Optional sort query parameters values. + /// The amount of items to skip. + /// The amount of items to take. + /// The paged result of the media item(s). + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + public async Task Query( + string? fetch, + [FromQuery] string[] filter, + [FromQuery] string[] sort, + int skip = 0, + int take = 10) + { + Attempt, ApiMediaQueryOperationStatus> queryAttempt = _apiMediaQueryService.ExecuteQuery(fetch, filter, sort, skip, take); + + if (queryAttempt.Success is false) + { + return ApiMediaQueryOperationStatusResult(queryAttempt.Status); + } + + PagedModel pagedResult = queryAttempt.Result; + IPublishedContent[] mediaItems = pagedResult.Items.Select(PublishedMediaCache.GetById).WhereNotNull().ToArray(); + + var model = new PagedViewModel + { + Total = pagedResult.Total, + Items = mediaItems.Select(BuildApiMediaWithCrops) + }; + + return await Task.FromResult(Ok(model)); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 34c6c37d18..d87741746a 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -28,6 +28,7 @@ public static class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.ConfigureOptions(); builder.AddUmbracoApiOpenApiUI(); diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/DeliveryApiMediaAccessAttribute.cs b/src/Umbraco.Cms.Api.Delivery/Filters/DeliveryApiMediaAccessAttribute.cs new file mode 100644 index 0000000000..e6dacce2c1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/DeliveryApiMediaAccessAttribute.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Delivery.Filters; + +internal sealed class DeliveryApiMediaAccessAttribute : TypeFilterAttribute +{ + public DeliveryApiMediaAccessAttribute() + : base(typeof(DeliveryApiMediaAccessFilter)) + { + } + + private class DeliveryApiMediaAccessFilter : IActionFilter + { + private readonly IApiAccessService _apiAccessService; + + public DeliveryApiMediaAccessFilter(IApiAccessService apiAccessService) + => _apiAccessService = apiAccessService; + + public void OnActionExecuting(ActionExecutingContext context) + { + if (_apiAccessService.HasMediaAccess()) + { + return; + } + + context.Result = new UnauthorizedResult(); + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs new file mode 100644 index 0000000000..8b3c946873 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs @@ -0,0 +1,171 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using Umbraco.Cms.Api.Delivery.Configuration; +using Umbraco.Cms.Api.Delivery.Controllers; + +namespace Umbraco.Cms.Api.Delivery.Filters; + +internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFilterBase +{ + protected override string DocumentationLink => DeliveryApiConfiguration.ApiDocumentationContentArticleLink; + + protected override void ApplyOperation(OpenApiOperation operation, OperationFilterContext context) + { + operation.Parameters ??= new List(); + + AddExpand(operation); + + operation.Parameters.Add(new OpenApiParameter + { + Name = "Accept-Language", + In = ParameterLocation.Header, + Required = false, + Description = "Defines the language to return. Use this when querying language variant content items.", + Schema = new OpenApiSchema { Type = "string" }, + Examples = new Dictionary + { + { "Default", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { "English culture", new OpenApiExample { Value = new OpenApiString("en-us") } } + } + }); + + AddApiKey(operation); + + operation.Parameters.Add(new OpenApiParameter + { + Name = "Preview", + In = ParameterLocation.Header, + Required = false, + Description = "Whether to request draft content.", + Schema = new OpenApiSchema { Type = "boolean" } + }); + + operation.Parameters.Add(new OpenApiParameter + { + Name = "Start-Item", + In = ParameterLocation.Header, + Required = false, + Description = "URL segment or GUID of a root content item.", + Schema = new OpenApiSchema { Type = "string" } + }); + } + + protected override void ApplyParameter(OpenApiParameter parameter, ParameterFilterContext context) + { + switch (parameter.Name) + { + case "fetch": + AddQueryParameterDocumentation(parameter, FetchQueryParameterExamples(), "Specifies the content items to fetch"); + break; + case "filter": + AddQueryParameterDocumentation(parameter, FilterQueryParameterExamples(), "Defines how to filter the fetched content items"); + break; + case "sort": + AddQueryParameterDocumentation(parameter, SortQueryParameterExamples(), "Defines how to sort the found content items"); + break; + case "skip": + parameter.Description = PaginationDescription(true, "content"); + break; + case "take": + parameter.Description = PaginationDescription(false, "content"); + break; + default: + return; + } + } + + private Dictionary FetchQueryParameterExamples() => + new() + { + { "Select all", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { + "Select all ancestors of a node by id", + new OpenApiExample { Value = new OpenApiString("ancestors:id") } + }, + { + "Select all ancestors of a node by path", + new OpenApiExample { Value = new OpenApiString("ancestors:path") } + }, + { + "Select all children of a node by id", + new OpenApiExample { Value = new OpenApiString("children:id") } + }, + { + "Select all children of a node by path", + new OpenApiExample { Value = new OpenApiString("children:path") } + }, + { + "Select all descendants of a node by id", + new OpenApiExample { Value = new OpenApiString("descendants:id") } + }, + { + "Select all descendants of a node by path", + new OpenApiExample { Value = new OpenApiString("descendants:path") } + } + }; + + private Dictionary FilterQueryParameterExamples() => + new() + { + { "Default filter", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { + "Filter by content type", + new OpenApiExample { Value = new OpenApiArray { new OpenApiString("contentType:alias1") } } + }, + { + "Filter by name", + new OpenApiExample { Value = new OpenApiArray { new OpenApiString("name:nodeName") } } + } + }; + + private Dictionary SortQueryParameterExamples() => + new() + { + { "Default sort", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { + "Sort by create date", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("createDate:asc"), new OpenApiString("createDate:desc") + } + } + }, + { + "Sort by level", + new OpenApiExample + { + Value = new OpenApiArray { new OpenApiString("level:asc"), new OpenApiString("level:desc") } + } + }, + { + "Sort by name", + new OpenApiExample + { + Value = new OpenApiArray { new OpenApiString("name:asc"), new OpenApiString("name:desc") } + } + }, + { + "Sort by sort order", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("sortOrder:asc"), new OpenApiString("sortOrder:desc") + } + } + }, + { + "Sort by update date", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("updateDate:asc"), new OpenApiString("updateDate:desc") + } + } + } + }; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilter.cs index e41a4b19c1..bc0e138a82 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilter.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilter.cs @@ -1,217 +1,18 @@ -using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; -using Umbraco.Cms.Api.Delivery.Configuration; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Filters; +[Obsolete($"Superseded by {nameof(SwaggerContentDocumentationFilter)} and {nameof(SwaggerMediaDocumentationFilter)}. Will be removed in V14.")] public class SwaggerDocumentationFilter : IOperationFilter, IParameterFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) { - if (context.MethodInfo.HasMapToApiAttribute(DeliveryApiConfiguration.ApiName) == false) - { - return; - } - - operation.Parameters ??= new List(); - - operation.Parameters.Add(new OpenApiParameter - { - Name = "expand", - In = ParameterLocation.Query, - Required = false, - Description = QueryParameterDescription("Defines the properties that should be expanded in the response"), - Schema = new OpenApiSchema { Type = "string" }, - Examples = new Dictionary - { - { "Expand none", new OpenApiExample { Value = new OpenApiString("") } }, - { "Expand all", new OpenApiExample { Value = new OpenApiString("all") } }, - { - "Expand specific property", - new OpenApiExample { Value = new OpenApiString("property:alias1") } - }, - { - "Expand specific properties", - new OpenApiExample { Value = new OpenApiString("property:alias1,alias2") } - } - } - }); - - operation.Parameters.Add(new OpenApiParameter - { - Name = "Accept-Language", - In = ParameterLocation.Header, - Required = false, - Description = "Defines the language to return. Use this when querying language variant content items.", - Schema = new OpenApiSchema { Type = "string" }, - Examples = new Dictionary - { - { "Default", new OpenApiExample { Value = new OpenApiString("") } }, - { "English culture", new OpenApiExample { Value = new OpenApiString("en-us") } } - } - }); - - operation.Parameters.Add(new OpenApiParameter - { - Name = "Api-Key", - In = ParameterLocation.Header, - Required = false, - Description = "API key specified through configuration to authorize access to the API.", - Schema = new OpenApiSchema { Type = "string" } - }); - - operation.Parameters.Add(new OpenApiParameter - { - Name = "Preview", - In = ParameterLocation.Header, - Required = false, - Description = "Whether to request draft content.", - Schema = new OpenApiSchema { Type = "boolean" } - }); - - operation.Parameters.Add(new OpenApiParameter - { - Name = "Start-Item", - In = ParameterLocation.Header, - Required = false, - Description = "URL segment or GUID of a root content item.", - Schema = new OpenApiSchema { Type = "string" } - }); + // retained for backwards compat } public void Apply(OpenApiParameter parameter, ParameterFilterContext context) { - if (context.DocumentName != DeliveryApiConfiguration.ApiName) - { - return; - } - - switch (parameter.Name) - { - case "fetch": - AddQueryParameterDocumentation(parameter, FetchQueryParameterExamples(), "Specifies the content items to fetch"); - break; - case "filter": - AddQueryParameterDocumentation(parameter, FilterQueryParameterExamples(), "Defines how to filter the fetched content items"); - break; - case "sort": - AddQueryParameterDocumentation(parameter, SortQueryParameterExamples(), "Defines how to sort the found content items"); - break; - case "skip": - parameter.Description = PaginationDescription(true); - break; - case "take": - parameter.Description = PaginationDescription(false); - break; - default: - return; - } + // retained for backwards compat } - - private string QueryParameterDescription(string description) - => $"{description}. Refer to [the documentation]({DeliveryApiConfiguration.ApiDocumentationArticleLink}#query-parameters) for more details on this."; - - private string PaginationDescription(bool skip) => $"Specifies the number of found content items to {(skip ? "skip" : "take")}. Use this to control pagination of the response."; - - private void AddQueryParameterDocumentation(OpenApiParameter parameter, Dictionary examples, string description) - { - parameter.Description = QueryParameterDescription(description); - parameter.Examples = examples; - } - - private Dictionary FetchQueryParameterExamples() => - new() - { - { "Select all", new OpenApiExample { Value = new OpenApiString("") } }, - { - "Select all ancestors of a node by id", - new OpenApiExample { Value = new OpenApiString("ancestors:id") } - }, - { - "Select all ancestors of a node by path", - new OpenApiExample { Value = new OpenApiString("ancestors:path") } - }, - { - "Select all children of a node by id", - new OpenApiExample { Value = new OpenApiString("children:id") } - }, - { - "Select all children of a node by path", - new OpenApiExample { Value = new OpenApiString("children:path") } - }, - { - "Select all descendants of a node by id", - new OpenApiExample { Value = new OpenApiString("descendants:id") } - }, - { - "Select all descendants of a node by path", - new OpenApiExample { Value = new OpenApiString("descendants:path") } - } - }; - - private Dictionary FilterQueryParameterExamples() => - new() - { - { "Default filter", new OpenApiExample { Value = new OpenApiString("") } }, - { - "Filter by content type", - new OpenApiExample { Value = new OpenApiArray { new OpenApiString("contentType:alias1") } } - }, - { - "Filter by name", - new OpenApiExample { Value = new OpenApiArray { new OpenApiString("name:nodeName") } } - } - }; - - private Dictionary SortQueryParameterExamples() => - new() - { - { "Default sort", new OpenApiExample { Value = new OpenApiString("") } }, - { - "Sort by create date", - new OpenApiExample - { - Value = new OpenApiArray - { - new OpenApiString("createDate:asc"), new OpenApiString("createDate:desc") - } - } - }, - { - "Sort by level", - new OpenApiExample - { - Value = new OpenApiArray { new OpenApiString("level:asc"), new OpenApiString("level:desc") } - } - }, - { - "Sort by name", - new OpenApiExample - { - Value = new OpenApiArray { new OpenApiString("name:asc"), new OpenApiString("name:desc") } - } - }, - { - "Sort by sort order", - new OpenApiExample - { - Value = new OpenApiArray - { - new OpenApiString("sortOrder:asc"), new OpenApiString("sortOrder:desc") - } - } - }, - { - "Sort by update date", - new OpenApiExample - { - Value = new OpenApiArray - { - new OpenApiString("updateDate:asc"), new OpenApiString("updateDate:desc") - } - } - } - }; } diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs new file mode 100644 index 0000000000..32791b9b5e --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs @@ -0,0 +1,82 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Filters; + +internal abstract class SwaggerDocumentationFilterBase : IOperationFilter, IParameterFilter + where TBaseController : Controller +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (CanApply(context.MethodInfo)) + { + ApplyOperation(operation, context); + } + } + + public void Apply(OpenApiParameter parameter, ParameterFilterContext context) + { + if (CanApply(context.ParameterInfo.Member)) + { + ApplyParameter(parameter, context); + } + } + + protected abstract string DocumentationLink { get; } + + protected abstract void ApplyOperation(OpenApiOperation operation, OperationFilterContext context); + + protected abstract void ApplyParameter(OpenApiParameter parameter, ParameterFilterContext context); + + protected void AddQueryParameterDocumentation(OpenApiParameter parameter, Dictionary examples, string description) + { + parameter.Description = QueryParameterDescription(description); + parameter.Examples = examples; + } + + protected void AddExpand(OpenApiOperation operation) => + operation.Parameters.Add(new OpenApiParameter + { + Name = "expand", + In = ParameterLocation.Query, + Required = false, + Description = QueryParameterDescription("Defines the properties that should be expanded in the response"), + Schema = new OpenApiSchema { Type = "string" }, + Examples = new Dictionary + { + { "Expand none", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { "Expand all", new OpenApiExample { Value = new OpenApiString("all") } }, + { + "Expand specific property", + new OpenApiExample { Value = new OpenApiString("property:alias1") } + }, + { + "Expand specific properties", + new OpenApiExample { Value = new OpenApiString("property:alias1,alias2") } + } + } + }); + + protected void AddApiKey(OpenApiOperation operation) => + operation.Parameters.Add(new OpenApiParameter + { + Name = "Api-Key", + In = ParameterLocation.Header, + Required = false, + Description = "API key specified through configuration to authorize access to the API.", + Schema = new OpenApiSchema { Type = "string" } + }); + + protected string PaginationDescription(bool skip, string itemType) + => $"Specifies the number of found {itemType} items to {(skip ? "skip" : "take")}. Use this to control pagination of the response."; + + private string QueryParameterDescription(string description) + => $"{description}. Refer to [the documentation]({DocumentationLink}#query-parameters) for more details on this."; + + private bool CanApply(MemberInfo member) + => member.DeclaringType?.Implements() is true; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs new file mode 100644 index 0000000000..af22914f78 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs @@ -0,0 +1,119 @@ +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using Umbraco.Cms.Api.Delivery.Configuration; +using Umbraco.Cms.Api.Delivery.Controllers; + +namespace Umbraco.Cms.Api.Delivery.Filters; + +internal sealed class SwaggerMediaDocumentationFilter : SwaggerDocumentationFilterBase +{ + protected override string DocumentationLink => DeliveryApiConfiguration.ApiDocumentationMediaArticleLink; + + protected override void ApplyOperation(OpenApiOperation operation, OperationFilterContext context) + { + operation.Parameters ??= new List(); + + AddExpand(operation); + + AddApiKey(operation); + } + + protected override void ApplyParameter(OpenApiParameter parameter, ParameterFilterContext context) + { + switch (parameter.Name) + { + case "fetch": + AddQueryParameterDocumentation(parameter, FetchQueryParameterExamples(), "Specifies the media items to fetch"); + break; + case "filter": + AddQueryParameterDocumentation(parameter, FilterQueryParameterExamples(), "Defines how to filter the fetched media items"); + break; + case "sort": + AddQueryParameterDocumentation(parameter, SortQueryParameterExamples(), "Defines how to sort the found media items"); + break; + case "skip": + parameter.Description = PaginationDescription(true, "media"); + break; + case "take": + parameter.Description = PaginationDescription(false, "media"); + break; + default: + return; + } + } + + private Dictionary FetchQueryParameterExamples() => + new() + { + { + "Select all children at root level", + new OpenApiExample { Value = new OpenApiString("children:/") } + }, + { + "Select all children of a media item by id", + new OpenApiExample { Value = new OpenApiString("children:id") } + }, + { + "Select all children of a media item by path", + new OpenApiExample { Value = new OpenApiString("children:path") } + } + }; + + private Dictionary FilterQueryParameterExamples() => + new() + { + { "Default filter", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { + "Filter by media type", + new OpenApiExample { Value = new OpenApiArray { new OpenApiString("mediaType:alias1") } } + }, + { + "Filter by name", + new OpenApiExample { Value = new OpenApiArray { new OpenApiString("name:nodeName") } } + } + }; + + private Dictionary SortQueryParameterExamples() => + new() + { + { "Default sort", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { + "Sort by create date", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("createDate:asc"), new OpenApiString("createDate:desc") + } + } + }, + { + "Sort by name", + new OpenApiExample + { + Value = new OpenApiArray { new OpenApiString("name:asc"), new OpenApiString("name:desc") } + } + }, + { + "Sort by sort order", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("sortOrder:asc"), new OpenApiString("sortOrder:desc") + } + } + }, + { + "Sort by update date", + new OpenApiExample + { + Value = new OpenApiArray + { + new OpenApiString("updateDate:asc"), new OpenApiString("updateDate:desc") + } + } + } + }; +} diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs index b87205501c..0ba7df9b49 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiAccessService.cs @@ -23,8 +23,13 @@ internal sealed class ApiAccessService : RequestHeaderHandler, IApiAccessService /// public bool HasPreviewAccess() => IfEnabled(HasValidApiKey); + /// + public bool HasMediaAccess() => IfMediaEnabled(() => _deliveryApiSettings is { PublicAccess: true, Media.PublicAccess: true } || HasValidApiKey()); + private bool IfEnabled(Func condition) => _deliveryApiSettings.Enabled && condition(); private bool HasValidApiKey() => _deliveryApiSettings.ApiKey.IsNullOrWhiteSpace() == false && _deliveryApiSettings.ApiKey.Equals(GetHeaderValue("Api-Key")); + + private bool IfMediaEnabled(Func condition) => _deliveryApiSettings is { Enabled: true, Media.Enabled: true } && condition(); } diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs new file mode 100644 index 0000000000..1fe1e92d9b --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs @@ -0,0 +1,191 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Services; + +/// +internal sealed class ApiMediaQueryService : IApiMediaQueryService +{ + private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; + private readonly ILogger _logger; + + public ApiMediaQueryService(IPublishedSnapshotAccessor publishedSnapshotAccessor, ILogger logger) + { + _publishedSnapshotAccessor = publishedSnapshotAccessor; + _logger = logger; + } + + /// + public Attempt, ApiMediaQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take) + { + var emptyResult = new PagedModel(); + + IEnumerable? source = GetSource(fetch); + if (source is null) + { + return Attempt.FailWithStatus(ApiMediaQueryOperationStatus.SelectorOptionNotFound, emptyResult); + } + + source = ApplyFilters(source, filters); + if (source is null) + { + return Attempt.FailWithStatus(ApiMediaQueryOperationStatus.FilterOptionNotFound, emptyResult); + } + + source = ApplySorts(source, sorts); + if (source is null) + { + return Attempt.FailWithStatus(ApiMediaQueryOperationStatus.SortOptionNotFound, emptyResult); + } + + return PagedResult(source, skip, take); + } + + /// + public IPublishedContent? GetByPath(string path) + => TryGetByPath(path, GetRequiredPublishedMediaCache()); + + private IPublishedMediaCache GetRequiredPublishedMediaCache() + => _publishedSnapshotAccessor.GetRequiredPublishedSnapshot().Media + ?? throw new InvalidOperationException("Could not obtain the published media cache"); + + private IPublishedContent? TryGetByPath(string path, IPublishedMediaCache mediaCache) + { + var segments = path.Split(Constants.CharArrays.ForwardSlash, StringSplitOptions.RemoveEmptyEntries); + IEnumerable currentChildren = mediaCache.GetAtRoot(); + IPublishedContent? resolvedMedia = null; + + foreach (var segment in segments) + { + resolvedMedia = currentChildren.FirstOrDefault(c => segment.InvariantEquals(c.Name)); + if (resolvedMedia is null) + { + break; + } + + currentChildren = resolvedMedia.Children; + } + + return resolvedMedia; + } + + private IEnumerable? GetSource(string? fetch) + { + const string childrenOfParameter = "children:"; + + if (fetch?.StartsWith(childrenOfParameter, StringComparison.OrdinalIgnoreCase) is not true) + { + _logger.LogInformation($"The current implementation of {nameof(IApiMediaQueryService)} expects \"{childrenOfParameter}[id/path]\" in the \"{nameof(fetch)}\" query option"); + return null; + } + + var childrenOf = fetch.TrimStart(childrenOfParameter); + if (childrenOf.IsNullOrWhiteSpace()) + { + // this mirrors the current behavior of the Content Delivery API :-) + return Array.Empty(); + } + + IPublishedMediaCache mediaCache = GetRequiredPublishedMediaCache(); + if (childrenOf.Trim(Constants.CharArrays.ForwardSlash).Length == 0) + { + return mediaCache.GetAtRoot(); + } + + IPublishedContent? parent = Guid.TryParse(childrenOf, out Guid parentKey) + ? mediaCache.GetById(parentKey) + : TryGetByPath(childrenOf, mediaCache); + + return parent?.Children ?? Array.Empty(); + } + + private IEnumerable? ApplyFilters(IEnumerable source, IEnumerable filters) + { + foreach (var filter in filters) + { + var parts = filter.Split(':'); + if (parts.Length != 2) + { + // invalid filter + _logger.LogInformation($"The \"{nameof(filters)}\" query option \"{filter}\" is not valid"); + return null; + } + + switch (parts[0]) + { + case "mediaType": + source = source.Where(c => c.ContentType.Alias == parts[1]); + break; + case "name": + source = source.Where(c => c.Name.InvariantContains(parts[1])); + break; + default: + // unknown filter + _logger.LogInformation($"The \"{nameof(filters)}\" query option \"{filter}\" is not supported"); + return null; + } + } + + return source; + } + + private IEnumerable? ApplySorts(IEnumerable source, IEnumerable sorts) + { + foreach (var sort in sorts) + { + var parts = sort.Split(':'); + if (parts.Length != 2) + { + // invalid sort + _logger.LogInformation($"The \"{nameof(sorts)}\" query option \"{sort}\" is not valid"); + return null; + } + + Func keySelector; + switch (parts[0]) + { + case "createDate": + keySelector = content => content.CreateDate; + break; + case "updateDate": + keySelector = content => content.UpdateDate; + break; + case "name": + keySelector = content => content.Name.ToLowerInvariant(); + break; + case "sortOrder": + keySelector = content => content.SortOrder; + break; + default: + // unknown sort + _logger.LogInformation($"The \"{nameof(sorts)}\" query option \"{sort}\" is not supported"); + return null; + } + + source = parts[1].StartsWith("asc") + ? source.OrderBy(keySelector) + : source.OrderByDescending(keySelector); + } + + return source; + } + + + private Attempt, ApiMediaQueryOperationStatus> PagedResult(IEnumerable children, int skip, int take) + { + IPublishedContent[] childrenAsArray = children as IPublishedContent[] ?? children.ToArray(); + var result = new PagedModel + { + Total = childrenAsArray.Length, + Items = childrenAsArray.Skip(skip).Take(take).Select(child => child.Key) + }; + + return Attempt.SucceedWithStatus(ApiMediaQueryOperationStatus.Success, result); + } +} diff --git a/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs b/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs index eeb4bb2bcb..69b1943c60 100644 --- a/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/DeliveryApiSettings.cs @@ -48,4 +48,40 @@ public class DeliveryApiSettings /// true if the Delivery API should output rich text values as JSON; false they should be output as HTML (default). [DefaultValue(StaticRichTextOutputAsJson)] public bool RichTextOutputAsJson { get; set; } = StaticRichTextOutputAsJson; + + /// + /// Gets or sets the settings for the Media APIs of the Delivery API. + /// + public MediaSettings Media { get; set; } = new (); + + /// + /// Typed configuration options for the Media APIs of the Delivery API. + /// + /// + /// The Delivery API settings (as configured in ) supersede these settings in levels of restriction. + /// I.e. the Media APIs cannot be enabled, if the Delivery API is disabled. + /// + public class MediaSettings + { + /// + /// Gets or sets a value indicating whether the Media APIs of the Delivery API should be enabled. + /// + /// true if the Media APIs should be enabled; otherwise, false. + /// + /// Setting this to true will have no effect if the Delivery API itself is disabled through + /// + [DefaultValue(StaticEnabled)] + public bool Enabled { get; set; } = StaticEnabled; + + /// + /// Gets or sets a value indicating whether the Media APIs of the Delivery API (if enabled) should be + /// publicly available or should require an API key for access. + /// + /// true if the Media APIs should be publicly available; false if an API key should be required for access. + /// + /// Setting this to true will have no effect if the Delivery API itself has public access disabled through + /// + [DefaultValue(StaticPublicAccess)] + public bool PublicAccess { get; set; } = StaticPublicAccess; + } } diff --git a/src/Umbraco.Core/DeliveryApi/IApiAccessService.cs b/src/Umbraco.Core/DeliveryApi/IApiAccessService.cs index e734571b7b..e890386a3a 100644 --- a/src/Umbraco.Core/DeliveryApi/IApiAccessService.cs +++ b/src/Umbraco.Core/DeliveryApi/IApiAccessService.cs @@ -11,4 +11,9 @@ public interface IApiAccessService /// Retrieves information on whether or not the API currently allows preview access. /// bool HasPreviewAccess(); + + /// + /// Retrieves information on whether or not the API currently allows access to media. + /// + bool HasMediaAccess() => false; } diff --git a/src/Umbraco.Core/DeliveryApi/IApiMediaQueryService.cs b/src/Umbraco.Core/DeliveryApi/IApiMediaQueryService.cs new file mode 100644 index 0000000000..db12543d57 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IApiMediaQueryService.cs @@ -0,0 +1,29 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.DeliveryApi; + +/// +/// Service that handles querying of the Media APIs. +/// +public interface IApiMediaQueryService +{ + /// + /// Returns an attempt with a collection of media ids that passed the search criteria as a paged model. + /// + /// Optional fetch query parameter value. + /// Optional filter query parameters values. + /// Optional sort query parameters values. + /// The amount of items to skip. + /// The amount of items to take. + /// A paged model of media ids that are returned after applying the search queries in an attempt. + Attempt, ApiMediaQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take); + + /// + /// Returns the media item that matches the supplied path (if any). + /// + /// The path to look up. + /// The media item at , or null if it does not exist. + IPublishedContent? GetByPath(string path); +} diff --git a/src/Umbraco.Core/DeliveryApi/NoopApiMediaQueryService.cs b/src/Umbraco.Core/DeliveryApi/NoopApiMediaQueryService.cs new file mode 100644 index 0000000000..c7f6bc8797 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopApiMediaQueryService.cs @@ -0,0 +1,15 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.DeliveryApi; + +public sealed class NoopApiMediaQueryService : IApiMediaQueryService +{ + /// + public Attempt, ApiMediaQueryOperationStatus> ExecuteQuery(string? fetch, IEnumerable filters, IEnumerable sorts, int skip, int take) + => Attempt.SucceedWithStatus(ApiMediaQueryOperationStatus.Success, new PagedModel()); + + /// + public IPublishedContent? GetByPath(string path) => null; +} diff --git a/src/Umbraco.Core/Services/OperationStatus/ApiMediaQueryOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ApiMediaQueryOperationStatus.cs new file mode 100644 index 0000000000..f031a37b91 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/ApiMediaQueryOperationStatus.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum ApiMediaQueryOperationStatus +{ + Success, + FilterOptionNotFound, + SelectorOptionNotFound, + SortOptionNotFound +} diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilder.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilder.cs new file mode 100644 index 0000000000..ab9d7943b4 --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilder.cs @@ -0,0 +1,21 @@ +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +internal sealed class ApiMediaWithCropsBuilder : ApiMediaWithCropsBuilderBase, IApiMediaWithCropsBuilder +{ + public ApiMediaWithCropsBuilder(IApiMediaBuilder apiMediaBuilder, IPublishedValueFallback publishedValueFallback) + : base(apiMediaBuilder, publishedValueFallback) + { + } + + protected override ApiMediaWithCrops Create( + IPublishedContent media, + IApiMedia inner, + ImageCropperValue.ImageCropperFocalPoint? focalPoint, + IEnumerable? crops) => + new ApiMediaWithCrops(inner, focalPoint, crops); +} diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilderBase.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilderBase.cs new file mode 100644 index 0000000000..8754eea976 --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsBuilderBase.cs @@ -0,0 +1,49 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +internal abstract class ApiMediaWithCropsBuilderBase + where T : IApiMedia +{ + private readonly IApiMediaBuilder _apiMediaBuilder; + private readonly IPublishedValueFallback _publishedValueFallback; + + protected ApiMediaWithCropsBuilderBase(IApiMediaBuilder apiMediaBuilder, IPublishedValueFallback publishedValueFallback) + { + _apiMediaBuilder = apiMediaBuilder; + _publishedValueFallback = publishedValueFallback; + } + + protected abstract T Create( + IPublishedContent media, + IApiMedia inner, + ImageCropperValue.ImageCropperFocalPoint? focalPoint, + IEnumerable? crops); + + public T Build(MediaWithCrops media) + { + IApiMedia inner = _apiMediaBuilder.Build(media.Content); + + // make sure we merge crops and focal point defined at media level with the locally defined ones (local ones take precedence in case of a conflict) + ImageCropperValue? mediaCrops = media.Content.Value(_publishedValueFallback, Constants.Conventions.Media.File); + ImageCropperValue localCrops = media.LocalCrops; + if (mediaCrops is not null) + { + localCrops = localCrops.Merge(mediaCrops); + } + + return Create(media.Content, inner, localCrops.FocalPoint, localCrops.Crops); + } + + public T Build(IPublishedContent media) + { + var mediaWithCrops = new MediaWithCrops(media, _publishedValueFallback, new ImageCropperValue()); + return Build(mediaWithCrops); + } +} diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsResponseBuilder.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsResponseBuilder.cs new file mode 100644 index 0000000000..68c73304da --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiMediaWithCropsResponseBuilder.cs @@ -0,0 +1,34 @@ +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +internal sealed class ApiMediaWithCropsResponseBuilder : ApiMediaWithCropsBuilderBase, IApiMediaWithCropsResponseBuilder +{ + public ApiMediaWithCropsResponseBuilder(IApiMediaBuilder apiMediaBuilder, IPublishedValueFallback publishedValueFallback) + : base(apiMediaBuilder, publishedValueFallback) + { + } + + protected override ApiMediaWithCropsResponse Create( + IPublishedContent media, + IApiMedia inner, + ImageCropperValue.ImageCropperFocalPoint? focalPoint, + IEnumerable? crops) + { + var path = $"/{string.Join("/", PathSegments(media).Reverse())}/"; + return new ApiMediaWithCropsResponse(inner, focalPoint, crops, path, media.CreateDate, media.UpdateDate); + } + + private IEnumerable PathSegments(IPublishedContent media) + { + IPublishedContent? current = media; + while (current != null) + { + yield return current.Name.ToLowerInvariant(); + current = current.Parent; + } + } +} diff --git a/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsBuilder.cs b/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsBuilder.cs new file mode 100644 index 0000000000..63c2a0d218 --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsBuilder.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +public interface IApiMediaWithCropsBuilder +{ + ApiMediaWithCrops Build(MediaWithCrops media); + + ApiMediaWithCrops Build(IPublishedContent media); +} diff --git a/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsResponseBuilder.cs b/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsResponseBuilder.cs new file mode 100644 index 0000000000..62e2cc7156 --- /dev/null +++ b/src/Umbraco.Infrastructure/DeliveryApi/IApiMediaWithCropsResponseBuilder.cs @@ -0,0 +1,9 @@ +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Infrastructure.DeliveryApi; + +public interface IApiMediaWithCropsResponseBuilder +{ + ApiMediaWithCropsResponse Build(IPublishedContent media); +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 62ce5d3aec..5416310566 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -423,6 +423,8 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -432,6 +434,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs index 4aeaba3dea..d51a34e27d 100644 --- a/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs +++ b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCrops.cs @@ -2,7 +2,7 @@ using Umbraco.Cms.Core.PropertyEditors.ValueConverters; namespace Umbraco.Cms.Core.Models.DeliveryApi; -internal sealed class ApiMediaWithCrops : IApiMedia +public class ApiMediaWithCrops : IApiMedia { private readonly IApiMedia _inner; diff --git a/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCropsResponse.cs b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCropsResponse.cs new file mode 100644 index 0000000000..e1c1f09344 --- /dev/null +++ b/src/Umbraco.Infrastructure/Models/DeliveryApi/ApiMediaWithCropsResponse.cs @@ -0,0 +1,26 @@ +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; + +namespace Umbraco.Cms.Core.Models.DeliveryApi; + +public sealed class ApiMediaWithCropsResponse : ApiMediaWithCrops +{ + public ApiMediaWithCropsResponse( + IApiMedia inner, + ImageCropperValue.ImageCropperFocalPoint? focalPoint, + IEnumerable? crops, + string path, + DateTime createDate, + DateTime updateDate) + : base(inner, focalPoint, crops) + { + Path = path; + CreateDate = createDate; + UpdateDate = updateDate; + } + + public string Path { get; } + + public DateTime CreateDate { get; } + + public DateTime UpdateDate { get; } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs index 58020c5554..7e0829a399 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs @@ -7,6 +7,7 @@ using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.DeliveryApi; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; @@ -19,9 +20,9 @@ public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase, ID private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; private readonly IPublishedUrlProvider _publishedUrlProvider; private readonly IPublishedValueFallback _publishedValueFallback; - private readonly IApiMediaBuilder _apiMediaBuilder; + private readonly IApiMediaWithCropsBuilder _apiMediaWithCropsBuilder; - [Obsolete("Use constructor that takes all parameters, scheduled for removal in V14")] + [Obsolete($"Use constructor that takes {nameof(IApiMediaWithCropsBuilder)}, scheduled for removal in V14")] public MediaPickerWithCropsValueConverter( IPublishedSnapshotAccessor publishedSnapshotAccessor, IPublishedUrlProvider publishedUrlProvider, @@ -32,24 +33,52 @@ public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase, ID publishedUrlProvider, publishedValueFallback, jsonSerializer, - StaticServiceProvider.Instance.GetRequiredService() + StaticServiceProvider.Instance.GetRequiredService() ) { } + [Obsolete($"Use constructor that takes {nameof(IApiMediaWithCropsBuilder)}, scheduled for removal in V14")] + public MediaPickerWithCropsValueConverter( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IPublishedUrlProvider publishedUrlProvider, + IPublishedValueFallback publishedValueFallback, + IJsonSerializer jsonSerializer, + IApiMediaBuilder apiMediaBuilder) + : this( + publishedSnapshotAccessor, + publishedUrlProvider, + publishedValueFallback, + jsonSerializer, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [Obsolete($"Use constructor that takes {nameof(IApiMediaWithCropsBuilder)} and no {nameof(IApiMediaBuilder)}, scheduled for removal in V14")] + public MediaPickerWithCropsValueConverter( + IPublishedSnapshotAccessor publishedSnapshotAccessor, + IPublishedUrlProvider publishedUrlProvider, + IPublishedValueFallback publishedValueFallback, + IJsonSerializer jsonSerializer, + IApiMediaBuilder apiMediaBuilder, + IApiMediaWithCropsBuilder apiMediaWithCropsBuilder) + : this(publishedSnapshotAccessor, publishedUrlProvider, publishedValueFallback, jsonSerializer, apiMediaWithCropsBuilder) + { + } + public MediaPickerWithCropsValueConverter( IPublishedSnapshotAccessor publishedSnapshotAccessor, IPublishedUrlProvider publishedUrlProvider, IPublishedValueFallback publishedValueFallback, IJsonSerializer jsonSerializer, - IApiMediaBuilder apiMediaBuilder) + IApiMediaWithCropsBuilder apiMediaWithCropsBuilder) { _publishedSnapshotAccessor = publishedSnapshotAccessor ?? throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); _publishedUrlProvider = publishedUrlProvider; _publishedValueFallback = publishedValueFallback; _jsonSerializer = jsonSerializer; - _apiMediaBuilder = apiMediaBuilder; + _apiMediaWithCropsBuilder = apiMediaWithCropsBuilder; } public override bool IsConverter(IPublishedPropertyType propertyType) => @@ -128,20 +157,7 @@ public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase, ID { var isMultiple = IsMultipleDataType(propertyType.DataType); - ApiMediaWithCrops ToApiMedia(MediaWithCrops media) - { - IApiMedia inner = _apiMediaBuilder.Build(media.Content); - - // make sure we merge crops and focal point defined at media level with the locally defined ones (local ones take precedence in case of a conflict) - ImageCropperValue? mediaCrops = media.Content.Value(_publishedValueFallback, Constants.Conventions.Media.File); - ImageCropperValue localCrops = media.LocalCrops; - if (mediaCrops != null) - { - localCrops = localCrops.Merge(mediaCrops); - } - - return new ApiMediaWithCrops(inner, localCrops.FocalPoint, localCrops.Crops); - } + ApiMediaWithCrops ToApiMedia(MediaWithCrops media) => _apiMediaWithCropsBuilder.Build(media); // NOTE: eventually we might implement this explicitly instead of piggybacking on the default object conversion. however, this only happens once per cache rebuild, // and the performance gain from an explicit implementation is negligible, so... at least for the time being this will do just fine. diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs index e39d9d83e5..9e0284ffa8 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MediaPickerWithCropsValueConverterTests.cs @@ -6,6 +6,7 @@ using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Infrastructure.DeliveryApi; using Umbraco.Cms.Infrastructure.Serialization; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; @@ -18,16 +19,19 @@ public class MediaPickerWithCropsValueConverterTests : PropertyValueConverterTes var serializer = new JsonNetSerializer(); var publishedValueFallback = Mock.Of(); var apiUrlProvider = new ApiMediaUrlProvider(PublishedUrlProvider); + var apiMediaWithCropsBuilder = new ApiMediaWithCropsBuilder( + new ApiMediaBuilder( + new ApiContentNameProvider(), + apiUrlProvider, + publishedValueFallback, + CreateOutputExpansionStrategyAccessor()), + publishedValueFallback); return new MediaPickerWithCropsValueConverter( PublishedSnapshotAccessor, PublishedUrlProvider, publishedValueFallback, serializer, - new ApiMediaBuilder( - new ApiContentNameProvider(), - apiUrlProvider, - Mock.Of(), - CreateOutputExpansionStrategyAccessor())); + apiMediaWithCropsBuilder); } [Test] diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs index 443dda29f4..75612932c8 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.DeliveryApi; using Umbraco.Cms.Infrastructure.Serialization; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; @@ -545,7 +546,10 @@ public class OutputExpansionStrategyTests : PropertyValueConverterTests } }); - MediaPickerWithCropsValueConverter mediaPickerValueConverter = new MediaPickerWithCropsValueConverter(PublishedSnapshotAccessor, PublishedUrlProvider, Mock.Of(), new JsonNetSerializer(), mediaBuilder); + var publishedValueFallback = Mock.Of(); + var apiMediaWithCropsBuilder = new ApiMediaWithCropsBuilder(mediaBuilder, publishedValueFallback); + + MediaPickerWithCropsValueConverter mediaPickerValueConverter = new MediaPickerWithCropsValueConverter(PublishedSnapshotAccessor, PublishedUrlProvider, publishedValueFallback, new JsonNetSerializer(), apiMediaWithCropsBuilder); var mediaPickerPropertyType = SetupPublishedPropertyType(mediaPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.MediaPicker3, new MediaPicker3Configuration()); return new PublishedElementPropertyBase(mediaPickerPropertyType, parent, false, PropertyCacheLevel.None, value); From 380af0057be1bfb1e1158afad9e037a6081ef641 Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Tue, 22 Aug 2023 10:25:55 +0200 Subject: [PATCH 10/26] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index fe28088e74..6761d6acc5 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "11.4.3", + "version": "11.5.0-rc", "assemblyVersion": { "precision": "build" }, From 5dfb914e0f2a335c05629f38fbad2a6197a2f656 Mon Sep 17 00:00:00 2001 From: Nathan Woulfe Date: Tue, 22 Aug 2023 19:10:58 +1000 Subject: [PATCH 11/26] Don't pass content object to save method - it's not a suitable label key :) (#14496) * don't set label key to entire content model... * Remove the default parameter value --------- Co-authored-by: kjac --- .../src/views/components/content/edit.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html b/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html index 0c5ad35b04..f031b4da9d 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html @@ -59,7 +59,7 @@ type="button" button-style="{{page.saveButtonStyle}}" state="page.saveButtonState" - action="save(content)" + action="save()" label-key="buttons_save" shortcut="ctrl+s" add-ellipsis="{{page.saveButtonEllipsis}}" From 38910a8d5c206934c02d266f88adec726ee83bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Tue, 22 Aug 2023 11:27:16 +0200 Subject: [PATCH 12/26] directly render labels without angularJS template code (#14700) --- .../src/common/services/blockeditormodelobject.service.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js index 20661d5d1c..606d16ad2d 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js @@ -644,6 +644,12 @@ blockObject.index = 0; if (blockObject.config.label && blockObject.config.label !== "" && blockObject.config.unsupported !== true) { + + // If the label does not contain any AngularJS template, then the MutationObserver wont give us any updates. To ensure labels without angular JS template code, we will just set the label directly for ones without '{{': + if(blockObject.config.label.indexOf("{{") === -1) { + blockObject.label = blockObject.config.label; + } + var labelElement = $('
', { text: blockObject.config.label}); var observer = new MutationObserver(function(mutations) { @@ -673,6 +679,7 @@ this.__labelScope = Object.assign(this.__labelScope, labelVars); $compile(labelElement.contents())(this.__labelScope); + }.bind(blockObject) } else { blockObject.__renderLabel = function() {}; From e1b4aebb0faa0fe479983d5940605f9e5c00296a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Tue, 22 Aug 2023 11:27:16 +0200 Subject: [PATCH 13/26] directly render labels without angularJS template code (#14700) --- .../src/common/services/blockeditormodelobject.service.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js index 20661d5d1c..606d16ad2d 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js @@ -644,6 +644,12 @@ blockObject.index = 0; if (blockObject.config.label && blockObject.config.label !== "" && blockObject.config.unsupported !== true) { + + // If the label does not contain any AngularJS template, then the MutationObserver wont give us any updates. To ensure labels without angular JS template code, we will just set the label directly for ones without '{{': + if(blockObject.config.label.indexOf("{{") === -1) { + blockObject.label = blockObject.config.label; + } + var labelElement = $('
', { text: blockObject.config.label}); var observer = new MutationObserver(function(mutations) { @@ -673,6 +679,7 @@ this.__labelScope = Object.assign(this.__labelScope, labelVars); $compile(labelElement.contents())(this.__labelScope); + }.bind(blockObject) } else { blockObject.__renderLabel = function() {}; From 19ee8e925411d83a02d55b4872b76f53133d0b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Tue, 22 Aug 2023 11:27:16 +0200 Subject: [PATCH 14/26] directly render labels without angularJS template code (#14700) --- .../src/common/services/blockeditormodelobject.service.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js index 20661d5d1c..606d16ad2d 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js @@ -644,6 +644,12 @@ blockObject.index = 0; if (blockObject.config.label && blockObject.config.label !== "" && blockObject.config.unsupported !== true) { + + // If the label does not contain any AngularJS template, then the MutationObserver wont give us any updates. To ensure labels without angular JS template code, we will just set the label directly for ones without '{{': + if(blockObject.config.label.indexOf("{{") === -1) { + blockObject.label = blockObject.config.label; + } + var labelElement = $('
', { text: blockObject.config.label}); var observer = new MutationObserver(function(mutations) { @@ -673,6 +679,7 @@ this.__labelScope = Object.assign(this.__labelScope, labelVars); $compile(labelElement.contents())(this.__labelScope); + }.bind(blockObject) } else { blockObject.__renderLabel = function() {}; From 80ea5174c6d1e52f798cd892acf243ed8e6ed27d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 22 Aug 2023 12:28:01 +0100 Subject: [PATCH 15/26] Updated JSON schema reference for Umbraco Forms. (#14701) --- src/JsonSchema/JsonSchema.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonSchema/JsonSchema.csproj b/src/JsonSchema/JsonSchema.csproj index edf62516a5..07a29835de 100644 --- a/src/JsonSchema/JsonSchema.csproj +++ b/src/JsonSchema/JsonSchema.csproj @@ -13,6 +13,6 @@ - + From 9fd0b1986c39344f8f7331b5c8b69cca76d228d4 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 22 Aug 2023 12:28:01 +0100 Subject: [PATCH 16/26] Updated JSON schema reference for Umbraco Forms. (#14701) --- src/JsonSchema/JsonSchema.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonSchema/JsonSchema.csproj b/src/JsonSchema/JsonSchema.csproj index edf62516a5..07a29835de 100644 --- a/src/JsonSchema/JsonSchema.csproj +++ b/src/JsonSchema/JsonSchema.csproj @@ -13,6 +13,6 @@ - + From d55329736969f9d87ed6c646609150bcf5152a5a Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Date: Wed, 23 Aug 2023 07:13:20 +0200 Subject: [PATCH 17/26] V12: Get multiple items by ids endpoints (#14702) * Adding get multiple items by their ids endpoint * Adding get multiple items by their ids endpoint for the Media API as well --- .../Controllers/ByIdsContentApiController.cs | 43 +++++++++++++++++++ .../Controllers/ByIdsMediaApiController.cs | 41 ++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/ByIdsContentApiController.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Controllers/ByIdsMediaApiController.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdsContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdsContentApiController.cs new file mode 100644 index 0000000000..ca2deb360d --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdsContentApiController.cs @@ -0,0 +1,43 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[ApiVersion("1.0")] +public class ByIdsContentApiController : ContentApiItemControllerBase +{ + public ByIdsContentApiController( + IApiPublishedContentCache apiPublishedContentCache, + IApiContentResponseBuilder apiContentResponseBuilder, + IPublicAccessService publicAccessService) + : base(apiPublishedContentCache, apiContentResponseBuilder, publicAccessService) + { + } + + /// + /// Gets content items by ids. + /// + /// The unique identifiers of the content items to retrieve. + /// The content items. + [HttpGet("item")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task Item([FromQuery(Name = "id")] HashSet ids) + { + IEnumerable contentItems = ApiPublishedContentCache.GetByIds(ids); + + IApiContentResponse[] apiContentItems = contentItems + .Where(contentItem => !IsProtected(contentItem)) + .Select(ApiContentResponseBuilder.Build) + .WhereNotNull() + .ToArray(); + + return await Task.FromResult(Ok(apiContentItems)); + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdsMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdsMediaApiController.cs new file mode 100644 index 0000000000..b8327e95c3 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/ByIdsMediaApiController.cs @@ -0,0 +1,41 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.DeliveryApi; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Controllers; + +[ApiVersion("1.0")] +public class ByIdsMediaApiController : MediaApiControllerBase +{ + public ByIdsMediaApiController(IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder) + : base(publishedSnapshotAccessor, apiMediaWithCropsResponseBuilder) + { + } + + /// + /// Gets media items by ids. + /// + /// The unique identifiers of the media items to retrieve. + /// The media items. + [HttpGet("item")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task Item([FromQuery(Name = "id")] HashSet ids) + { + IPublishedContent[] mediaItems = ids + .Select(PublishedMediaCache.GetById) + .WhereNotNull() + .ToArray(); + + ApiMediaWithCropsResponse[] apiMediaItems = mediaItems + .Select(BuildApiMediaWithCrops) + .ToArray(); + + return await Task.FromResult(Ok(apiMediaItems)); + } +} From e5a8d01004c151cbcf91dd0a50344b0b16864dab Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Wed, 23 Aug 2023 10:09:43 +0200 Subject: [PATCH 18/26] Fix exceptions in Slider and Tags property value converters (#13782) * Fix IndexOutOfRangeException when converting single value to range in SliderValueConverter * Fix NullReferenceException while deserializing empty value in TagsValueConverter * Use invariant decimal parsing * Handle converting from slider to single value * Fix parsing range as single value * Make Handle methods autonomous --------- Co-authored-by: nikolajlauridsen --- .../Cache/DataTypeCacheRefresher.cs | 4 - .../ValueConverters/SliderValueConverter.cs | 127 ++++++++++++------ .../ValueConverters/TagsValueConverter.cs | 78 +++++------ 3 files changed, 125 insertions(+), 84 deletions(-) diff --git a/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs b/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs index ea661c5498..394630fa64 100644 --- a/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs @@ -88,10 +88,6 @@ public sealed class DataTypeCacheRefresher : PayloadCacheRefresherBase _publishedSnapshotService.Notify(payloads)); diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs index 76f5b62265..14e80952f4 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Globalization; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Services; @@ -6,74 +6,123 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; +/// +/// The slider property value converter. +/// +/// [DefaultPropertyValueConverter] public class SliderValueConverter : PropertyValueConverterBase { - private static readonly ConcurrentDictionary Storages = new(); - private readonly IDataTypeService _dataTypeService; + /// + /// Initializes a new instance of the class. + /// + public SliderValueConverter() + { } - public SliderValueConverter(IDataTypeService dataTypeService) => _dataTypeService = - dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService)); + /// + /// Initializes a new instance of the class. + /// + /// The data type service. + [Obsolete("The IDataTypeService is not used anymore. This constructor will be removed in a future version.")] + public SliderValueConverter(IDataTypeService dataTypeService) + { } - public static void ClearCaches() => Storages.Clear(); + /// + /// Clears the data type configuration caches. + /// + [Obsolete("Caching of data type configuration is not done anymore. This method will be removed in a future version.")] + public static void ClearCaches() + { } + /// public override bool IsConverter(IPublishedPropertyType propertyType) => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.Slider); + /// public override Type GetPropertyValueType(IPublishedPropertyType propertyType) - => IsRangeDataType(propertyType.DataType.Id) ? typeof(Range) : typeof(decimal); + => IsRange(propertyType) ? typeof(Range) : typeof(decimal); + /// public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Element; + /// public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel cacheLevel, object? source, bool preview) { - if (source == null) + bool isRange = IsRange(propertyType); + + var sourceString = source?.ToString(); + + return isRange + ? HandleRange(sourceString) + : HandleDecimal(sourceString); + } + + private static Range HandleRange(string? sourceString) + { + if (sourceString is null) { - return null; + return new Range(); } - if (IsRangeDataType(propertyType.DataType.Id)) - { - var rangeRawValues = source.ToString()!.Split(Constants.CharArrays.Comma); - Attempt minimumAttempt = rangeRawValues[0].TryConvertTo(); - Attempt maximumAttempt = rangeRawValues[1].TryConvertTo(); + string[] rangeRawValues = sourceString.Split(Constants.CharArrays.Comma); - if (minimumAttempt.Success && maximumAttempt.Success) + if (TryParseDecimal(rangeRawValues[0], out var minimum)) + { + if (rangeRawValues.Length == 1) { - return new Range { Maximum = maximumAttempt.Result, Minimum = minimumAttempt.Result }; + // Configuration is probably changed from single to range, return range with same min/max + return new Range + { + Minimum = minimum, + Maximum = minimum + }; + } + + if (rangeRawValues.Length == 2 && TryParseDecimal(rangeRawValues[1], out var maximum)) + { + return new Range + { + Minimum = minimum, + Maximum = maximum + }; } } - Attempt valueAttempt = source.ToString().TryConvertTo(); - if (valueAttempt.Success) + return new Range(); + } + + private static decimal HandleDecimal(string? sourceString) + { + if (string.IsNullOrEmpty(sourceString)) { - return valueAttempt.Result; + return default; } - // Something failed in the conversion of the strings to decimals - return null; + // This used to be a range slider, so we'll assign the minimum value as the new value + if (sourceString.Contains(',')) + { + var minimumValueRepresentation = sourceString.Split(Constants.CharArrays.Comma)[0]; + + if (TryParseDecimal(minimumValueRepresentation, out var minimum)) + { + return minimum; + } + } + else if (TryParseDecimal(sourceString, out var value)) + { + return value; + } + + return default; } /// - /// Discovers if the slider is set to range mode. + /// Helper method for parsing a double consistently /// - /// - /// The data type id. - /// - /// - /// The . - /// - private bool IsRangeDataType(int dataTypeId) => + private static bool TryParseDecimal(string? representation, out decimal value) + => decimal.TryParse(representation, NumberStyles.Number, CultureInfo.InvariantCulture, out value); - // GetPreValuesCollectionByDataTypeId is cached at repository level; - // still, the collection is deep-cloned so this is kinda expensive, - // better to cache here + trigger refresh in DataTypeCacheRefresher - // TODO: this is cheap now, remove the caching - Storages.GetOrAdd(dataTypeId, id => - { - IDataType? dataType = _dataTypeService.GetDataType(id); - SliderConfiguration? configuration = dataType?.ConfigurationAs(); - return configuration?.EnableRange ?? false; - }); + private static bool IsRange(IPublishedPropertyType propertyType) + => propertyType.DataType.ConfigurationAs()?.EnableRange == true; } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs index 3afc5a6596..2dd1c1d56e 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Serialization; @@ -7,69 +6,66 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; +/// +/// The tags property value converter. +/// +/// [DefaultPropertyValueConverter] public class TagsValueConverter : PropertyValueConverterBase { - private static readonly ConcurrentDictionary Storages = new(); - private readonly IDataTypeService _dataTypeService; private readonly IJsonSerializer _jsonSerializer; + /// + /// Initializes a new instance of the class. + /// + /// The JSON serializer. + /// jsonSerializer + public TagsValueConverter(IJsonSerializer jsonSerializer) + => _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); + + /// + /// Initializes a new instance of the class. + /// + /// The data type service. + /// The JSON serializer. + [Obsolete("The IDataTypeService is not used anymore. This constructor will be removed in a future version.")] public TagsValueConverter(IDataTypeService dataTypeService, IJsonSerializer jsonSerializer) - { - _dataTypeService = dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService)); - _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); - } + : this(jsonSerializer) + { } - public static void ClearCaches() => Storages.Clear(); + /// + /// Clears the data type configuration caches. + /// + [Obsolete("Caching of data type configuration is not done anymore. This method will be removed in a future version.")] + public static void ClearCaches() + { } + /// public override bool IsConverter(IPublishedPropertyType propertyType) => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.Tags); + /// public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); + /// public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Element; + /// public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview) { - if (source == null) + string? sourceString = source?.ToString(); + if (string.IsNullOrEmpty(sourceString)) { return Array.Empty(); } - // if Json storage type deserialize and return as string array - if (JsonStorageType(propertyType.DataType.Id)) - { - var array = source.ToString() is not null - ? _jsonSerializer.Deserialize(source.ToString()!) - : null; - return array ?? Array.Empty(); - } - - // Otherwise assume CSV storage type and return as string array - return source.ToString()?.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); + return IsJson(propertyType) + ? _jsonSerializer.Deserialize(sourceString) ?? Array.Empty() + : sourceString.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); } - public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel cacheLevel, object? source, bool preview) => (string[]?)source; - - /// - /// Discovers if the tags data type is storing its data in a Json format - /// - /// - /// The data type id. - /// - /// - /// The . - /// - private bool JsonStorageType(int dataTypeId) => - - // GetDataType(id) is cached at repository level; still, there is some - // deep-cloning involved (expensive) - better cache here + trigger - // refresh in DataTypeCacheRefresher - Storages.GetOrAdd(dataTypeId, id => - { - TagConfiguration? configuration = _dataTypeService.GetDataType(id)?.ConfigurationAs(); - return configuration?.StorageType == TagsStorageType.Json; - }); + private static bool IsJson(IPublishedPropertyType propertyType) + => propertyType.DataType.ConfigurationAs()?.StorageType == TagsStorageType.Json; } From 3c37653012d1d4e43c74c605cdd5b786c51a5026 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Wed, 23 Aug 2023 10:09:43 +0200 Subject: [PATCH 19/26] Fix exceptions in Slider and Tags property value converters (#13782) * Fix IndexOutOfRangeException when converting single value to range in SliderValueConverter * Fix NullReferenceException while deserializing empty value in TagsValueConverter * Use invariant decimal parsing * Handle converting from slider to single value * Fix parsing range as single value * Make Handle methods autonomous --------- Co-authored-by: nikolajlauridsen --- .../Cache/DataTypeCacheRefresher.cs | 4 - .../ValueConverters/SliderValueConverter.cs | 127 ++++++++++++------ .../ValueConverters/TagsValueConverter.cs | 78 +++++------ 3 files changed, 125 insertions(+), 84 deletions(-) diff --git a/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs b/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs index ea661c5498..394630fa64 100644 --- a/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs @@ -88,10 +88,6 @@ public sealed class DataTypeCacheRefresher : PayloadCacheRefresherBase _publishedSnapshotService.Notify(payloads)); diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs index 76f5b62265..14e80952f4 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Globalization; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Services; @@ -6,74 +6,123 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; +/// +/// The slider property value converter. +/// +/// [DefaultPropertyValueConverter] public class SliderValueConverter : PropertyValueConverterBase { - private static readonly ConcurrentDictionary Storages = new(); - private readonly IDataTypeService _dataTypeService; + /// + /// Initializes a new instance of the class. + /// + public SliderValueConverter() + { } - public SliderValueConverter(IDataTypeService dataTypeService) => _dataTypeService = - dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService)); + /// + /// Initializes a new instance of the class. + /// + /// The data type service. + [Obsolete("The IDataTypeService is not used anymore. This constructor will be removed in a future version.")] + public SliderValueConverter(IDataTypeService dataTypeService) + { } - public static void ClearCaches() => Storages.Clear(); + /// + /// Clears the data type configuration caches. + /// + [Obsolete("Caching of data type configuration is not done anymore. This method will be removed in a future version.")] + public static void ClearCaches() + { } + /// public override bool IsConverter(IPublishedPropertyType propertyType) => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.Slider); + /// public override Type GetPropertyValueType(IPublishedPropertyType propertyType) - => IsRangeDataType(propertyType.DataType.Id) ? typeof(Range) : typeof(decimal); + => IsRange(propertyType) ? typeof(Range) : typeof(decimal); + /// public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Element; + /// public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel cacheLevel, object? source, bool preview) { - if (source == null) + bool isRange = IsRange(propertyType); + + var sourceString = source?.ToString(); + + return isRange + ? HandleRange(sourceString) + : HandleDecimal(sourceString); + } + + private static Range HandleRange(string? sourceString) + { + if (sourceString is null) { - return null; + return new Range(); } - if (IsRangeDataType(propertyType.DataType.Id)) - { - var rangeRawValues = source.ToString()!.Split(Constants.CharArrays.Comma); - Attempt minimumAttempt = rangeRawValues[0].TryConvertTo(); - Attempt maximumAttempt = rangeRawValues[1].TryConvertTo(); + string[] rangeRawValues = sourceString.Split(Constants.CharArrays.Comma); - if (minimumAttempt.Success && maximumAttempt.Success) + if (TryParseDecimal(rangeRawValues[0], out var minimum)) + { + if (rangeRawValues.Length == 1) { - return new Range { Maximum = maximumAttempt.Result, Minimum = minimumAttempt.Result }; + // Configuration is probably changed from single to range, return range with same min/max + return new Range + { + Minimum = minimum, + Maximum = minimum + }; + } + + if (rangeRawValues.Length == 2 && TryParseDecimal(rangeRawValues[1], out var maximum)) + { + return new Range + { + Minimum = minimum, + Maximum = maximum + }; } } - Attempt valueAttempt = source.ToString().TryConvertTo(); - if (valueAttempt.Success) + return new Range(); + } + + private static decimal HandleDecimal(string? sourceString) + { + if (string.IsNullOrEmpty(sourceString)) { - return valueAttempt.Result; + return default; } - // Something failed in the conversion of the strings to decimals - return null; + // This used to be a range slider, so we'll assign the minimum value as the new value + if (sourceString.Contains(',')) + { + var minimumValueRepresentation = sourceString.Split(Constants.CharArrays.Comma)[0]; + + if (TryParseDecimal(minimumValueRepresentation, out var minimum)) + { + return minimum; + } + } + else if (TryParseDecimal(sourceString, out var value)) + { + return value; + } + + return default; } /// - /// Discovers if the slider is set to range mode. + /// Helper method for parsing a double consistently /// - /// - /// The data type id. - /// - /// - /// The . - /// - private bool IsRangeDataType(int dataTypeId) => + private static bool TryParseDecimal(string? representation, out decimal value) + => decimal.TryParse(representation, NumberStyles.Number, CultureInfo.InvariantCulture, out value); - // GetPreValuesCollectionByDataTypeId is cached at repository level; - // still, the collection is deep-cloned so this is kinda expensive, - // better to cache here + trigger refresh in DataTypeCacheRefresher - // TODO: this is cheap now, remove the caching - Storages.GetOrAdd(dataTypeId, id => - { - IDataType? dataType = _dataTypeService.GetDataType(id); - SliderConfiguration? configuration = dataType?.ConfigurationAs(); - return configuration?.EnableRange ?? false; - }); + private static bool IsRange(IPublishedPropertyType propertyType) + => propertyType.DataType.ConfigurationAs()?.EnableRange == true; } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs index 3afc5a6596..2dd1c1d56e 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Serialization; @@ -7,69 +6,66 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; +/// +/// The tags property value converter. +/// +/// [DefaultPropertyValueConverter] public class TagsValueConverter : PropertyValueConverterBase { - private static readonly ConcurrentDictionary Storages = new(); - private readonly IDataTypeService _dataTypeService; private readonly IJsonSerializer _jsonSerializer; + /// + /// Initializes a new instance of the class. + /// + /// The JSON serializer. + /// jsonSerializer + public TagsValueConverter(IJsonSerializer jsonSerializer) + => _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); + + /// + /// Initializes a new instance of the class. + /// + /// The data type service. + /// The JSON serializer. + [Obsolete("The IDataTypeService is not used anymore. This constructor will be removed in a future version.")] public TagsValueConverter(IDataTypeService dataTypeService, IJsonSerializer jsonSerializer) - { - _dataTypeService = dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService)); - _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); - } + : this(jsonSerializer) + { } - public static void ClearCaches() => Storages.Clear(); + /// + /// Clears the data type configuration caches. + /// + [Obsolete("Caching of data type configuration is not done anymore. This method will be removed in a future version.")] + public static void ClearCaches() + { } + /// public override bool IsConverter(IPublishedPropertyType propertyType) => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.Tags); + /// public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); + /// public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Element; + /// public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview) { - if (source == null) + string? sourceString = source?.ToString(); + if (string.IsNullOrEmpty(sourceString)) { return Array.Empty(); } - // if Json storage type deserialize and return as string array - if (JsonStorageType(propertyType.DataType.Id)) - { - var array = source.ToString() is not null - ? _jsonSerializer.Deserialize(source.ToString()!) - : null; - return array ?? Array.Empty(); - } - - // Otherwise assume CSV storage type and return as string array - return source.ToString()?.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); + return IsJson(propertyType) + ? _jsonSerializer.Deserialize(sourceString) ?? Array.Empty() + : sourceString.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); } - public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel cacheLevel, object? source, bool preview) => (string[]?)source; - - /// - /// Discovers if the tags data type is storing its data in a Json format - /// - /// - /// The data type id. - /// - /// - /// The . - /// - private bool JsonStorageType(int dataTypeId) => - - // GetDataType(id) is cached at repository level; still, there is some - // deep-cloning involved (expensive) - better cache here + trigger - // refresh in DataTypeCacheRefresher - Storages.GetOrAdd(dataTypeId, id => - { - TagConfiguration? configuration = _dataTypeService.GetDataType(id)?.ConfigurationAs(); - return configuration?.StorageType == TagsStorageType.Json; - }); + private static bool IsJson(IPublishedPropertyType propertyType) + => propertyType.DataType.ConfigurationAs()?.StorageType == TagsStorageType.Json; } From f97e9a9f34a2350aac4f5777ef240c9b63038f8e Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Wed, 23 Aug 2023 10:09:43 +0200 Subject: [PATCH 20/26] Fix exceptions in Slider and Tags property value converters (#13782) * Fix IndexOutOfRangeException when converting single value to range in SliderValueConverter * Fix NullReferenceException while deserializing empty value in TagsValueConverter * Use invariant decimal parsing * Handle converting from slider to single value * Fix parsing range as single value * Make Handle methods autonomous --------- Co-authored-by: nikolajlauridsen --- .../Cache/DataTypeCacheRefresher.cs | 4 - .../ValueConverters/SliderValueConverter.cs | 127 ++++++++++++------ .../ValueConverters/TagsValueConverter.cs | 78 +++++------ 3 files changed, 125 insertions(+), 84 deletions(-) diff --git a/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs b/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs index ea661c5498..394630fa64 100644 --- a/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/DataTypeCacheRefresher.cs @@ -88,10 +88,6 @@ public sealed class DataTypeCacheRefresher : PayloadCacheRefresherBase _publishedSnapshotService.Notify(payloads)); diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs index 76f5b62265..14e80952f4 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/SliderValueConverter.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Globalization; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Services; @@ -6,74 +6,123 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; +/// +/// The slider property value converter. +/// +/// [DefaultPropertyValueConverter] public class SliderValueConverter : PropertyValueConverterBase { - private static readonly ConcurrentDictionary Storages = new(); - private readonly IDataTypeService _dataTypeService; + /// + /// Initializes a new instance of the class. + /// + public SliderValueConverter() + { } - public SliderValueConverter(IDataTypeService dataTypeService) => _dataTypeService = - dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService)); + /// + /// Initializes a new instance of the class. + /// + /// The data type service. + [Obsolete("The IDataTypeService is not used anymore. This constructor will be removed in a future version.")] + public SliderValueConverter(IDataTypeService dataTypeService) + { } - public static void ClearCaches() => Storages.Clear(); + /// + /// Clears the data type configuration caches. + /// + [Obsolete("Caching of data type configuration is not done anymore. This method will be removed in a future version.")] + public static void ClearCaches() + { } + /// public override bool IsConverter(IPublishedPropertyType propertyType) => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.Slider); + /// public override Type GetPropertyValueType(IPublishedPropertyType propertyType) - => IsRangeDataType(propertyType.DataType.Id) ? typeof(Range) : typeof(decimal); + => IsRange(propertyType) ? typeof(Range) : typeof(decimal); + /// public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Element; + /// public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel cacheLevel, object? source, bool preview) { - if (source == null) + bool isRange = IsRange(propertyType); + + var sourceString = source?.ToString(); + + return isRange + ? HandleRange(sourceString) + : HandleDecimal(sourceString); + } + + private static Range HandleRange(string? sourceString) + { + if (sourceString is null) { - return null; + return new Range(); } - if (IsRangeDataType(propertyType.DataType.Id)) - { - var rangeRawValues = source.ToString()!.Split(Constants.CharArrays.Comma); - Attempt minimumAttempt = rangeRawValues[0].TryConvertTo(); - Attempt maximumAttempt = rangeRawValues[1].TryConvertTo(); + string[] rangeRawValues = sourceString.Split(Constants.CharArrays.Comma); - if (minimumAttempt.Success && maximumAttempt.Success) + if (TryParseDecimal(rangeRawValues[0], out var minimum)) + { + if (rangeRawValues.Length == 1) { - return new Range { Maximum = maximumAttempt.Result, Minimum = minimumAttempt.Result }; + // Configuration is probably changed from single to range, return range with same min/max + return new Range + { + Minimum = minimum, + Maximum = minimum + }; + } + + if (rangeRawValues.Length == 2 && TryParseDecimal(rangeRawValues[1], out var maximum)) + { + return new Range + { + Minimum = minimum, + Maximum = maximum + }; } } - Attempt valueAttempt = source.ToString().TryConvertTo(); - if (valueAttempt.Success) + return new Range(); + } + + private static decimal HandleDecimal(string? sourceString) + { + if (string.IsNullOrEmpty(sourceString)) { - return valueAttempt.Result; + return default; } - // Something failed in the conversion of the strings to decimals - return null; + // This used to be a range slider, so we'll assign the minimum value as the new value + if (sourceString.Contains(',')) + { + var minimumValueRepresentation = sourceString.Split(Constants.CharArrays.Comma)[0]; + + if (TryParseDecimal(minimumValueRepresentation, out var minimum)) + { + return minimum; + } + } + else if (TryParseDecimal(sourceString, out var value)) + { + return value; + } + + return default; } /// - /// Discovers if the slider is set to range mode. + /// Helper method for parsing a double consistently /// - /// - /// The data type id. - /// - /// - /// The . - /// - private bool IsRangeDataType(int dataTypeId) => + private static bool TryParseDecimal(string? representation, out decimal value) + => decimal.TryParse(representation, NumberStyles.Number, CultureInfo.InvariantCulture, out value); - // GetPreValuesCollectionByDataTypeId is cached at repository level; - // still, the collection is deep-cloned so this is kinda expensive, - // better to cache here + trigger refresh in DataTypeCacheRefresher - // TODO: this is cheap now, remove the caching - Storages.GetOrAdd(dataTypeId, id => - { - IDataType? dataType = _dataTypeService.GetDataType(id); - SliderConfiguration? configuration = dataType?.ConfigurationAs(); - return configuration?.EnableRange ?? false; - }); + private static bool IsRange(IPublishedPropertyType propertyType) + => propertyType.DataType.ConfigurationAs()?.EnableRange == true; } diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs index 3afc5a6596..2dd1c1d56e 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/TagsValueConverter.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Serialization; @@ -7,69 +6,66 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; +/// +/// The tags property value converter. +/// +/// [DefaultPropertyValueConverter] public class TagsValueConverter : PropertyValueConverterBase { - private static readonly ConcurrentDictionary Storages = new(); - private readonly IDataTypeService _dataTypeService; private readonly IJsonSerializer _jsonSerializer; + /// + /// Initializes a new instance of the class. + /// + /// The JSON serializer. + /// jsonSerializer + public TagsValueConverter(IJsonSerializer jsonSerializer) + => _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); + + /// + /// Initializes a new instance of the class. + /// + /// The data type service. + /// The JSON serializer. + [Obsolete("The IDataTypeService is not used anymore. This constructor will be removed in a future version.")] public TagsValueConverter(IDataTypeService dataTypeService, IJsonSerializer jsonSerializer) - { - _dataTypeService = dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService)); - _jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer)); - } + : this(jsonSerializer) + { } - public static void ClearCaches() => Storages.Clear(); + /// + /// Clears the data type configuration caches. + /// + [Obsolete("Caching of data type configuration is not done anymore. This method will be removed in a future version.")] + public static void ClearCaches() + { } + /// public override bool IsConverter(IPublishedPropertyType propertyType) => propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.Tags); + /// public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); + /// public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Element; + /// public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview) { - if (source == null) + string? sourceString = source?.ToString(); + if (string.IsNullOrEmpty(sourceString)) { return Array.Empty(); } - // if Json storage type deserialize and return as string array - if (JsonStorageType(propertyType.DataType.Id)) - { - var array = source.ToString() is not null - ? _jsonSerializer.Deserialize(source.ToString()!) - : null; - return array ?? Array.Empty(); - } - - // Otherwise assume CSV storage type and return as string array - return source.ToString()?.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); + return IsJson(propertyType) + ? _jsonSerializer.Deserialize(sourceString) ?? Array.Empty() + : sourceString.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); } - public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel cacheLevel, object? source, bool preview) => (string[]?)source; - - /// - /// Discovers if the tags data type is storing its data in a Json format - /// - /// - /// The data type id. - /// - /// - /// The . - /// - private bool JsonStorageType(int dataTypeId) => - - // GetDataType(id) is cached at repository level; still, there is some - // deep-cloning involved (expensive) - better cache here + trigger - // refresh in DataTypeCacheRefresher - Storages.GetOrAdd(dataTypeId, id => - { - TagConfiguration? configuration = _dataTypeService.GetDataType(id)?.ConfigurationAs(); - return configuration?.StorageType == TagsStorageType.Json; - }); + private static bool IsJson(IPublishedPropertyType propertyType) + => propertyType.DataType.ConfigurationAs()?.StorageType == TagsStorageType.Json; } From f2b0c0e8eb9c43d3ad7b0c6dc443c21604685504 Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Thu, 24 Aug 2023 10:44:01 +0200 Subject: [PATCH 21/26] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 6761d6acc5..840819c76b 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "11.5.0-rc", + "version": "11.6.0-rc", "assemblyVersion": { "precision": "build" }, From 869b480dae03345f110a26fc0a5fa08e7f5b2487 Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Thu, 24 Aug 2023 10:49:50 +0200 Subject: [PATCH 22/26] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index f4c3bd0a8a..3c0aeb9872 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "10.7.0-rc", + "version": "10.8.0-rc", "assemblyVersion": { "precision": "build" }, From 1198c76d67e3aa9ef5cebeb54d66708186125165 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Fri, 25 Aug 2023 13:07:42 +0200 Subject: [PATCH 23/26] Remove parsing of short into integer (#14721) --- .../Persistence/Factories/UserGroupFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs index 3c4546da04..44d9cc6790 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs @@ -78,7 +78,7 @@ internal static class UserGroupFactory if (entity.HasIdentity) { - dto.Id = short.Parse(entity.Id.ToString()); + dto.Id = entity.Id; } return dto; From 3a53f51ef389202b07d76b0eafe4a6174fddabf9 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Fri, 25 Aug 2023 13:07:42 +0200 Subject: [PATCH 24/26] Remove parsing of short into integer (#14721) --- .../Persistence/Factories/UserGroupFactory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs index 3c4546da04..44d9cc6790 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs @@ -78,7 +78,7 @@ internal static class UserGroupFactory if (entity.HasIdentity) { - dto.Id = short.Parse(entity.Id.ToString()); + dto.Id = entity.Id; } return dto; From 27441dfee123956f812f80f7b6e7b90263dad425 Mon Sep 17 00:00:00 2001 From: Nathan Woulfe Date: Tue, 22 Aug 2023 19:10:58 +1000 Subject: [PATCH 25/26] Don't pass content object to save method - it's not a suitable label key :) (#14496) * don't set label key to entire content model... * Remove the default parameter value --------- Co-authored-by: kjac (cherry picked from commit 5dfb914e0f2a335c05629f38fbad2a6197a2f656) --- .../src/views/components/content/edit.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html b/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html index 0c5ad35b04..f031b4da9d 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/content/edit.html @@ -59,7 +59,7 @@ type="button" button-style="{{page.saveButtonStyle}}" state="page.saveButtonState" - action="save(content)" + action="save()" label-key="buttons_save" shortcut="ctrl+s" add-ellipsis="{{page.saveButtonEllipsis}}" From 9230b253b6cca9220e9741cf15e8e090bd2d2c51 Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Mon, 28 Aug 2023 09:47:14 +0200 Subject: [PATCH 26/26] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index c4ce657196..ab0523008b 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "12.1.1", + "version": "12.1.2", "assemblyVersion": { "precision": "build" },