From a49a9851dc81677637ec1823963c50d84ef360f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dennis=20=C3=96hman?= Date: Tue, 18 Jul 2023 18:10:33 +0200 Subject: [PATCH 01/56] #14325 - Remove wildcard-background from RTE (#14439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update rte-content.less #14325 - Remove wildcard-background from RTE * Update rte-content.less I did an update. 🙂 The transparent background is there because the tinymce theme Oxide adds a background-color to all selected elements. So the umbraco added transparent background makes white text not be visible. https://github.com/tinymce/oxide/blob/012346905578b0d7e59f7000d447f56243243b4a/src/less/theme/content/selection/selection.less#L50C2-L50C2 So I put back the transparent background-color and added a @blueExtraDark as text-color I have updated the issue with more details. --- src/Umbraco.Web.UI.Client/src/less/rte-content.less | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/less/rte-content.less b/src/Umbraco.Web.UI.Client/src/less/rte-content.less index f19b1edb98..5a52060a0f 100644 --- a/src/Umbraco.Web.UI.Client/src/less/rte-content.less +++ b/src/Umbraco.Web.UI.Client/src/less/rte-content.less @@ -44,6 +44,7 @@ } .umb-rte *[data-mce-selected="inline-boundary"] { - background:rgba(0,0,0,0.025); + background: rgba(0,0,0,.025); + color: @blueExtraDark; outline: 2px solid @pinkLight; } From 57852f5e627b61e9d4c2e6f20274f16bde032f1c Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Thu, 20 Jul 2023 13:23:38 +0200 Subject: [PATCH 02/56] Add PreRouting and PostRouting pipeline filters (#14503) --- .../IUmbracoApplicationBuilderContext.cs | 29 ++++-- .../IUmbracoPipelineFilter.cs | 42 ++++++--- .../UmbracoApplicationBuilder.cs | 19 ++++ .../UmbracoPipelineFilter.cs | 92 ++++++++++++++++--- 4 files changed, 154 insertions(+), 28 deletions(-) diff --git a/src/Umbraco.Web.Common/ApplicationBuilder/IUmbracoApplicationBuilderContext.cs b/src/Umbraco.Web.Common/ApplicationBuilder/IUmbracoApplicationBuilderContext.cs index 749c3feae4..5fb83e5e47 100644 --- a/src/Umbraco.Web.Common/ApplicationBuilder/IUmbracoApplicationBuilderContext.cs +++ b/src/Umbraco.Web.Common/ApplicationBuilder/IUmbracoApplicationBuilderContext.cs @@ -1,30 +1,47 @@ namespace Umbraco.Cms.Web.Common.ApplicationBuilder; /// -/// The context object used during +/// The context object used when building the Umbraco application. /// +/// public interface IUmbracoApplicationBuilderContext : IUmbracoApplicationBuilderServices { /// - /// Called to include the core umbraco middleware. + /// Called to include the core Umbraco middlewares. /// void UseUmbracoCoreMiddleware(); /// - /// Manually runs the pre pipeline filters + /// Manually runs the pre pipeline filters. /// void RunPrePipeline(); /// - /// Manually runs the post pipeline filters + /// Manually runs the pre routing filters. + /// + void RunPreRouting() + { + // TODO: Remove default implementation in Umbraco 13 + } + + /// + /// Manually runs the post routing filters. + /// + void RunPostRouting() + { + // TODO: Remove default implementation in Umbraco 13 + } + + /// + /// Manually runs the post pipeline filters. /// void RunPostPipeline(); /// - /// Called to include all of the default umbraco required middleware. + /// Called to include all of the default Umbraco required middleware. /// /// - /// If using this method, there is no need to use + /// If using this method, there is no need to use . /// void RegisterDefaultRequiredMiddleware(); } diff --git a/src/Umbraco.Web.Common/ApplicationBuilder/IUmbracoPipelineFilter.cs b/src/Umbraco.Web.Common/ApplicationBuilder/IUmbracoPipelineFilter.cs index 1f86dbeed7..0cbb61ac3c 100644 --- a/src/Umbraco.Web.Common/ApplicationBuilder/IUmbracoPipelineFilter.cs +++ b/src/Umbraco.Web.Common/ApplicationBuilder/IUmbracoPipelineFilter.cs @@ -3,37 +3,57 @@ using Microsoft.AspNetCore.Builder; namespace Umbraco.Cms.Web.Common.ApplicationBuilder; /// -/// Used to modify the pipeline before and after Umbraco registers it's core -/// middlewares. +/// Used to modify the pipeline before and after Umbraco registers its middlewares. /// /// -/// Mainly used for package developers. +/// Mainly used for package developers. /// public interface IUmbracoPipelineFilter { /// - /// The name of the filter + /// The name of the filter. /// + /// + /// The name. + /// /// - /// This can be used by developers to see what is registered and if anything should be re-ordered, removed, etc... + /// This can be used by developers to see what is registered and if anything should be re-ordered, removed, etc... /// string Name { get; } /// - /// Executes before Umbraco middlewares are registered + /// Executes before any default Umbraco middlewares are registered. /// - /// + /// The application. void OnPrePipeline(IApplicationBuilder app); /// - /// Executes after core Umbraco middlewares are registered and before any Endpoints are declared + /// Executes after static files middlewares are registered and just before the routing middleware is registered. /// - /// + /// The application. + void OnPreRouting(IApplicationBuilder app) + { + // TODO: Remove default implementation in Umbraco 13 + } + + /// + /// Executes after the routing middleware is registered and just before the authentication and authorization middlewares are registered. This can be used to add CORS policies. + /// + /// The application. + void OnPostRouting(IApplicationBuilder app) + { + // TODO: Remove default implementation in Umbraco 13 + } + + /// + /// Executes after core Umbraco middlewares are registered and before any endpoints are declared. + /// + /// The application. void OnPostPipeline(IApplicationBuilder app); /// - /// Executes after just before any Umbraco endpoints are declared. + /// Executes after the middlewares are registered and just before any Umbraco endpoints are declared. /// - /// + /// The application. void OnEndpoints(IApplicationBuilder app); } diff --git a/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoApplicationBuilder.cs b/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoApplicationBuilder.cs index 93fb6f3143..d489b658f0 100644 --- a/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoApplicationBuilder.cs +++ b/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoApplicationBuilder.cs @@ -82,7 +82,10 @@ public class UmbracoApplicationBuilder : IUmbracoApplicationBuilder, IUmbracoEnd // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-5.0 // where we need to have UseAuthentication and UseAuthorization proceeding this call but before // endpoints are defined. + RunPreRouting(); AppBuilder.UseRouting(); + RunPostRouting(); + AppBuilder.UseAuthentication(); AppBuilder.UseAuthorization(); @@ -114,6 +117,22 @@ public class UmbracoApplicationBuilder : IUmbracoApplicationBuilder, IUmbracoEnd } } + public void RunPreRouting() + { + foreach (IUmbracoPipelineFilter filter in _umbracoPipelineStartupOptions.Value.PipelineFilters) + { + filter.OnPreRouting(AppBuilder); + } + } + + public void RunPostRouting() + { + foreach (IUmbracoPipelineFilter filter in _umbracoPipelineStartupOptions.Value.PipelineFilters) + { + filter.OnPostRouting(AppBuilder); + } + } + public void RunPostPipeline() { foreach (IUmbracoPipelineFilter filter in _umbracoPipelineStartupOptions.Value.PipelineFilters) diff --git a/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoPipelineFilter.cs b/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoPipelineFilter.cs index aa11bc6bd9..558e52c29d 100644 --- a/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoPipelineFilter.cs +++ b/src/Umbraco.Web.Common/ApplicationBuilder/UmbracoPipelineFilter.cs @@ -2,43 +2,113 @@ using Microsoft.AspNetCore.Builder; namespace Umbraco.Cms.Web.Common.ApplicationBuilder; -/// -/// Used to modify the pipeline before and after Umbraco registers it's core -/// middlewares. -/// -/// -/// Mainly used for package developers. -/// +/// public class UmbracoPipelineFilter : IUmbracoPipelineFilter { + /// + /// Initializes a new instance of the class. + /// + /// The name. public UmbracoPipelineFilter(string name) - : this(name, null, null, null) - { - } + : this(name, null, null, null, null, null) + { } + /// + /// Initializes a new instance of the class. + /// + /// The name. + /// The pre pipeline callback. + /// The post pipeline callback. + /// The endpoint callback. + [Obsolete("Use the constructor with named parameters or set the callback properties instead. This constructor will be removed in Umbraco 13.")] public UmbracoPipelineFilter( string name, Action? prePipeline, Action? postPipeline, Action? endpointCallback) + : this(name, prePipeline, null, null, postPipeline, endpointCallback) + { } + + /// + /// Initializes a new instance of the class. + /// + /// The name. + /// The pre pipeline callback. + /// The pre routing callback. + /// The post routing callback. + /// The post pipeline callback. + /// The endpoints callback. + public UmbracoPipelineFilter( + string name, + Action? prePipeline = null, + Action? preRouting = null, + Action? postRouting = null, + Action? postPipeline = null, + Action? endpoints = null) { Name = name ?? throw new ArgumentNullException(nameof(name)); PrePipeline = prePipeline; + PreRouting = preRouting; + PostRouting = postRouting; PostPipeline = postPipeline; - Endpoints = endpointCallback; + Endpoints = endpoints; } + /// + /// Gets or sets the pre pipeline callback. + /// + /// + /// The pre pipeline callback. + /// public Action? PrePipeline { get; set; } + /// + /// Gets or sets the pre routing. + /// + /// + /// The pre routing. + /// + public Action? PreRouting { get; set; } + + /// + /// Gets or sets the post routing callback. + /// + /// + /// The post routing callback. + /// + public Action? PostRouting { get; set; } + + /// + /// Gets or sets the post pipeline callback. + /// + /// + /// The post pipeline callback. + /// public Action? PostPipeline { get; set; } + /// + /// Gets or sets the endpoints callback. + /// + /// + /// The endpoints callback. + /// public Action? Endpoints { get; set; } + /// public string Name { get; } + /// public void OnPrePipeline(IApplicationBuilder app) => PrePipeline?.Invoke(app); + /// + public void OnPreRouting(IApplicationBuilder app) => PreRouting?.Invoke(app); + + /// + public void OnPostRouting(IApplicationBuilder app) => PostRouting?.Invoke(app); + + /// public void OnPostPipeline(IApplicationBuilder app) => PostPipeline?.Invoke(app); + /// public void OnEndpoints(IApplicationBuilder app) => Endpoints?.Invoke(app); } From a936fe09ab9c9c36130d5f8468dd21143ca39dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rasmus=20S=C3=B6derstr=C3=B6m?= Date: Thu, 20 Jul 2023 13:36:29 +0200 Subject: [PATCH 03/56] Fix for #14565 - Empty DocType folders hidden (#14581) --- src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs index 27c26004b9..f68b4f8783 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentTypeTreeController.cs @@ -63,8 +63,8 @@ public class ContentTypeTreeController : TreeController, ISearchableTree if (root is not null) { - //check if there are any types - root.HasChildren = _contentTypeService.GetAll().Any(); + // check if there are any types or containers + root.HasChildren = _contentTypeService.GetAll().Any() || _contentTypeService.GetContainers(Array.Empty()).Any(); } return root; From 1527b2577e7273157cfede70444c65f838a87225 Mon Sep 17 00:00:00 2001 From: Erik-Jan Westendorp Date: Fri, 21 Jul 2023 10:31:28 +0200 Subject: [PATCH 04/56] Add missing translation (#14588) --- src/Umbraco.Core/EmbeddedResources/Lang/en.xml | 1 + src/Umbraco.Core/EmbeddedResources/Lang/nl.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 58d6787768..b7eca1ac73 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -838,6 +838,7 @@ No items have been added Server Settings + Shared Show Show page on Send Size diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml b/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml index 1b9d2882ab..0c454329f5 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml @@ -795,6 +795,7 @@ Er zijn geen items toegevoegd Server Instellingen + Gedeeld Toon Toon pagina na verzenden Grootte From 46aad34396449fd62db16b0e74e6829f049e2696 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Jul 2023 08:19:26 +0000 Subject: [PATCH 05/56] Bump word-wrap from 1.2.3 to 1.2.4 in /src/Umbraco.Web.UI.Client Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4. - [Release notes](https://github.com/jonschlinkert/word-wrap/releases) - [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4) --- updated-dependencies: - dependency-name: word-wrap dependency-type: indirect ... Signed-off-by: dependabot[bot] --- src/Umbraco.Web.UI.Client/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index c088148f80..3a5f6175d2 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -17288,9 +17288,9 @@ "integrity": "sha512-Ba9tGNYxXwaqKEi9sJJvPMKuo063umUPsHN0JJsjrs2j8KDSzkWLMZGZ+MH1Jf1Fq4OWZ5HsESJID6nRza2ang==" }, "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", + "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", "dev": true, "engines": { "node": ">=0.10.0" From 557fe38a98345f14f5dc1738b3505692bbbb693b Mon Sep 17 00:00:00 2001 From: Erik-Jan Westendorp Date: Mon, 24 Jul 2023 11:48:49 +0200 Subject: [PATCH 06/56] Change 'Comment' to 'Commentaar' --- src/Umbraco.Core/EmbeddedResources/Lang/nl.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml b/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml index 0c454329f5..3b3b09953e 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml @@ -698,7 +698,7 @@ Sluiten Sluit venster Sluit paneel - Comment + Commentaar Bevestig Beperken Verhoudingen behouden From 05b89999afb65caf7e8dd7f17d34edbaa10cc842 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Mon, 24 Jul 2023 18:51:02 +0200 Subject: [PATCH 07/56] Set max length of text input to avoid server error due character length in database (#14592) --- .../src/views/components/contenttype/umb-content-type-tab.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-tab.html b/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-tab.html index a145955334..a5c98f3f39 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-tab.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-tab.html @@ -46,6 +46,7 @@ umb-auto-resize val-server-field="{{ vm.valServerFieldName }}" data-lpignore="true" + maxlength="255" required />
From ea642d69e5e71fec6f0203433b7ddbabb582ef6a Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Tue, 25 Jul 2023 23:38:40 +0200 Subject: [PATCH 08/56] Replace attribute with noPasswordManager directive (#14593) --- .../views/components/contenttype/umb-content-type-group.html | 2 +- .../src/views/components/contenttype/umb-content-type-tab.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-group.html b/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-group.html index ad827cda8b..911ef54035 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-group.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-group.html @@ -22,7 +22,7 @@ ng-focus="vm.whenNameFocus()" required val-server-field="{{vm.valServerFieldName}}" - data-lpignore="true" /> + no-password-manager />
{{groupNameForm.groupName.errorMsg}}
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-tab.html b/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-tab.html index a5c98f3f39..d2914c69e2 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-tab.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/contenttype/umb-content-type-tab.html @@ -45,7 +45,7 @@ umb-auto-focus umb-auto-resize val-server-field="{{ vm.valServerFieldName }}" - data-lpignore="true" + no-password-manager maxlength="255" required /> From 157005194fac432580594e4357919228fdda416e Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Wed, 26 Jul 2023 17:16:30 +0200 Subject: [PATCH 09/56] Block grid area allowance editor (#14582) * Add position relative to button to fix issue with umb-outline on button element * Add pattern attribute to get more specific virtual numeric keyboard on some devices as we expect integer values * Fix missing localization of title attributes due missing @ prefix * Fix incorrect end-closing tag --- .../umb-block-grid-area-allowance-editor.html | 17 +++++++------- .../umb-block-grid-area-allowance-editor.less | 6 ++--- .../prevalue/umb-block-grid-area-editor.html | 2 +- ...b-block-grid-configuration-area-entry.html | 22 +++++++++++++------ 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.html index f6d22780d7..8bb04feef7 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.html @@ -8,38 +8,39 @@ + title="@blockEditor_allowanceMinimum" + fix-number /> + title="@blockEditor_allowanceMaximum" + fix-number /> -
- - +
{{vm.area.columnSpan}} x {{vm.area.rowSpan}}
-
\ No newline at end of file +
From dc947977848b87dba3caac96bfe69362535378a5 Mon Sep 17 00:00:00 2001 From: rasmusmedj Date: Wed, 19 Jul 2023 15:25:52 +0200 Subject: [PATCH 10/56] #14190: Save all languages sent to approval in audit log --- .../overlays/sendtopublish.controller.js | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/content/overlays/sendtopublish.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/overlays/sendtopublish.controller.js index 0bc9c16a97..c1c91057da 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/overlays/sendtopublish.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/overlays/sendtopublish.controller.js @@ -5,6 +5,7 @@ var vm = this; vm.loading = true; + vm.selectedVariants = []; vm.changeSelection = changeSelection; @@ -22,38 +23,42 @@ vm.variants.forEach(variant => { variant.isMandatory = isMandatoryFilter(variant); }); - + vm.availableVariants = vm.variants.filter(publishableVariantFilter); - + if (vm.availableVariants.length !== 0) { vm.availableVariants = contentEditingHelper.getSortedVariantsAndSegments(vm.availableVariants); - - vm.availableVariants.forEach(v => { - if(v.active) { - v.save = true; - } - }); - - } else { - //disable save button if we have nothing to save - $scope.model.disableSubmitButton = true; } + $scope.model.disableSubmitButton = true; vm.loading = false; - } function allowSendToPublish (variant) { return variant.allowedActions.includes("H"); } - function changeSelection() { - var firstSelected = vm.variants.find(v => v.save); - $scope.model.disableSubmitButton = !firstSelected; //disable submit button if there is none selected + function changeSelection(variant) { + let foundVariant = vm.selectedVariants.find(x => x.compositeId === variant.compositeId); + + if (foundVariant === undefined) { + variant.save = true; + vm.selectedVariants.push(variant); + } else { + variant.save = false; + let index = vm.selectedVariants.indexOf(foundVariant); + if (index !== -1) { + vm.selectedVariants.splice(index, 1); + } + } + + let firstSelected = vm.variants.find(v => v.save); + $scope.model.disableSubmitButton = !firstSelected; } - function isMandatoryFilter(variant) { + + function isMandatoryFilter(variant) { //determine a variant is 'dirty' (meaning it will show up as publish-able) if it's // * has a mandatory language // * without having a segment, segments cant be mandatory at current state of code. @@ -79,9 +84,7 @@ }); onInit(); - } angular.module("umbraco").controller("Umbraco.Overlays.SendToPublishController", SendToPublishController); - })(); From 71d990504ee583204b76f45e7bf6d336b5e04882 Mon Sep 17 00:00:00 2001 From: Johan Runsten Date: Sat, 29 Jul 2023 17:47:43 +0200 Subject: [PATCH 11/56] Fix incorrect redirectUrl check with external authentication (#14198) (#14423) * Fix check local redirect url * Removed line break * Small adjustment --------- Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com> --- .../Controllers/BackOfficeController.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs index bd63b51711..9cabd97dd6 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/BackOfficeController.cs @@ -329,7 +329,9 @@ public class BackOfficeController : UmbracoController [AllowAnonymous] public ActionResult ExternalLogin(string provider, string? redirectUrl = null) { - if (redirectUrl == null || Uri.TryCreate(redirectUrl, UriKind.Absolute, out _)) + // Only relative urls are accepted as redirect url + // We can't simply use Uri.TryCreate with kind Absolute, as in Linux any relative url would be seen as an absolute file uri + if (redirectUrl == null || !Uri.TryCreate(redirectUrl, UriKind.RelativeOrAbsolute, out Uri? redirectUri) || redirectUri.IsAbsoluteUri) { redirectUrl = Url.Action(nameof(Default), this.GetControllerName()); } From b7f39768315636183bdae3027580424dd10f3b71 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Mon, 31 Jul 2023 13:31:47 +0200 Subject: [PATCH 12/56] Ignore 1Password as well in `noPasswordManager` directive (#14613) --- .../common/directives/util/noPasswordManager.directive.js | 8 +++++--- .../components/editor/umb-editor-content-header.html | 1 + .../src/views/components/editor/umb-editor-header.html | 6 +++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/util/noPasswordManager.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/util/noPasswordManager.directive.js index 2c52506f42..cbd02042bd 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/util/noPasswordManager.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/util/noPasswordManager.directive.js @@ -1,10 +1,10 @@ -/** +/** * @ngdoc directive * @name umbraco.directives.directive:noPasswordManager * @attribte * @function * @description -* Added attributes to block password manager elements should as LastPass +* Added attributes to tell password managers to ignore specific input fields and not inject elements via browser extensions. * @example * @@ -18,7 +18,9 @@ angular.module("umbraco.directives") return { restrict: 'A', link: function (scope, element, attrs) { - element.attr("data-lpignore", "true"); + element + .attr("data-lpignore", "true") // LastPass + .attr("data-1p-ignore", ""); // 1Password } } }); diff --git a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-content-header.html b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-content-header.html index 93d27b448c..5d69915583 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-content-header.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-content-header.html @@ -32,6 +32,7 @@ umb-auto-focus focus-on-filled="true" val-server-field="{{serverValidationNameField}}" + no-password-manager required aria-required="true" aria-invalid="{{contentForm.headerNameForm.headerName.$invalid ? true : false}}" diff --git a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html index 62e6a847e2..8c2ca91cc0 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/editor/umb-editor-header.html @@ -34,13 +34,12 @@
- Date: Mon, 31 Jul 2023 15:10:40 +0200 Subject: [PATCH 13/56] Allow Media Picker 3 to be used as macro parameter editor (#14594) --- .../PropertyEditors/MediaPicker3PropertyEditor.cs | 2 +- .../macroparameterpicker/macroparameterpicker.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index c2dedef852..0014a96e1f 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -20,7 +20,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// [DataEditor( Constants.PropertyEditors.Aliases.MediaPicker3, - EditorType.PropertyValue, + EditorType.PropertyValue | EditorType.MacroParameter, "Media Picker", "mediapicker3", ValueType = ValueTypes.Json, diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macroparameterpicker/macroparameterpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macroparameterpicker/macroparameterpicker.html index 9270b719c2..26248d392c 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macroparameterpicker/macroparameterpicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/macroparameterpicker/macroparameterpicker.html @@ -31,7 +31,7 @@
-
{{key}}
+
{{key | umbCmsTitleCase}}
  • @@ -55,7 +55,7 @@
    -
    {{result.group}}
    +
    {{result.group | umbCmsTitleCase}}
    • From 22820995777ab6d5bae27d409047cb3a0864934c Mon Sep 17 00:00:00 2001 From: Dhanesh Kumar Mj <58820887+dKumarmj@users.noreply.github.com> Date: Mon, 31 Jul 2023 19:12:21 +0530 Subject: [PATCH 14/56] considering id, key & name as filter params for content listview (#14514) --- .../Controllers/ContentController.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs index 5aab77ed47..1d899bb3fe 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs @@ -760,7 +760,6 @@ public class ContentController : ContentControllerBase { long totalChildren; List children; - // Sets the culture to the only existing culture if we only have one culture. if (string.IsNullOrWhiteSpace(cultureName)) { @@ -769,18 +768,19 @@ public class ContentController : ContentControllerBase cultureName = _allLangs.Value.First().Key; } } - if (pageNumber > 0 && pageSize > 0) { IQuery? queryFilter = null; if (filter.IsNullOrWhiteSpace() == false) { + int.TryParse(filter, out int filterAsIntId);//considering id,key & name as filter param + Guid.TryParse(filter, out Guid filterAsGuid); //add the default text filter queryFilter = _sqlContext.Query() .Where(x => x.Name != null) - .Where(x => x.Name!.Contains(filter)); + .Where(x => x.Name!.Contains(filter) + || x.Id == filterAsIntId || x.Key == filterAsGuid); } - children = _contentService .GetPagedChildren(id, pageNumber - 1, pageSize, out totalChildren, queryFilter, Ordering.By(orderBy, orderDirection, cultureName, !orderBySystemField)).ToList(); } From 88c35e97a26150c0820cc8606e67a93f46ce3c40 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Mon, 31 Jul 2023 20:12:09 +0200 Subject: [PATCH 15/56] Compile css for icons style (#14615) --- src/Umbraco.Web.UI.Client/gulp/config.js | 1 + src/Umbraco.Web.UI.Client/src/less/belle.less | 3 +-- src/Umbraco.Web.UI.Client/src/less/icons.less | 2 ++ .../gridblock/gridblock.editor.html | 17 ++--------------- .../gridinlineblock.editor.html | 18 ++---------------- .../gridsortblock/gridsortblock.editor.html | 16 +++------------- .../unsupportedblock.editor.html | 16 +--------------- .../propertyeditors/blockgrid/blockgridui.less | 1 + 8 files changed, 13 insertions(+), 61 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/less/icons.less diff --git a/src/Umbraco.Web.UI.Client/gulp/config.js b/src/Umbraco.Web.UI.Client/gulp/config.js index 50ec4b8d84..a2eb211266 100755 --- a/src/Umbraco.Web.UI.Client/gulp/config.js +++ b/src/Umbraco.Web.UI.Client/gulp/config.js @@ -36,6 +36,7 @@ module.exports = { preview: { files: "./src/less/canvas-designer.less", watch: "./src/less/**/*.less", out: "canvasdesigner.min.css" }, umbraco: { files: "./src/less/belle.less", watch: "./src/**/*.less", out: "umbraco.min.css" }, rteContent: { files: "./src/less/rte-content.less", watch: "./src/less/**/*.less", out: "rte-content.css" }, + icons: { files: "./src/less/icons.less", watch: "./src/less/**/*.less", out: "icons.css" }, blockgridui: { files: "./src/views/propertyeditors/blockgrid/blockgridui.less", watch: "./src/views/propertyeditors/blockgrid/blockgridui.less", out: "blockgridui.css" } }, diff --git a/src/Umbraco.Web.UI.Client/src/less/belle.less b/src/Umbraco.Web.UI.Client/src/less/belle.less index c01b99d710..95a207b01a 100644 --- a/src/Umbraco.Web.UI.Client/src/less/belle.less +++ b/src/Umbraco.Web.UI.Client/src/less/belle.less @@ -73,7 +73,7 @@ @import "modals.less"; @import "panel.less"; @import "sections.less"; -@import "helveticons.less"; +@import "icons.less"; @import "main.less"; @import "listview.less"; @import "gridview.less"; @@ -150,7 +150,6 @@ @import "components/umb-color-swatches.less"; @import "components/check-circle.less"; @import "components/umb-file-icon.less"; -@import "components/umb-icon.less"; @import "components/umb-iconpicker.less"; @import "components/umb-insert-code-box.less"; @import "components/umb-packages.less"; diff --git a/src/Umbraco.Web.UI.Client/src/less/icons.less b/src/Umbraco.Web.UI.Client/src/less/icons.less new file mode 100644 index 0000000000..1bb3cea19e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/less/icons.less @@ -0,0 +1,2 @@ +@import "helveticons.less"; +@import "components/umb-icon.less"; diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridblock/gridblock.editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridblock/gridblock.editor.html index bceef66e37..938ae384db 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridblock/gridblock.editor.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridblock/gridblock.editor.html @@ -1,18 +1,5 @@ + ${ model.stylesheet ? ` ` : '' } +
      + ng-include="api.internal.sortMode ? api.internal.sortModeView : '${model.view}'"> +
      `; $compile(shadowRoot)($scope); From b923c3252537468a7ecad2d394f37c0dbc7387ae Mon Sep 17 00:00:00 2001 From: Rasmus John Pedersen Date: Tue, 1 Aug 2023 15:20:22 +0200 Subject: [PATCH 21/56] fix: mark MultiUrlPickerValueConverter with DefaultValueConverter (#13347) Co-authored-by: georgebid <91198628+georgebid@users.noreply.github.com> --- .../ValueConverters/MultiUrlPickerValueConverter.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs index 18891003bc..dfc64bdad5 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MultiUrlPickerValueConverter.cs @@ -17,6 +17,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; +[DefaultPropertyValueConverter(typeof(JsonValueConverter))] public class MultiUrlPickerValueConverter : PropertyValueConverterBase, IDeliveryApiPropertyValueConverter { private readonly IJsonSerializer _jsonSerializer; From 8e8a09f1bad83d23beacda97aac020c0c64c3613 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Tue, 8 Aug 2023 23:44:34 +0200 Subject: [PATCH 22/56] Fix warning icon color in health check dashboard --- .../src/views/dashboard/settings/healthcheck.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/dashboard/settings/healthcheck.html b/src/Umbraco.Web.UI.Client/src/views/dashboard/settings/healthcheck.html index 15ac3111f6..d970959e0e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/dashboard/settings/healthcheck.html +++ b/src/Umbraco.Web.UI.Client/src/views/dashboard/settings/healthcheck.html @@ -105,7 +105,7 @@
      From 7537509ede430a82ad2ca49ef0709b6a64c8a0a3 Mon Sep 17 00:00:00 2001 From: Nikolaj Brask-Nielsen Date: Wed, 9 Aug 2023 17:30:28 +0200 Subject: [PATCH 23/56] feat: Show published state in tree picker (#14641) --- .../Models/Mapping/EntityMapDefinition.cs | 5 +++++ .../Search/UmbracoTreeSearcherFields.cs | 2 ++ .../src/less/components/tree/umb-tree.less | 3 +++ .../src/views/components/tree/umb-tree-search-results.html | 2 +- 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/Models/Mapping/EntityMapDefinition.cs b/src/Umbraco.Infrastructure/Models/Mapping/EntityMapDefinition.cs index 2ddb17bcfd..0116ec401d 100644 --- a/src/Umbraco.Infrastructure/Models/Mapping/EntityMapDefinition.cs +++ b/src/Umbraco.Infrastructure/Models/Mapping/EntityMapDefinition.cs @@ -276,6 +276,11 @@ public class EntityMapDefinition : IMapDefinition { target.AdditionalData.Add("contentType", source.Values[ExamineFieldNames.ItemTypeFieldName]); } + + if (source.Values.ContainsKey(UmbracoExamineFieldNames.PublishedFieldName)) + { + target.AdditionalData.Add("published", string.Equals(source.Values[UmbracoExamineFieldNames.PublishedFieldName], "y", StringComparison.InvariantCultureIgnoreCase)); + } } private static string? MapContentTypeIcon(IEntitySlim entity) diff --git a/src/Umbraco.Infrastructure/Search/UmbracoTreeSearcherFields.cs b/src/Umbraco.Infrastructure/Search/UmbracoTreeSearcherFields.cs index d0131399fe..8845e6e371 100644 --- a/src/Umbraco.Infrastructure/Search/UmbracoTreeSearcherFields.cs +++ b/src/Umbraco.Infrastructure/Search/UmbracoTreeSearcherFields.cs @@ -73,6 +73,8 @@ public class UmbracoTreeSearcherFields : IUmbracoTreeSearcherFields fields.Add(field); } + fields.Add(UmbracoExamineFieldNames.PublishedFieldName); + return fields; } } diff --git a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less index 1f61b7cfc2..4dd0a56b3f 100644 --- a/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less +++ b/src/Umbraco.Web.UI.Client/src/less/components/tree/umb-tree.less @@ -257,6 +257,9 @@ body.touch .umb-tree { > .umb-tree-item__inner > a { opacity: 0.6; } + &.umb-search-group-item { + opacity: 0.6; + } } .not-allowed { diff --git a/src/Umbraco.Web.UI.Client/src/views/components/tree/umb-tree-search-results.html b/src/Umbraco.Web.UI.Client/src/views/components/tree/umb-tree-search-results.html index f0b4af8dd2..97b3f6824f 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/tree/umb-tree-search-results.html +++ b/src/Umbraco.Web.UI.Client/src/views/components/tree/umb-tree-search-results.html @@ -7,7 +7,7 @@
        • -
        • +
      @@ -72,7 +78,7 @@
    • + ng-class="{'-hidden': ((compositeContentType.allowed === false && !compositeContentType.selected) || compositeContentType.inherited) && vm.hideUnavailable, '-disabled': (compositeContentType.allowed === false && !compositeContentType.selected) || compositeContentType.inherited, '-selected': compositeContentType.selected}">
      Date: Wed, 16 Aug 2023 23:37:10 +0200 Subject: [PATCH 28/56] Fix userid zero in integration tests (#14639) * test: Fix invalid user ids * feat: Update parameter defaults with constants --- .../Services/ContentVersionService.cs | 2 +- .../Services/MediaServiceExtensions.cs | 4 +- .../UmbracoIntegrationTestWithContent.cs | 10 +- .../Packaging/PackageDataInstallationTests.cs | 108 +++++++++--------- .../Repositories/DocumentRepositoryTest.cs | 8 +- .../Repositories/MediaRepositoryTest.cs | 6 +- .../Repositories/RelationRepositoryTest.cs | 8 +- .../Services/ContentServicePerformanceTest.cs | 12 +- .../Services/ContentServiceTests.cs | 12 +- .../Services/ContentTypeServiceTests.cs | 6 +- .../Services/EntityServiceTests.cs | 18 +-- .../Services/LocalizationServiceTests.cs | 8 +- 12 files changed, 101 insertions(+), 101 deletions(-) diff --git a/src/Umbraco.Core/Services/ContentVersionService.cs b/src/Umbraco.Core/Services/ContentVersionService.cs index f0c92db28f..6f1c28deb8 100644 --- a/src/Umbraco.Core/Services/ContentVersionService.cs +++ b/src/Umbraco.Core/Services/ContentVersionService.cs @@ -66,7 +66,7 @@ internal class ContentVersionService : IContentVersionService } /// - public void SetPreventCleanup(int versionId, bool preventCleanup, int userId = -1) + public void SetPreventCleanup(int versionId, bool preventCleanup, int userId = Constants.Security.SuperUserId) { using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) { diff --git a/src/Umbraco.Core/Services/MediaServiceExtensions.cs b/src/Umbraco.Core/Services/MediaServiceExtensions.cs index 8d45367e61..e7d01f122e 100644 --- a/src/Umbraco.Core/Services/MediaServiceExtensions.cs +++ b/src/Umbraco.Core/Services/MediaServiceExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Umbraco. +// Copyright (c) Umbraco. // See LICENSE for more details. using Umbraco.Cms.Core; @@ -34,7 +34,7 @@ public static class MediaServiceExtensions return mediaService.GetByIds(guids.Select(x => x.Guid)); } - public static IMedia CreateMedia(this IMediaService mediaService, string name, Udi parentId, string mediaTypeAlias, int userId = 0) + public static IMedia CreateMedia(this IMediaService mediaService, string name, Udi parentId, string mediaTypeAlias, int userId = Constants.Security.SuperUserId) { if (parentId is not GuidUdi guidUdi) { diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs index 274f93125c..37673b1c2e 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContent.cs @@ -46,24 +46,24 @@ public abstract class UmbracoIntegrationTestWithContent : UmbracoIntegrationTest // Create and Save Content "Homepage" based on "umbTextpage" -> 1053 Textpage = ContentBuilder.CreateSimpleContent(ContentType); Textpage.Key = new Guid("B58B3AD4-62C2-4E27-B1BE-837BD7C533E0"); - ContentService.Save(Textpage, 0); + ContentService.Save(Textpage, -1); // Create and Save Content "Text Page 1" based on "umbTextpage" -> 1054 Subpage = ContentBuilder.CreateSimpleContent(ContentType, "Text Page 1", Textpage.Id); var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null); - ContentService.Save(Subpage, 0, contentSchedule); + ContentService.Save(Subpage, -1, contentSchedule); // Create and Save Content "Text Page 1" based on "umbTextpage" -> 1055 Subpage2 = ContentBuilder.CreateSimpleContent(ContentType, "Text Page 2", Textpage.Id); - ContentService.Save(Subpage2, 0); + ContentService.Save(Subpage2, -1); Subpage3 = ContentBuilder.CreateSimpleContent(ContentType, "Text Page 3", Textpage.Id); - ContentService.Save(Subpage3, 0); + ContentService.Save(Subpage3, -1); // Create and Save Content "Text Page Deleted" based on "umbTextpage" -> 1056 Trashed = ContentBuilder.CreateSimpleContent(ContentType, "Text Page Deleted", -20); Trashed.Trashed = true; - ContentService.Save(Trashed, 0); + ContentService.Save(Trashed, -1); } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs index 765eea85c6..7eb0d07da9 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs @@ -90,9 +90,9 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var docTypeElement = xml.Descendants("DocumentTypes").First(); // Act - var dataTypes = PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), 0); - var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), 0); - var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), 0); + var dataTypes = PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), -1); + var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), -1); + var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), -1); var numberOfTemplates = (from doc in templateElement.Elements("Template") select doc).Count(); var numberOfDocTypes = (from doc in docTypeElement.Elements("DocumentType") select doc).Count(); @@ -137,9 +137,9 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var docTypeElement = xml.Descendants("DocumentTypes").First(); // Act - var dataTypes = PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), 0); - var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), 0); - var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), 0); + var dataTypes = PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), -1); + var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), -1); + var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), -1); // Assert var mRBasePage = contentTypes.First(x => x.Alias == "MRBasePage"); @@ -163,9 +163,9 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var docTypeElement = xml.Descendants("DocumentTypes").First(); // Act - var dataTypes = PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), 0); - var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), 0); - var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), 0); + var dataTypes = PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), -1); + var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), -1); + var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), -1); var numberOfDocTypes = (from doc in docTypeElement.Elements("DocumentType") select doc).Count(); @@ -202,7 +202,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var init = FileService.GetTemplates().Count(); // Act - var templates = PackageDataInstallation.ImportTemplates(element.Elements("Template").ToList(), 0); + var templates = PackageDataInstallation.ImportTemplates(element.Elements("Template").ToList(), -1); var numberOfTemplates = (from doc in element.Elements("Template") select doc).Count(); var allTemplates = FileService.GetTemplates(); @@ -224,7 +224,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var element = xml.Descendants("Templates").First(); // Act - var templates = PackageDataInstallation.ImportTemplate(element.Elements("Template").First(), 0); + var templates = PackageDataInstallation.ImportTemplate(element.Elements("Template").First(), -1); // Assert Assert.That(templates, Is.Not.Null); @@ -248,7 +248,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent firstTemplateElement.Add(new XElement("Key", key)); // Act - var templates = PackageDataInstallation.ImportTemplate(firstTemplateElement, 0); + var templates = PackageDataInstallation.ImportTemplate(firstTemplateElement, -1); // Assert Assert.That(templates, Is.Not.Null); @@ -272,9 +272,9 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var dataTypeDefinitions = - PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), 0); - var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), 0); - var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), 0); + PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), -1); + var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), -1); + var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), -1); var numberOfDocTypes = (from doc in docTypeElement.Elements("DocumentType") select doc).Count(); // Assert @@ -310,14 +310,14 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var dataTypeDefinitions = - PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), 0); - var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), 0); - var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), 0); + PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), -1); + var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), -1); + var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), -1); var numberOfDocTypes = (from doc in docTypeElement.Elements("DocumentType") select doc).Count(); // Assert - Re-Import contenttypes doesn't throw Assert.DoesNotThrow(() => - PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), 0)); + PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), -1)); Assert.That(contentTypes.Count(), Is.EqualTo(numberOfDocTypes)); Assert.That(dataTypeDefinitions, Is.Not.Null); Assert.That(dataTypeDefinitions.Any(), Is.True); @@ -336,14 +336,14 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var dataTypeDefinitions = - PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), 0); - var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), 0); - var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), 0); + PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), -1); + var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), -1); + var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), -1); var numberOfDocTypes = (from doc in docTypeElement.Elements("DocumentType") select doc).Count(); // Assert - Re-Import contenttypes doesn't throw Assert.DoesNotThrow(() => - PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), 0)); + PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), -1)); Assert.That(contentTypes.Count(), Is.EqualTo(numberOfDocTypes)); Assert.That(dataTypeDefinitions, Is.Not.Null); Assert.That(dataTypeDefinitions.Any(), Is.True); @@ -363,10 +363,10 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var dataTypeDefinitions = - PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), 0); - var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypesElement.Elements("DocumentType"), 0); + PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), -1); + var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypesElement.Elements("DocumentType"), -1); var importedContentTypes = contentTypes.ToDictionary(x => x.Alias, x => x); - var contents = PackageDataInstallation.ImportContentBase(packageDocument.Yield(), importedContentTypes, 0, ContentTypeService, ContentService); + var contents = PackageDataInstallation.ImportContentBase(packageDocument.Yield(), importedContentTypes, -1, ContentTypeService, ContentService); var numberOfDocs = (from doc in element.Descendants() where (string)doc.Attribute("isDoc") == string.Empty select doc).Count(); @@ -390,9 +390,9 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var packageMedia = CompiledPackageContentBase.Create(element); // Act - var mediaTypes = PackageDataInstallation.ImportMediaTypes(mediaTypesElement.Elements("MediaType"), 0); + var mediaTypes = PackageDataInstallation.ImportMediaTypes(mediaTypesElement.Elements("MediaType"), -1); var importedMediaTypes = mediaTypes.ToDictionary(x => x.Alias, x => x); - var medias = PackageDataInstallation.ImportContentBase(packageMedia.Yield(), importedMediaTypes, 0, MediaTypeService, MediaService); + var medias = PackageDataInstallation.ImportContentBase(packageMedia.Yield(), importedMediaTypes, -1, MediaTypeService, MediaService); var numberOfDocs = (from doc in element.Descendants() where (string)doc.Attribute("isDoc") == string.Empty select doc).Count(); @@ -419,10 +419,10 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var dataTypeDefinitions = - PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), 0); - var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypesElement.Elements("DocumentType"), 0); + PackageDataInstallation.ImportDataTypes(dataTypeElement.Elements("DataType").ToList(), -1); + var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypesElement.Elements("DocumentType"), -1); var importedContentTypes = contentTypes.ToDictionary(x => x.Alias, x => x); - var contents = PackageDataInstallation.ImportContentBase(packageDocument.Yield(), importedContentTypes, 0, ContentTypeService, ContentService); + var contents = PackageDataInstallation.ImportContentBase(packageDocument.Yield(), importedContentTypes, -1, ContentTypeService, ContentService); var numberOfDocs = (from doc in element.Descendants() where (string)doc.Attribute("isDoc") == string.Empty select doc).Count(); @@ -456,7 +456,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var templateElement = xml.Descendants("Templates").First(); // Act - var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), 0); + var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), -1); var numberOfTemplates = (from doc in templateElement.Elements("Template") select doc).Count(); // Assert @@ -472,7 +472,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var docTypeElement = XElement.Parse(strXml); // Act - var contentTypes = PackageDataInstallation.ImportDocumentType(docTypeElement, 0); + var contentTypes = PackageDataInstallation.ImportDocumentType(docTypeElement, -1); // Assert Assert.That(contentTypes.Any(), Is.True); @@ -490,7 +490,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var serializer = GetRequiredService(); // Act - var contentTypes = PackageDataInstallation.ImportDocumentType(docTypeElement, 0); + var contentTypes = PackageDataInstallation.ImportDocumentType(docTypeElement, -1); var contentType = contentTypes.FirstOrDefault(); var element = serializer.Serialize(contentType); @@ -513,8 +513,8 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var docTypeElement = XElement.Parse(strXml); // Act - var contentTypes = PackageDataInstallation.ImportDocumentType(docTypeElement, 0); - var contentTypesUpdated = PackageDataInstallation.ImportDocumentType(docTypeElement, 0); + var contentTypes = PackageDataInstallation.ImportDocumentType(docTypeElement, -1); + var contentTypesUpdated = PackageDataInstallation.ImportDocumentType(docTypeElement, -1); // Assert Assert.That(contentTypes.Any(), Is.True); @@ -544,9 +544,9 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var numberOfTemplates = (from doc in templateElement.Elements("Template") select doc).Count(); - var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), 0); + var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), -1); var templatesAfterUpdate = - PackageDataInstallation.ImportTemplates(templateElementUpdated.Elements("Template").ToList(), 0); + PackageDataInstallation.ImportTemplates(templateElementUpdated.Elements("Template").ToList(), -1); var allTemplates = fileService.GetTemplates(); // Assert @@ -572,7 +572,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent AddLanguages(); // Act - PackageDataInstallation.ImportDictionaryItems(dictionaryItemsElement.Elements("DictionaryItem"), 0); + PackageDataInstallation.ImportDictionaryItems(dictionaryItemsElement.Elements("DictionaryItem"), -1); // Assert AssertDictionaryItem("Parent", expectedEnglishParentValue, "en-GB"); @@ -595,7 +595,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var dictionaryItems = - PackageDataInstallation.ImportDictionaryItems(dictionaryItemsElement.Elements("DictionaryItem"), 0); + PackageDataInstallation.ImportDictionaryItems(dictionaryItemsElement.Elements("DictionaryItem"), -1); // Assert Assert.That(LocalizationService.DictionaryItemExists(parentKey), "DictionaryItem parentKey does not exist"); @@ -624,7 +624,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent AddExistingEnglishAndNorwegianParentDictionaryItem(expectedEnglishParentValue, expectedNorwegianParentValue); // Act - PackageDataInstallation.ImportDictionaryItems(dictionaryItemsElement.Elements("DictionaryItem"), 0); + PackageDataInstallation.ImportDictionaryItems(dictionaryItemsElement.Elements("DictionaryItem"), -1); // Assert AssertDictionaryItem("Parent", expectedEnglishParentValue, "en-GB"); @@ -649,7 +649,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent AddExistingEnglishParentDictionaryItem(expectedEnglishParentValue); // Act - PackageDataInstallation.ImportDictionaryItems(dictionaryItemsElement.Elements("DictionaryItem"), 0); + PackageDataInstallation.ImportDictionaryItems(dictionaryItemsElement.Elements("DictionaryItem"), -1); // Assert AssertDictionaryItem("Parent", expectedEnglishParentValue, "en-GB"); @@ -666,7 +666,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var languageItemsElement = newPackageXml.Elements("Languages").First(); // Act - var languages = PackageDataInstallation.ImportLanguages(languageItemsElement.Elements("Language"), 0); + var languages = PackageDataInstallation.ImportLanguages(languageItemsElement.Elements("Language"), -1); var allLanguages = LocalizationService.GetAllLanguages(); // Assert @@ -688,7 +688,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var macros = PackageDataInstallation.ImportMacros( macrosElement.Elements("macro"), - 0).ToList(); + -1).ToList(); // Assert Assert.That(macros.Any(), Is.True); @@ -711,7 +711,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var macros = PackageDataInstallation.ImportMacros( macrosElement.Elements("macro"), - 0).ToList(); + -1).ToList(); // Assert Assert.That(macros.Any(), Is.True); @@ -734,8 +734,8 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var docTypeElement = xml.Descendants("DocumentTypes").First(); // Act - var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), 0); - var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), 0); + var templates = PackageDataInstallation.ImportTemplates(templateElement.Elements("Template").ToList(), -1); + var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), -1); var numberOfDocTypes = (from doc in docTypeElement.Elements("DocumentType") select doc).Count(); // Assert @@ -761,7 +761,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent var docTypeElement = xml.Descendants("DocumentTypes").First(); // Act - var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), 0); + var contentTypes = PackageDataInstallation.ImportDocumentTypes(docTypeElement.Elements("DocumentType"), -1); var numberOfDocTypes = (from doc in docTypeElement.Elements("DocumentType") select doc).Count(); // Assert @@ -784,7 +784,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var contentTypes = PackageDataInstallation - .ImportDocumentType(withoutCleanupPolicy, 0) + .ImportDocumentType(withoutCleanupPolicy, -1) .OfType(); // Assert @@ -803,7 +803,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var contentTypes = PackageDataInstallation - .ImportDocumentType(docTypeElement, 0) + .ImportDocumentType(docTypeElement, -1) .OfType(); // Assert @@ -825,11 +825,11 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent // Act var contentTypes = PackageDataInstallation - .ImportDocumentType(withCleanupPolicy, 0) + .ImportDocumentType(withCleanupPolicy, -1) .OfType(); var contentTypesUpdated = PackageDataInstallation - .ImportDocumentType(withoutCleanupPolicy, 0) + .ImportDocumentType(withoutCleanupPolicy, -1) .OfType(); // Assert @@ -851,8 +851,8 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent { var norwegian = new Language("nb-NO", "Norwegian Bokmål (Norway)"); var english = new Language("en-GB", "English (United Kingdom)"); - LocalizationService.Save(norwegian, 0); - LocalizationService.Save(english, 0); + LocalizationService.Save(norwegian, -1); + LocalizationService.Save(english, -1); } private void AssertDictionaryItem(string dictionaryItemName, string expectedValue, string cultureCode) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentRepositoryTest.cs index 51a1a92376..7ee763e863 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DocumentRepositoryTest.cs @@ -76,21 +76,21 @@ public class DocumentRepositoryTest : UmbracoIntegrationTest // Create and Save Content "Homepage" based on "umbTextpage" -> (_textpage.Id) _textpage = ContentBuilder.CreateSimpleContent(_contentType); _textpage.Key = new Guid("B58B3AD4-62C2-4E27-B1BE-837BD7C533E0"); - ContentService.Save(_textpage, 0); + ContentService.Save(_textpage, -1); // Create and Save Content "Text Page 1" based on "umbTextpage" -> (_subpage.Id) _subpage = ContentBuilder.CreateSimpleContent(_contentType, "Text Page 1", _textpage.Id); _subpage.Key = new Guid("FF11402B-7E53-4654-81A7-462AC2108059"); - ContentService.Save(_subpage, 0); + ContentService.Save(_subpage, -1); // Create and Save Content "Text Page 1" based on "umbTextpage" -> (_subpage2.Id) _subpage2 = ContentBuilder.CreateSimpleContent(_contentType, "Text Page 2", _textpage.Id); - ContentService.Save(_subpage2, 0); + ContentService.Save(_subpage2, -1); // Create and Save Content "Text Page Deleted" based on "umbTextpage" -> (_trashed.Id) _trashed = ContentBuilder.CreateSimpleContent(_contentType, "Text Page Deleted", -20); _trashed.Trashed = true; - ContentService.Save(_trashed, 0); + ContentService.Save(_trashed, -1); } private DocumentRepository CreateRepository(IScopeAccessor scopeAccessor, out ContentTypeRepository contentTypeRepository, out DataTypeRepository dtdRepository, AppCaches appCaches = null) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/MediaRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/MediaRepositoryTest.cs index ce60146635..595e881f28 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/MediaRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/MediaRepositoryTest.cs @@ -585,16 +585,16 @@ public class MediaRepositoryTest : UmbracoIntegrationTest // Create and Save folder-Media -> (1051) var folderMediaType = MediaTypeService.Get(1031); _testFolder = MediaBuilder.CreateMediaFolder(folderMediaType, -1); - MediaService.Save(_testFolder, 0); + MediaService.Save(_testFolder, -1); // Create and Save image-Media -> (1052) var imageMediaType = MediaTypeService.Get(1032); _testImage = MediaBuilder.CreateMediaImage(imageMediaType, _testFolder.Id); - MediaService.Save(_testImage, 0); + MediaService.Save(_testImage, -1); // Create and Save file-Media -> (1053) var fileMediaType = MediaTypeService.Get(1033); _testFile = MediaBuilder.CreateMediaFile(fileMediaType, _testFolder.Id); - MediaService.Save(_testFile, 0); + MediaService.Save(_testFile, -1); } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/RelationRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/RelationRepositoryTest.cs index 402aa0b653..def5d3cafc 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/RelationRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/RelationRepositoryTest.cs @@ -526,7 +526,7 @@ public class RelationRepositoryTest : UmbracoIntegrationTest var repository = CreateRepository(ScopeProvider, out var repositoryType); var content = ContentService.GetById(_subpage.Id); - ContentService.Delete(content, 0); + ContentService.Delete(content, -1); // Act var shouldntExist = repository.Exists(1); @@ -577,15 +577,15 @@ public class RelationRepositoryTest : UmbracoIntegrationTest // Create and Save Content "Homepage" based on "umbTextpage" -> (NodeDto.NodeIdSeed + 1) _textpage = ContentBuilder.CreateSimpleContent(_contentType); - ContentService.Save(_textpage, 0); + ContentService.Save(_textpage, -1); // Create and Save Content "Text Page 1" based on "umbTextpage" -> (NodeDto.NodeIdSeed + 2) _subpage = ContentBuilder.CreateSimpleContent(_contentType, "Text Page 1", _textpage.Id); - ContentService.Save(_subpage, 0); + ContentService.Save(_subpage, -1); // Create and Save Content "Text Page 1" based on "umbTextpage" -> (NodeDto.NodeIdSeed + 3) _subpage2 = ContentBuilder.CreateSimpleContent(_contentType, "Text Page 2", _textpage.Id); - ContentService.Save(_subpage2, 0); + ContentService.Save(_subpage2, -1); _relation = new Relation(_textpage.Id, _subpage.Id, _relateContent) { Comment = string.Empty }; _relation2 = new Relation(_textpage.Id, _subpage2.Id, _relateContent) { Comment = string.Empty }; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePerformanceTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePerformanceTest.cs index b2507bf4f5..54cb06ef04 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePerformanceTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePerformanceTest.cs @@ -120,7 +120,7 @@ public class ContentServicePerformanceTest : UmbracoIntegrationTest // Act var watch = Stopwatch.StartNew(); - ContentService.Save(pages, 0); + ContentService.Save(pages, -1); watch.Stop(); var elapsed = watch.ElapsedMilliseconds; @@ -139,7 +139,7 @@ public class ContentServicePerformanceTest : UmbracoIntegrationTest // Act var watch = Stopwatch.StartNew(); - ContentService.Save(pages, 0); + ContentService.Save(pages, -1); watch.Stop(); var elapsed = watch.ElapsedMilliseconds; @@ -155,7 +155,7 @@ public class ContentServicePerformanceTest : UmbracoIntegrationTest // Arrange var contentType = ContentTypeService.Get(ContentType.Id); var pages = ContentBuilder.CreateTextpageContent(contentType, -1, 100); - ContentService.Save(pages, 0); + ContentService.Save(pages, -1); var provider = ScopeProvider; using (var scope = provider.CreateScope()) @@ -182,7 +182,7 @@ public class ContentServicePerformanceTest : UmbracoIntegrationTest // Arrange var contentType = ContentTypeService.Get(ContentType.Id); var pages = ContentBuilder.CreateTextpageContent(contentType, -1, 1000); - ContentService.Save(pages, 0); + ContentService.Save(pages, -1); using (var scope = ScopeProvider.CreateScope()) { @@ -208,7 +208,7 @@ public class ContentServicePerformanceTest : UmbracoIntegrationTest // Arrange var contentType = ContentTypeService.Get(ContentType.Id); var pages = ContentBuilder.CreateTextpageContent(contentType, -1, 100); - ContentService.Save(pages, 0); + ContentService.Save(pages, -1); using (var scope = ScopeProvider.CreateScope()) { @@ -237,7 +237,7 @@ public class ContentServicePerformanceTest : UmbracoIntegrationTest // Arrange var contentType = ContentTypeService.Get(ContentType.Id); var pages = ContentBuilder.CreateTextpageContent(contentType, -1, 1000); - ContentService.Save(pages, 0); + ContentService.Save(pages, -1); using (var scope = ScopeProvider.CreateScope()) { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTests.cs index f399a00a3d..0f399f771d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTests.cs @@ -360,7 +360,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent var results = new List(); for (var i = 0; i < 20; i++) { - results.Add(ContentService.CreateAndSave("Test", Constants.System.Root, "umbTextpage", 0)); + results.Add(ContentService.CreateAndSave("Test", Constants.System.Root, "umbTextpage", -1)); } var sortedGet = ContentService.GetByIds(new[] { results[10].Id, results[5].Id, results[12].Id }) @@ -706,10 +706,10 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent { // Arrange var content = ContentService.GetById(Textpage.Id); - var published = ContentService.SaveAndPublish(content, userId: 0); + var published = ContentService.SaveAndPublish(content, userId: -1); // Act - var unpublished = ContentService.Unpublish(content, userId: 0); + var unpublished = ContentService.Unpublish(content, userId: -1); // Assert Assert.That(published.Success, Is.True); @@ -1082,7 +1082,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent var content = ContentService.GetById(Textpage.Id); // Act - var published = ContentService.SaveAndPublish(content, userId: 0); + var published = ContentService.SaveAndPublish(content, userId: -1); // Assert Assert.That(published.Success, Is.True); @@ -1965,12 +1965,12 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent content1.PropertyValues(obj); content1.ResetDirtyProperties(false); ContentService.Save(content1); - Assert.IsTrue(ContentService.SaveAndPublish(content1, userId: 0).Success); + Assert.IsTrue(ContentService.SaveAndPublish(content1, userId: -1).Success); var content2 = ContentBuilder.CreateBasicContent(contentType); content2.PropertyValues(obj); content2.ResetDirtyProperties(false); ContentService.Save(content2); - Assert.IsTrue(ContentService.SaveAndPublish(content2, userId: 0).Success); + Assert.IsTrue(ContentService.SaveAndPublish(content2, userId: -1).Success); var editorGroup = UserService.GetUserGroupByAlias(Constants.Security.EditorGroupAlias); editorGroup.StartContentId = content1.Id; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentTypeServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentTypeServiceTests.cs index bb24410e7b..056f65947e 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentTypeServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentTypeServiceTests.cs @@ -262,7 +262,7 @@ public class ContentTypeServiceTests : UmbracoIntegrationTest // Arrange var contentTypeService = ContentTypeService; var hierarchy = CreateContentTypeHierarchy(); - contentTypeService.Save(hierarchy, 0); // ensure they are saved! + contentTypeService.Save(hierarchy, -1); // ensure they are saved! var master = hierarchy.First(); // Act @@ -278,7 +278,7 @@ public class ContentTypeServiceTests : UmbracoIntegrationTest // Arrange var contentTypeService = ContentTypeService; var hierarchy = CreateContentTypeHierarchy(); - contentTypeService.Save(hierarchy, 0); // ensure they are saved! + contentTypeService.Save(hierarchy, -1); // ensure they are saved! var master = hierarchy.First(); // Act @@ -296,7 +296,7 @@ public class ContentTypeServiceTests : UmbracoIntegrationTest var hierarchy = CreateContentTypeHierarchy(); // Act - contentTypeService.Save(hierarchy, 0); + contentTypeService.Save(hierarchy, -1); Assert.That(hierarchy.Any(), Is.True); Assert.That(hierarchy.Any(x => x.HasIdentity == false), Is.False); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs index f19f86a6fc..f1b47e81c4 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs @@ -898,45 +898,45 @@ public class EntityServiceTests : UmbracoIntegrationTest // Create and Save Content "Homepage" based on "umbTextpage" -> 1053 _textpage = ContentBuilder.CreateSimpleContent(_contentType); _textpage.Key = new Guid("B58B3AD4-62C2-4E27-B1BE-837BD7C533E0"); - ContentService.Save(_textpage, 0); + ContentService.Save(_textpage, -1); // Create and Save Content "Text Page 1" based on "umbTextpage" -> 1054 _subpage = ContentBuilder.CreateSimpleContent(_contentType, "Text Page 1", _textpage.Id); var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null); - ContentService.Save(_subpage, 0, contentSchedule); + ContentService.Save(_subpage, -1, contentSchedule); // Create and Save Content "Text Page 2" based on "umbTextpage" -> 1055 _subpage2 = ContentBuilder.CreateSimpleContent(_contentType, "Text Page 2", _textpage.Id); - ContentService.Save(_subpage2, 0); + ContentService.Save(_subpage2, -1); // Create and Save Content "Text Page Deleted" based on "umbTextpage" -> 1056 _trashed = ContentBuilder.CreateSimpleContent(_contentType, "Text Page Deleted", -20); _trashed.Trashed = true; - ContentService.Save(_trashed, 0); + ContentService.Save(_trashed, -1); // Create and Save folder-Media -> 1057 _folderMediaType = MediaTypeService.Get(1031); _folder = MediaBuilder.CreateMediaFolder(_folderMediaType, -1); - MediaService.Save(_folder, 0); + MediaService.Save(_folder, -1); _folderId = _folder.Id; // Create and Save image-Media -> 1058 _imageMediaType = MediaTypeService.Get(1032); _image = MediaBuilder.CreateMediaImage(_imageMediaType, _folder.Id); - MediaService.Save(_image, 0); + MediaService.Save(_image, -1); // Create and Save file-Media -> 1059 var fileMediaType = MediaTypeService.Get(1033); var file = MediaBuilder.CreateMediaFile(fileMediaType, _folder.Id); - MediaService.Save(file, 0); + MediaService.Save(file, -1); // Create and save sub folder -> 1060 _subfolder = MediaBuilder.CreateMediaFolder(_folderMediaType, _folder.Id); - MediaService.Save(_subfolder, 0); + MediaService.Save(_subfolder, -1); // Create and save sub folder -> 1061 _subfolder2 = MediaBuilder.CreateMediaFolder(_folderMediaType, _subfolder.Id); - MediaService.Save(_subfolder2, 0); + MediaService.Save(_subfolder2, -1); } } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LocalizationServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LocalizationServiceTests.cs index 4924ee51f7..14b24b434c 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LocalizationServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LocalizationServiceTests.cs @@ -209,7 +209,7 @@ public class LocalizationServiceTests : UmbracoIntegrationTest var languageNbNo = new LanguageBuilder() .WithCultureInfo("nb-NO") .Build(); - LocalizationService.Save(languageNbNo, 0); + LocalizationService.Save(languageNbNo, -1); Assert.That(languageNbNo.HasIdentity, Is.True); var languageId = languageNbNo.Id; @@ -227,7 +227,7 @@ public class LocalizationServiceTests : UmbracoIntegrationTest .WithCultureInfo("nb-NO") .WithFallbackLanguageId(languageDaDk.Id) .Build(); - LocalizationService.Save(languageNbNo, 0); + LocalizationService.Save(languageNbNo, -1); var languageId = languageDaDk.Id; LocalizationService.Delete(languageDaDk); @@ -445,8 +445,8 @@ public class LocalizationServiceTests : UmbracoIntegrationTest .WithCultureInfo("en-GB") .Build(); - LocalizationService.Save(languageDaDk, 0); - LocalizationService.Save(languageEnGb, 0); + LocalizationService.Save(languageDaDk, -1); + LocalizationService.Save(languageEnGb, -1); _danishLangId = languageDaDk.Id; _englishLangId = languageEnGb.Id; From 685b8674568a5cc14872fa50d99998795b6fcaef Mon Sep 17 00:00:00 2001 From: Nikolaj Brask-Nielsen Date: Fri, 11 Aug 2023 22:32:32 +0200 Subject: [PATCH 29/56] fix: Pass correct user id to Audit log --- src/Umbraco.Core/Services/ContentService.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 7111b2aca2..7a72bb1f8d 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -1097,7 +1097,8 @@ public class ContentService : RepositoryService, IContentService scope.Notifications.Publish( new ContentTreeChangeNotification(contentsA, TreeChangeTypes.RefreshNode, eventMessages)); - Audit(AuditType.Save, userId == -1 ? 0 : userId, Constants.System.Root, "Saved multiple content"); + string contentIds = string.Join(", ", contentsA.Select(x => x.Id)); + Audit(AuditType.Save, userId, Constants.System.Root, $"Saved multiple content items ({contentIds})"); scope.Complete(); } @@ -2820,7 +2821,7 @@ public class ContentService : RepositoryService, IContentService } else { - Audit(AuditType.SendToPublish, content.WriterId, content.Id); + Audit(AuditType.SendToPublish, userId, content.Id); } return saveResult.Success; @@ -3562,7 +3563,7 @@ public class ContentService : RepositoryService, IContentService _documentBlueprintRepository.Save(content); - Audit(AuditType.Save, Constants.Security.SuperUserId, content.Id, $"Saved content template: {content.Name}"); + Audit(AuditType.Save, userId, content.Id, $"Saved content template: {content.Name}"); scope.Notifications.Publish(new ContentSavedBlueprintNotification(content, evtMsgs)); From d7cbd9bf619ef22aeb2d7fa6bd9b94f1e8478e0b Mon Sep 17 00:00:00 2001 From: Nikolaj Brask-Nielsen Date: Thu, 17 Aug 2023 06:49:01 +0200 Subject: [PATCH 30/56] fix: Translations keys from #12776 --- src/Umbraco.Core/EmbeddedResources/Lang/da.xml | 2 +- src/Umbraco.Core/EmbeddedResources/Lang/en.xml | 2 +- src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml | 2 +- .../views/common/infiniteeditors/compositions/compositions.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml index 1c41c1f9a2..a75e0f0a43 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml @@ -56,7 +56,7 @@ Opret indholdsskabelon Gensend invitation Standardværdi - Skjul utilgængelige kompositioner + Skjul utilgængelige kompositioner Indhold diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 30b8a6b6a7..895cff0953 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -55,7 +55,7 @@ Unlock Create Content Template Resend Invitation - Hide unavailable options + Hide unavailable options Content diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index 51890ed834..d67f79fde8 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -57,7 +57,7 @@ Unlock Create Content Template Resend Invitation - Hide unavailable options + Hide unavailable options Content diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/compositions/compositions.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/compositions/compositions.html index 926305e2de..18d8e8d7ea 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/compositions/compositions.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/compositions/compositions.html @@ -37,7 +37,7 @@ From b1e42e334d58551bd85c50a5116d78ef6be1587a Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Mon, 21 Aug 2023 12:24:17 +0200 Subject: [PATCH 31/56] Move to Minimal Hosting Model in a backwards compatible way (#14656) * Use minimal hosting model * Make CoreRuntime backward compatible to the old hosting model * Remove unneccessary methods from interface again * Pushed the timeout for E2E test to 120 minutes instead of 60 * Updated the preview version from 6 to 7 * Explicitly call BootUmbracoAsync * Add CreateUmbracoBuilder extension method * Do not add IRuntime as hosted service when using WebApplication/WebApplicationBuilder * Set StaticServiceProvider.Instance before booting * Ensure Umbraco is booted and StaticServiceProvider.Instance is set before configuring middleware * Do not enable static web assets on production environments * Removed root namespace from viewImports --------- Co-authored-by: Andreas Zerbst Co-authored-by: Ronald Barendse --- build/azure-pipelines.yml | 1 + .../Runtime/CoreRuntime.cs | 44 +++++------- .../UmbracoBuilderExtensions.cs | 2 - .../ApplicationBuilderExtensions.cs | 18 ++++- .../WebApplicationBuilderExtensions.cs | 36 ++++++++++ .../Extensions/WebApplicationExtensions.cs | 31 ++++++++ .../Hosting/HostBuilderExtensions.cs | 13 +++- src/Umbraco.Web.UI/Program.cs | 51 +++++++++----- src/Umbraco.Web.UI/Startup.cs | 70 ------------------- src/Umbraco.Web.UI/Views/_ViewImports.cshtml | 1 - templates/Umbraco.Templates.csproj | 4 -- .../misc/umbraco-linux.docker | 2 +- 12 files changed, 149 insertions(+), 124 deletions(-) create mode 100644 src/Umbraco.Web.Common/Extensions/WebApplicationBuilderExtensions.cs create mode 100644 src/Umbraco.Web.Common/Extensions/WebApplicationExtensions.cs delete mode 100644 src/Umbraco.Web.UI/Startup.cs diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 0068b4d0eb..386b08a41d 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -335,6 +335,7 @@ stages: # E2E Tests - job: displayName: E2E Tests + timeoutInMinutes: 120 variables: Umbraco__CMS__Unattended__UnattendedUserName: Playwright Test Umbraco__CMS__Unattended__UnattendedUserPassword: UmbracoAcceptance123! diff --git a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs index 19c947481d..81929290cd 100644 --- a/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs +++ b/src/Umbraco.Infrastructure/Runtime/CoreRuntime.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Hosting; @@ -16,6 +15,7 @@ using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Extensions; using ComponentCollection = Umbraco.Cms.Core.Composing.ComponentCollection; using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; +using LogLevel = Umbraco.Cms.Core.Logging.LogLevel; namespace Umbraco.Cms.Infrastructure.Runtime; @@ -152,12 +152,6 @@ public class CoreRuntime : IRuntime // Store token, so we can re-use this during restart _cancellationToken = cancellationToken; - // Just in-case HostBuilder.ConfigureUmbracoDefaults() isn't used (e.g. upgrade from 9 and ignored advice). - if (StaticServiceProvider.Instance == null!) - { - StaticServiceProvider.Instance = _serviceProvider!; - } - if (isRestarting == false) { AppDomain.CurrentDomain.UnhandledException += (_, args) @@ -170,8 +164,8 @@ public class CoreRuntime : IRuntime AcquireMainDom(); // Notify for unattended install - await _eventAggregator.PublishAsync(new RuntimeUnattendedInstallNotification(), cancellationToken); - DetermineRuntimeLevel(); + await _eventAggregator.PublishAsync(new RuntimeUnattendedInstallNotification(), cancellationToken); + DetermineRuntimeLevel(); if (!State.UmbracoCanBoot()) { @@ -182,8 +176,7 @@ public class CoreRuntime : IRuntime IApplicationShutdownRegistry hostingEnvironmentLifetime = _applicationShutdownRegistry; if (hostingEnvironmentLifetime == null) { - throw new InvalidOperationException( - $"An instance of {typeof(IApplicationShutdownRegistry)} could not be resolved from the container, ensure that one if registered in your runtime before calling {nameof(IRuntime)}.{nameof(StartAsync)}"); + throw new InvalidOperationException($"An instance of {typeof(IApplicationShutdownRegistry)} could not be resolved from the container, ensure that one if registered in your runtime before calling {nameof(IRuntime)}.{nameof(StartAsync)}"); } // If level is Run and reason is UpgradeMigrations, that means we need to perform an unattended upgrade @@ -194,8 +187,7 @@ public class CoreRuntime : IRuntime case RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors: if (State.BootFailedException == null) { - throw new InvalidOperationException( - $"Unattended upgrade result was {RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors} but no {nameof(BootFailedException)} was registered"); + throw new InvalidOperationException($"Unattended upgrade result was {RuntimeUnattendedUpgradeNotification.UpgradeResult.HasErrors} but no {nameof(BootFailedException)} was registered"); } // We cannot continue here, the exception will be rethrown by BootFailedMiddelware @@ -210,33 +202,29 @@ public class CoreRuntime : IRuntime } // Initialize the components - _components.Initialize(); + _components.Initialize(); - await _eventAggregator.PublishAsync( - new UmbracoApplicationStartingNotification(State.Level, isRestarting), - cancellationToken); + await _eventAggregator.PublishAsync(new UmbracoApplicationStartingNotification(State.Level, isRestarting), cancellationToken); if (isRestarting == false) { // Add application started and stopped notifications last (to ensure they're always published after starting) - _hostApplicationLifetime?.ApplicationStarted.Register(() => - _eventAggregator.Publish(new UmbracoApplicationStartedNotification(false))); - _hostApplicationLifetime?.ApplicationStopped.Register(() => - _eventAggregator.Publish(new UmbracoApplicationStoppedNotification(false))); + _hostApplicationLifetime?.ApplicationStarted.Register(() => _eventAggregator.Publish(new UmbracoApplicationStartedNotification(false))); + _hostApplicationLifetime?.ApplicationStopped.Register(() => _eventAggregator.Publish(new UmbracoApplicationStoppedNotification(false))); } } private async Task StopAsync(CancellationToken cancellationToken, bool isRestarting) { _components.Terminate(); - await _eventAggregator.PublishAsync( - new UmbracoApplicationStoppingNotification(isRestarting), - cancellationToken); + await _eventAggregator.PublishAsync(new UmbracoApplicationStoppingNotification(isRestarting), cancellationToken); } private void AcquireMainDom() { - using DisposableTimer? timer = !_profilingLogger.IsEnabled(Core.Logging.LogLevel.Debug) ? null : _profilingLogger.DebugDuration("Acquiring MainDom.", "Acquired."); + using DisposableTimer? timer = !_profilingLogger.IsEnabled(LogLevel.Debug) + ? null + : _profilingLogger.DebugDuration("Acquiring MainDom.", "Acquired."); try { @@ -257,8 +245,9 @@ public class CoreRuntime : IRuntime return; } - using DisposableTimer? timer = !_profilingLogger.IsEnabled(Core.Logging.LogLevel.Debug) ? null : - _profilingLogger.DebugDuration("Determining runtime level.", "Determined."); + using DisposableTimer? timer = !_profilingLogger.IsEnabled(LogLevel.Debug) + ? null + : _profilingLogger.DebugDuration("Determining runtime level.", "Determined."); try { @@ -274,6 +263,7 @@ public class CoreRuntime : IRuntime { _logger.LogDebug("Configure database factory for upgrades."); } + _databaseFactory.ConfigureForUpgrade(); } } diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 0fa1d7fc4b..74977c9969 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -142,8 +142,6 @@ public static partial class UmbracoBuilderExtensions sp, sp.GetRequiredService())); - builder.Services.AddHostedService(factory => factory.GetRequiredService()); - builder.Services.AddSingleton(); builder.Services.TryAddEnumerable(ServiceDescriptor .Singleton()); diff --git a/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs b/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs index 08a22cfda3..244fe39a50 100644 --- a/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs @@ -9,6 +9,8 @@ using Serilog.Context; using StackExchange.Profiling; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Extensions; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Logging.Serilog.Enrichers; @@ -30,7 +32,21 @@ public static class ApplicationBuilderExtensions /// Configures and use services required for using Umbraco /// public static IUmbracoApplicationBuilder UseUmbraco(this IApplicationBuilder app) - => new UmbracoApplicationBuilder(app); + { + // Ensure Umbraco is booted and StaticServiceProvider.Instance is set before continuing + IRuntimeState runtimeState = app.ApplicationServices.GetRequiredService(); + if (runtimeState.Level == RuntimeLevel.Unknown) + { + throw new BootFailedException("The runtime level is unknown, please make sure Umbraco is booted by adding `await app.BootUmbracoAsync();` just after `WebApplication app = builder.Build();` in your Program.cs file."); + } + + if (StaticServiceProvider.Instance is null) + { + throw new BootFailedException("StaticServiceProvider.Instance is not set, please make sure ConfigureUmbracoDefaults() is added in your Program.cs file."); + } + + return new UmbracoApplicationBuilder(app); + } /// /// Returns true if Umbraco is greater than diff --git a/src/Umbraco.Web.Common/Extensions/WebApplicationBuilderExtensions.cs b/src/Umbraco.Web.Common/Extensions/WebApplicationBuilderExtensions.cs new file mode 100644 index 0000000000..d0040bfe47 --- /dev/null +++ b/src/Umbraco.Web.Common/Extensions/WebApplicationBuilderExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; + +namespace Umbraco.Extensions; + +/// +/// Extension methods for . +/// +public static class WebApplicationBuilderExtensions +{ + /// + /// Creates an and registers basic Umbraco services. + /// + /// The builder. + /// + /// The Umbraco builder. + /// + public static IUmbracoBuilder CreateUmbracoBuilder(this WebApplicationBuilder builder) + { + // Configure Umbraco defaults, but ignore decorated host builder and + // don't add runtime as hosted service (this is replaced by the explicit BootUmbracoAsync) + builder.Host.ConfigureUmbracoDefaults(false); + + // Do not enable static web assets on production environments, + // because the files are already copied to the publish output folder. + if (builder.Configuration.GetRuntimeMode() != RuntimeMode.Production) + { + builder.WebHost.UseStaticWebAssets(); + } + + return builder.Services.AddUmbraco(builder.Environment, builder.Configuration); + } +} diff --git a/src/Umbraco.Web.Common/Extensions/WebApplicationExtensions.cs b/src/Umbraco.Web.Common/Extensions/WebApplicationExtensions.cs new file mode 100644 index 0000000000..69b3e7882d --- /dev/null +++ b/src/Umbraco.Web.Common/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Extensions; + +/// +/// Extension methods for . +/// +public static class WebApplicationExtensions +{ + /// + /// Starts the to ensure Umbraco is ready for middleware to be added. + /// + /// The application. + /// + /// A representing the asynchronous operation. + /// + public static async Task BootUmbracoAsync(this WebApplication app) + { + // Set static IServiceProvider before booting + StaticServiceProvider.Instance = app.Services; + + // Ensure the Umbraco runtime is started before middleware is added and stopped when performing a graceful shutdown + IRuntime umbracoRuntime = app.Services.GetRequiredService(); + CancellationTokenRegistration cancellationTokenRegistration = app.Lifetime.ApplicationStopping.Register((_, token) => umbracoRuntime.StopAsync(token), null); + + await umbracoRuntime.StartAsync(cancellationTokenRegistration.Token); + } +} diff --git a/src/Umbraco.Web.Common/Hosting/HostBuilderExtensions.cs b/src/Umbraco.Web.Common/Hosting/HostBuilderExtensions.cs index 7d80f53980..6b2dafa240 100644 --- a/src/Umbraco.Web.Common/Hosting/HostBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/Hosting/HostBuilderExtensions.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Hosting; // ReSharper disable once CheckNamespace @@ -15,6 +17,9 @@ public static class HostBuilderExtensions /// Configures an existing with defaults for an Umbraco application. /// public static IHostBuilder ConfigureUmbracoDefaults(this IHostBuilder builder) + => builder.ConfigureUmbracoDefaults(true); + + internal static IHostBuilder ConfigureUmbracoDefaults(this IHostBuilder builder, bool addRuntimeHostedService) { #if DEBUG builder.ConfigureAppConfiguration(config @@ -26,10 +31,16 @@ public static class HostBuilderExtensions #endif builder.ConfigureLogging(x => x.ClearProviders()); + if (addRuntimeHostedService) + { + // Add the Umbraco IRuntime as hosted service + builder.ConfigureServices(services => services.AddHostedService(factory => factory.GetRequiredService())); + } + return new UmbracoHostBuilderDecorator(builder, OnHostBuilt); } - // Runs before any IHostedService starts (including generic web host). + // Runs before any IHostedService starts (including generic web host) private static void OnHostBuilt(IHost host) => StaticServiceProvider.Instance = host.Services; } diff --git a/src/Umbraco.Web.UI/Program.cs b/src/Umbraco.Web.UI/Program.cs index cebb7b0370..780ac4d53e 100644 --- a/src/Umbraco.Web.UI/Program.cs +++ b/src/Umbraco.Web.UI/Program.cs @@ -1,19 +1,36 @@ -namespace Umbraco.Cms.Web.UI -{ - public class Program - { - public static void Main(string[] args) - => CreateHostBuilder(args) - .Build() - .Run(); +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureUmbracoDefaults() - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStaticWebAssets(); - webBuilder.UseStartup(); - }); - } +builder.CreateUmbracoBuilder() + .AddBackOffice() + .AddWebsite() + .AddDeliveryApi() + .AddComposers() + .Build(); + +WebApplication app = builder.Build(); + +await app.BootUmbracoAsync(); + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); } + +#if (UseHttpsRedirect) +app.UseHttpsRedirection(); +#endif + +app.UseUmbraco() + .WithMiddleware(u => + { + u.UseBackOffice(); + u.UseWebsite(); + }) + .WithEndpoints(u => + { + u.UseInstallerEndpoints(); + u.UseBackOfficeEndpoints(); + u.UseWebsiteEndpoints(); + }); + +await app.RunAsync(); diff --git a/src/Umbraco.Web.UI/Startup.cs b/src/Umbraco.Web.UI/Startup.cs deleted file mode 100644 index 1d7f49b1e5..0000000000 --- a/src/Umbraco.Web.UI/Startup.cs +++ /dev/null @@ -1,70 +0,0 @@ -namespace Umbraco.Cms.Web.UI -{ - public class Startup - { - private readonly IWebHostEnvironment _env; - private readonly IConfiguration _config; - - /// - /// Initializes a new instance of the class. - /// - /// The web hosting environment. - /// The configuration. - /// - /// Only a few services are possible to be injected here https://github.com/dotnet/aspnetcore/issues/9337. - /// - public Startup(IWebHostEnvironment webHostEnvironment, IConfiguration config) - { - _env = webHostEnvironment ?? throw new ArgumentNullException(nameof(webHostEnvironment)); - _config = config ?? throw new ArgumentNullException(nameof(config)); - } - - /// - /// Configures the services. - /// - /// The services. - /// - /// This method gets called by the runtime. Use this method to add services to the container. - /// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940. - /// - public void ConfigureServices(IServiceCollection services) - { - services.AddUmbraco(_env, _config) - .AddBackOffice() - .AddWebsite() - .AddDeliveryApi() - .AddComposers() - .Build(); - } - - /// - /// Configures the application. - /// - /// The application builder. - /// The web hosting environment. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } -#if (UseHttpsRedirect) - - app.UseHttpsRedirection(); -#endif - - app.UseUmbraco() - .WithMiddleware(u => - { - u.UseBackOffice(); - u.UseWebsite(); - }) - .WithEndpoints(u => - { - u.UseInstallerEndpoints(); - u.UseBackOfficeEndpoints(); - u.UseWebsiteEndpoints(); - }); - } - } -} diff --git a/src/Umbraco.Web.UI/Views/_ViewImports.cshtml b/src/Umbraco.Web.UI/Views/_ViewImports.cshtml index 2d6f535107..91d671eaa1 100644 --- a/src/Umbraco.Web.UI/Views/_ViewImports.cshtml +++ b/src/Umbraco.Web.UI/Views/_ViewImports.cshtml @@ -1,5 +1,4 @@ @using Umbraco.Extensions -@using Umbraco.Cms.Web.UI @using Umbraco.Cms.Web.Common.PublishedModels @using Umbraco.Cms.Web.Common.Views @using Umbraco.Cms.Core.Models.PublishedContent diff --git a/templates/Umbraco.Templates.csproj b/templates/Umbraco.Templates.csproj index 38848d398a..5413f1ee0f 100644 --- a/templates/Umbraco.Templates.csproj +++ b/templates/Umbraco.Templates.csproj @@ -19,10 +19,6 @@ UmbracoProject\Program.cs UmbracoProject - - UmbracoProject\Startup.cs - UmbracoProject - UmbracoProject\Views\Partials\blocklist\%(RecursiveDir)%(Filename)%(Extension) UmbracoProject\Views\Partials\blocklist diff --git a/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker b/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker index 9fcdb4f4ba..2844f7bdc7 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker +++ b/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker @@ -2,7 +2,7 @@ ## Build ############################################ -FROM mcr.microsoft.com/dotnet/nightly/sdk:8.0.100-preview.6-jammy AS build +FROM mcr.microsoft.com/dotnet/nightly/sdk:8.0.100-preview.7-jammy AS build COPY nuget.config . From 311d322129d6db5ed5c3f1be98ee58e4e35ed2d4 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 21 Aug 2023 13:08:26 +0200 Subject: [PATCH 32/56] 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 33/56] 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 34/56] 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 9a63542645fac78b1f704bf76c71978e8e9de791 Mon Sep 17 00:00:00 2001 From: Nikolaj Brask-Nielsen Date: Mon, 21 Aug 2023 14:55:41 +0200 Subject: [PATCH 35/56] refactor: Only use pooled DbContexts (#14672) --- ...mbracoEFCoreServiceCollectionExtensions.cs | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs index 52c187dba3..6d8c207635 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs @@ -19,13 +19,8 @@ public static class UmbracoEFCoreServiceCollectionExtensions { defaultEFCoreOptionsAction ??= DefaultOptionsAction; - services.AddDbContext( - (provider, builder) => SetupDbContext(defaultEFCoreOptionsAction, provider, builder), - optionsLifetime: ServiceLifetime.Transient); - - - - services.AddDbContextFactory((provider, builder) => SetupDbContext(defaultEFCoreOptionsAction, provider, builder)); + services.AddPooledDbContextFactory((provider, builder) => SetupDbContext(defaultEFCoreOptionsAction, provider, builder)); + services.AddTransient(services => services.GetRequiredService>().CreateDbContext()); services.AddUnique, AmbientEFCoreScopeStack>(); services.AddUnique, EFCoreScopeAccessor>(); @@ -75,17 +70,8 @@ public static class UmbracoEFCoreServiceCollectionExtensions connectionString = connectionString.Replace(Constants.System.DataDirectoryPlaceholder, dataDirectory); } - services.AddDbContext( - options => - { - defaultEFCoreOptionsAction(options, providerName, connectionString); - }, - optionsLifetime: ServiceLifetime.Singleton); - - services.AddDbContextFactory(options => - { - defaultEFCoreOptionsAction(options, providerName, connectionString); - }); + services.AddPooledDbContextFactory((_, options) => defaultEFCoreOptionsAction(options, providerName, connectionString)); + services.AddTransient(services => services.GetRequiredService>().CreateDbContext()); services.AddUnique, AmbientEFCoreScopeStack>(); services.AddUnique, EFCoreScopeAccessor>(); From a5a6fe4f76291cef93103f77e7c9a57e4d8ac9fc Mon Sep 17 00:00:00 2001 From: Nikolaj Brask-Nielsen Date: Tue, 22 Aug 2023 09:34:19 +0200 Subject: [PATCH 36/56] docs: Improve DbContext XML docs (#14673) --- .../UmbracoDbContext.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs b/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs index 2c940602ac..688d91ffa9 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs @@ -8,8 +8,15 @@ namespace Umbraco.Cms.Persistence.EFCore; /// To autogenerate migrations use the following commands /// and insure the 'src/Umbraco.Web.UI/appsettings.json' have a connection string set with the right provider. /// -/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer -- --provider SqlServer -/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite -- --provider Sqlite +/// Create a migration for each provider. +/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer -- --provider SqlServer +/// +/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite -- --provider Sqlite +/// +/// Remove the last migration for each provider. +/// dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer -- --provider SqlServer +/// +/// dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite -- --provider Sqlite /// /// To find documentation about this way of working with the context see /// https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/providers?tabs=dotnet-core-cli#using-one-context-type From 380af0057be1bfb1e1158afad9e037a6081ef641 Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Tue, 22 Aug 2023 10:25:55 +0200 Subject: [PATCH 37/56] 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 38/56] 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 39/56] 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 40/56] 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 41/56] 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 42/56] 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 43/56] 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 7b336c45f7cdfcc7e9cfd0f33bedd63c06333c1b Mon Sep 17 00:00:00 2001 From: Nikolaj Brask-Nielsen Date: Tue, 22 Aug 2023 16:52:27 +0200 Subject: [PATCH 44/56] perf: Don't call GetNextUsers when we don't have to (#14670) --- src/Umbraco.Core/Services/NotificationService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Core/Services/NotificationService.cs b/src/Umbraco.Core/Services/NotificationService.cs index 72d46b2fb6..62620b1adc 100644 --- a/src/Umbraco.Core/Services/NotificationService.cs +++ b/src/Umbraco.Core/Services/NotificationService.cs @@ -99,14 +99,14 @@ public class NotificationService : INotificationService const int pagesz = 400; // load batches of 400 users do { - // users are returned ordered by id, notifications are returned ordered by user id - var users = _userService.GetNextUsers(id, pagesz).Where(x => x.IsApproved).ToList(); - var notifications = GetUsersNotifications(users.Select(x => x.Id), action, Enumerable.Empty(), Constants.ObjectTypes.Document)?.ToList(); + var notifications = GetUsersNotifications(new List(), action, Enumerable.Empty(), Constants.ObjectTypes.Document)?.ToList(); if (notifications is null || notifications.Count == 0) { break; } + // users are returned ordered by id, notifications are returned ordered by user id + var users = _userService.GetNextUsers(id, pagesz).Where(x => x.IsApproved).ToList(); foreach (IUser user in users) { Notification[] userNotifications = notifications.Where(n => n.UserId == user.Id).ToArray(); 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 45/56] 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 46/56] 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 47/56] 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 48/56] 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 3f6ebe7656d6ded634db2c268a4b60d11fa076c7 Mon Sep 17 00:00:00 2001 From: Nikolaj Brask-Nielsen Date: Thu, 24 Aug 2023 10:15:18 +0200 Subject: [PATCH 49/56] feat: Let the DbContext handle the connection to the database (#14674) --- ...mbracoEFCoreServiceCollectionExtensions.cs | 59 +++++-------------- .../UmbracoDbContext.cs | 46 ++++++++++++++- 2 files changed, 60 insertions(+), 45 deletions(-) diff --git a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs index 6d8c207635..da9c2e59ef 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs @@ -17,8 +17,6 @@ public static class UmbracoEFCoreServiceCollectionExtensions public static IServiceCollection AddUmbracoEFCoreContext(this IServiceCollection services, DefaultEFCoreOptionsAction? defaultEFCoreOptionsAction = null) where T : DbContext { - defaultEFCoreOptionsAction ??= DefaultOptionsAction; - services.AddPooledDbContextFactory((provider, builder) => SetupDbContext(defaultEFCoreOptionsAction, provider, builder)); services.AddTransient(services => services.GetRequiredService>().CreateDbContext()); @@ -31,38 +29,9 @@ public static class UmbracoEFCoreServiceCollectionExtensions return services; } - private static void SetupDbContext(DefaultEFCoreOptionsAction defaultEFCoreOptionsAction, IServiceProvider provider, DbContextOptionsBuilder builder) - { - ConnectionStrings connectionStrings = GetConnectionStringAndProviderName(provider); - IEnumerable migrationProviders = provider.GetServices(); - IMigrationProviderSetup? migrationProvider = - migrationProviders.FirstOrDefault(x => x.ProviderName == connectionStrings.ProviderName); - migrationProvider?.Setup(builder, connectionStrings.ConnectionString); - defaultEFCoreOptionsAction(builder, connectionStrings.ConnectionString, connectionStrings.ProviderName); - } - - private static ConnectionStrings GetConnectionStringAndProviderName(IServiceProvider serviceProvider) - { - string? connectionString = null; - string? providerName = null; - - ConnectionStrings connectionStrings = serviceProvider.GetRequiredService>().CurrentValue; - - // Replace data directory - string? dataDirectory = AppDomain.CurrentDomain.GetData(Constants.System.DataDirectoryName)?.ToString(); - if (string.IsNullOrEmpty(dataDirectory) is false) - { - connectionStrings.ConnectionString = connectionStrings.ConnectionString?.Replace(Constants.System.DataDirectoryPlaceholder, dataDirectory); - } - - return connectionStrings; - } - public static IServiceCollection AddUmbracoEFCoreContext(this IServiceCollection services, string connectionString, string providerName, DefaultEFCoreOptionsAction? defaultEFCoreOptionsAction = null) where T : DbContext { - defaultEFCoreOptionsAction ??= DefaultOptionsAction; - // Replace data directory string? dataDirectory = AppDomain.CurrentDomain.GetData(Constants.System.DataDirectoryName)?.ToString(); if (string.IsNullOrEmpty(dataDirectory) is false) @@ -70,7 +39,7 @@ public static class UmbracoEFCoreServiceCollectionExtensions connectionString = connectionString.Replace(Constants.System.DataDirectoryPlaceholder, dataDirectory); } - services.AddPooledDbContextFactory((_, options) => defaultEFCoreOptionsAction(options, providerName, connectionString)); + services.AddPooledDbContextFactory(options => defaultEFCoreOptionsAction?.Invoke(options, providerName, connectionString)); services.AddTransient(services => services.GetRequiredService>().CreateDbContext()); services.AddUnique, AmbientEFCoreScopeStack>(); @@ -82,21 +51,23 @@ public static class UmbracoEFCoreServiceCollectionExtensions return services; } - private static void DefaultOptionsAction(DbContextOptionsBuilder options, string? providerName, string? connectionString) + private static void SetupDbContext(DefaultEFCoreOptionsAction? defaultEFCoreOptionsAction, IServiceProvider provider, DbContextOptionsBuilder builder) { - if (connectionString.IsNullOrWhiteSpace()) + ConnectionStrings connectionStrings = GetConnectionStringAndProviderName(provider); + defaultEFCoreOptionsAction?.Invoke(builder, connectionStrings.ConnectionString, connectionStrings.ProviderName); + } + + private static ConnectionStrings GetConnectionStringAndProviderName(IServiceProvider serviceProvider) + { + ConnectionStrings connectionStrings = serviceProvider.GetRequiredService>().CurrentValue; + + // Replace data directory + string? dataDirectory = AppDomain.CurrentDomain.GetData(Constants.System.DataDirectoryName)?.ToString(); + if (string.IsNullOrEmpty(dataDirectory) is false) { - return; + connectionStrings.ConnectionString = connectionStrings.ConnectionString?.Replace(Constants.System.DataDirectoryPlaceholder, dataDirectory); } - switch (providerName) - { - case "Microsoft.Data.Sqlite": - options.UseSqlite(connectionString); - break; - case "Microsoft.Data.SqlClient": - options.UseSqlServer(connectionString); - break; - } + return connectionStrings; } } diff --git a/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs b/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs index 688d91ffa9..7874d9d889 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs @@ -1,9 +1,18 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Persistence.EFCore.Migrations; namespace Umbraco.Cms.Persistence.EFCore; +/// +/// Represents the Umbraco EF Core database context. +/// /// /// To autogenerate migrations use the following commands /// and insure the 'src/Umbraco.Web.UI/appsettings.json' have a connection string set with the right provider. @@ -23,9 +32,44 @@ namespace Umbraco.Cms.Persistence.EFCore; /// public class UmbracoDbContext : DbContext { + /// + /// Initializes a new instance of the class. + /// + /// public UmbracoDbContext(DbContextOptions options) - : base(options) + : base(ConfigureOptions(options, out IOptionsMonitor? connectionStringsOptionsMonitor)) { + connectionStringsOptionsMonitor.OnChange(c => + { + ILogger logger = StaticServiceProvider.Instance.GetRequiredService>(); + logger.LogWarning("Connection string changed, disposing context"); + Dispose(); + }); + } + + private static DbContextOptions ConfigureOptions(DbContextOptions options, out IOptionsMonitor connectionStringsOptionsMonitor) + { + connectionStringsOptionsMonitor = StaticServiceProvider.Instance.GetRequiredService>(); + + ConnectionStrings connectionStrings = connectionStringsOptionsMonitor.CurrentValue; + + if (string.IsNullOrWhiteSpace(connectionStrings.ConnectionString)) + { + ILogger logger = StaticServiceProvider.Instance.GetRequiredService>(); + logger.LogCritical("No connection string was found, cannot setup Umbraco EF Core context"); + } + + IEnumerable migrationProviders = StaticServiceProvider.Instance.GetServices(); + IMigrationProviderSetup? migrationProvider = migrationProviders.FirstOrDefault(x => x.ProviderName == connectionStrings.ProviderName); + + if (migrationProvider == null && connectionStrings.ProviderName != null) + { + throw new InvalidOperationException($"No migration provider found for provider name {connectionStrings.ProviderName}"); + } + + var optionsBuilder = new DbContextOptionsBuilder(options); + migrationProvider?.Setup(optionsBuilder, connectionStrings.ConnectionString); + return optionsBuilder.Options; } protected override void OnModelCreating(ModelBuilder modelBuilder) From f2b0c0e8eb9c43d3ad7b0c6dc443c21604685504 Mon Sep 17 00:00:00 2001 From: Nikolaj Date: Thu, 24 Aug 2023 10:44:01 +0200 Subject: [PATCH 50/56] 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 51/56] 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 fec51a9ec598cc0b43b53c3f937045d6e2b8608f Mon Sep 17 00:00:00 2001 From: Christian Funder Sommerlund Date: Thu, 24 Aug 2023 13:06:21 +0200 Subject: [PATCH 52/56] Add ReSharperTestRunner64 to the assembly exclusion list in TypeFinder (#14498) --- src/Umbraco.Core/Composing/TypeFinder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Core/Composing/TypeFinder.cs b/src/Umbraco.Core/Composing/TypeFinder.cs index 5925d3dab1..e22df693e9 100644 --- a/src/Umbraco.Core/Composing/TypeFinder.cs +++ b/src/Umbraco.Core/Composing/TypeFinder.cs @@ -34,7 +34,7 @@ public class TypeFinder : ITypeFinder "ServiceStack.", "SqlCE4Umbraco,", "Superpower,", // used by Serilog "System.", "TidyNet,", "TidyNet.", "WebDriver,", "itextsharp,", "mscorlib,", "NUnit,", "NUnit.", "NUnit3.", "Selenium.", "ImageProcessor", "MiniProfiler.", "Owin,", "SQLite", - "ReSharperTestRunner32", // used by resharper testrunner + "ReSharperTestRunner32", "ReSharperTestRunner64", // These are used by the Jetbrains Rider IDE and Visual Studio ReSharper Extension }; private static readonly ConcurrentDictionary TypeNamesCache = new(); From 1198c76d67e3aa9ef5cebeb54d66708186125165 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Fri, 25 Aug 2023 13:07:42 +0200 Subject: [PATCH 53/56] 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 54/56] 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 55/56] 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 56/56] 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" },