From 5e87dead442da420afc5cf813dd351e2dca479e8 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Fri, 31 Oct 2025 10:53:57 +0100 Subject: [PATCH 1/5] Task: Dependency track (#20670) * Generate BOM files on build * Upload BOM to Dependency Track * Move Backoffice BOM generation to right after install The build and/or pack steps are deleting files that are needed for the BOM to be generated properly. * Split the BOM uploads into different jobs * Fix wrong usage of parameters * Move order of dependency track stage * Fix wrong umbracoVersion value * Small fixes * Log curl response headers * Correct version sent to dependency track * Adjusted curl flags * Fix bom file path * Fix dotnet bom file name * Add Login UI to dependency track * Generate BOM for E2E Tests * Move dependency track stage * Move acceptance test .env generation to e2e install template Needed as the post install script is expecting this to exist. * Use major version if public release * Missing ')' * Reverted npm install command changes in static assets project --- build/azure-pipelines.yml | 81 ++++++++++++++++++++++++++++ build/nightly-E2E-setup-template.yml | 34 +++--------- build/templates/dependency-track.yml | 56 +++++++++++++++++++ build/templates/e2e-install.yml | 49 +++++++++++++++++ 4 files changed, 193 insertions(+), 27 deletions(-) create mode 100644 build/templates/dependency-track.yml create mode 100644 build/templates/e2e-install.yml diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index b53a2d0ad2..8b97f87ee5 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -34,6 +34,10 @@ parameters: displayName: Upload API docs type: boolean default: false + - name: uploadDependencyTrack + displayName: Upload BOMs to Dependency Track + type: boolean + default: false - name: forceReleaseTestFilter displayName: Force to use the release test filters type: boolean @@ -103,6 +107,15 @@ stages: command: build projects: $(solution) arguments: "--configuration $(buildConfiguration) --no-restore --property:ContinuousIntegrationBuild=true --property:GeneratePackageOnBuild=true --property:PackageOutputPath=$(Build.ArtifactStagingDirectory)/nupkg" + - powershell: | + dotnet tool install --global CycloneDX + dotnet-CycloneDX $(solution) --output $(Build.ArtifactStagingDirectory)/bom --filename bom-dotnet.xml + displayName: 'Generate Backend BOM' + - powershell: | + npm install --global @cyclonedx/cyclonedx-npm + cyclonedx-npm -o $(Build.ArtifactStagingDirectory)\bom\bom-login.xml --ignore-npm-errors --verbose + displayName: Generate Login UI BOM + workingDirectory: src/Umbraco.Web.UI.Login - task: PublishPipelineArtifact@1 displayName: Publish nupkg inputs: @@ -113,6 +126,11 @@ stages: inputs: targetPath: $(Build.SourcesDirectory) artifactName: build_output + - task: PublishPipelineArtifact@1 + displayName: Publish Backend BOM + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/bom + artifactName: bom-backend - job: B displayName: Build Bellissima Package @@ -124,6 +142,11 @@ stages: lfs: false, fetchDepth: 500 - template: templates/backoffice-install.yml + - powershell: | + npm install --global @cyclonedx/cyclonedx-npm + cyclonedx-npm -o $(Build.ArtifactStagingDirectory)/bom/bom-backoffice.xml --ignore-npm-errors --verbose + displayName: Generate Backoffice UI BOM + workingDirectory: src/Umbraco.Web.UI.Client - script: npm run build:for:npm displayName: Run build:for:npm workingDirectory: src/Umbraco.Web.UI.Client @@ -140,6 +163,35 @@ stages: inputs: targetPath: $(Build.ArtifactStagingDirectory)/npm artifactName: npm + - publish: $(Build.ArtifactStagingDirectory)/bom + artifact: bom-frontend + displayName: 'Publish Frontend BOM' + + - stage: E2E_BOM + displayName: E2E Tests BOM Generation + dependsOn: [] + jobs: + - job: + displayName: E2E Generate BOM + pool: + vmImage: "ubuntu-latest" + steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 500 + - template: templates/e2e-install.yml + parameters: + nodeVersion: ${{ variables.nodeVersion }} + npm_config_cache: ${{ variables.npm_config_cache }} + - powershell: | + npm install --global @cyclonedx/cyclonedx-npm + cyclonedx-npm -o $(Build.ArtifactStagingDirectory)/bom/bom-e2e.xml --ignore-npm-errors --verbose + displayName: Generate E2E Tests BOM + workingDirectory: tests/Umbraco.Tests.AcceptanceTest + - publish: $(Build.ArtifactStagingDirectory)/bom + artifact: bom-e2e + displayName: 'Publish E2E BOM' - stage: Build_Docs condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.buildApiDocs}})) @@ -668,6 +720,34 @@ stages: ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }} DatabaseType: ${{ variables.DatabaseType }} + - stage: Dependency_Track + displayName: Dependency Track + dependsOn: + - Build + - E2E_BOM + condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.uploadDependencyTrack}})) + variables: + # Determine Umbraco version based on whether it's a public release or not. If public release, use major version, else use full NuGet package version. + umbracoVersion: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PublicRelease'], 'True'), stageDependencies.Build.A.outputs['build.NBGV_VersionMajor'], stageDependencies.Build.A.outputs['build.NBGV_NuGetPackageVersion']) ] + jobs: + - template: templates/dependency-track.yml + parameters: + projectName: "Umbraco-CMS" + umbracoVersion: $(umbracoVersion) + projects: + - name: "Backend" + artifact: "bom-backend" + bomFilePath: "bom-dotnet.xml" + - name: "Login" + artifact: "bom-backend" + bomFilePath: "bom-login.xml" + - name: "Backoffice" + artifact: "bom-frontend" + bomFilePath: "bom-backoffice.xml" + - name: "E2E" + artifact: "bom-e2e" + bomFilePath: "bom-e2e.xml" + ############################################### ## Release ############################################### @@ -874,3 +954,4 @@ stages: ContainerName: "$web" BlobPrefix: v$(umbracoMajorVersion)/ui-api CleanTargetBeforeCopy: true + diff --git a/build/nightly-E2E-setup-template.yml b/build/nightly-E2E-setup-template.yml index 8085561900..907ace6b72 100644 --- a/build/nightly-E2E-setup-template.yml +++ b/build/nightly-E2E-setup-template.yml @@ -26,38 +26,18 @@ steps: artifact: nupkg path: $(Agent.BuildDirectory)/app/nupkg - - task: NodeTool@0 - displayName: Use Node.js $(nodeVersion) - inputs: - versionSpec: $(nodeVersion) - - task: UseDotNet@2 displayName: Use .NET SDK from global.json inputs: useGlobalJson: true - - pwsh: | - "UMBRACO_USER_LOGIN=${{ parameters.PlaywrightUserEmail }} - UMBRACO_USER_PASSWORD=${{ parameters.PlaywrightPassword }} - URL=${{ parameters.ASPNETCORE_URLS }} - STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json - CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env - displayName: Generate .env - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - - # Cache and restore NPM packages - - task: Cache@2 - displayName: Cache NPM packages - inputs: - key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json' - restoreKeys: | - npm_e2e | "$(Agent.OS)" - npm_e2e - path: ${{ parameters.npm_config_cache }} - - - script: npm ci --no-fund --no-audit --prefer-offline - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - displayName: Restore NPM packages + - template: templates/e2e-install.yml + parameters: + nodeVersion: ${{ parameters.nodeVersion }} + npm_config_cache: ${{ parameters.npm_config_cache }} + PlaywrightUserEmail: ${{ parameters.PlaywrightUserEmail }} + PlaywrightPassword: ${{ parameters.PlaywrightPassword }} + ASPNETCORE_URLS: ${{ parameters.ASPNETCORE_URLS }} # Install Template - pwsh: | diff --git a/build/templates/dependency-track.yml b/build/templates/dependency-track.yml new file mode 100644 index 0000000000..74968b4616 --- /dev/null +++ b/build/templates/dependency-track.yml @@ -0,0 +1,56 @@ +parameters: + - name: projectName + type: string + - name: umbracoVersion + type: string + - name: projects + type: object + +jobs: +- job: Create_DT_Project + displayName: Create Dependency Track Project + steps: + - checkout: none + + - bash: | + project_id=$(curl --no-progress-meter -H "X-Api-Key: $(DT_API_KEY)" "$(DT_API_URL)/v1/project/lookup?name=${{ parameters.projectName }}&version=${{ parameters.umbracoVersion }}" | jq -r '.uuid') + if [ "$project_id" != "null" ] && [ -n "$project_id" ]; then + echo "Project '${{ parameters.projectName }}' with version '${{ parameters.umbracoVersion }}' already exists (ID: $project_id)." + else + project_id=$(curl --no-progress-meter \ + -X PUT "$(DT_API_URL)/v1/project" \ + -H "X-Api-Key: $(DT_API_KEY)" \ + -H "Content-Type: application/json" \ + -d '{"name": "${{ parameters.projectName }}", "version": "${{ parameters.umbracoVersion }}", "collectionLogic": "AGGREGATE_DIRECT_CHILDREN"}' \ + | jq -r '.uuid') + if [ -z "$project_id" ] || [ "$project_id" == "null" ]; then + echo "Failed to create project '${{ parameters.projectName }}' version '${{ parameters.umbracoVersion }}'." + exit 1 + fi + echo "Created project '${{ parameters.projectName }}' with version '${{ parameters.umbracoVersion }}' (ID: $project_id)." + fi + displayName: Ensure main project exists in Dependency Track + +- ${{ each project in parameters.projects }}: + - job: + displayName: Upload ${{ project.name }} BOM + dependsOn: Create_DT_Project + steps: + - checkout: none + + - download: current + artifact: ${{ project.artifact }} + displayName: Download ${{ project.artifact }} artifact + + - script: | + curl --no-progress-meter --fail-with-body \ + -X POST "$(DT_API_URL)/v1/bom" \ + -H "X-Api-Key: $(DT_API_KEY)" \ + -H "Content-Type: multipart/form-data" \ + -F "autoCreate=true" \ + -F "projectName=${{ parameters.projectName }}-${{ project.name }}" \ + -F "projectVersion=${{ parameters.umbracoVersion }}" \ + -F "parentName=${{ parameters.projectName }}" \ + -F "parentVersion=${{ parameters.umbracoVersion }}" \ + -F "bom=@$(Pipeline.Workspace)/${{ project.artifact }}/${{ project.bomFilePath }}" + displayName: Upload ${{ project.name }} BOM to Dependency Track diff --git a/build/templates/e2e-install.yml b/build/templates/e2e-install.yml new file mode 100644 index 0000000000..3c2909f3fb --- /dev/null +++ b/build/templates/e2e-install.yml @@ -0,0 +1,49 @@ +parameters: + - name: nodeVersion + type: string + default: '' + + - name: npm_config_cache + type: string + default: '' + + - name: PlaywrightUserEmail + type: string + default: '' + + - name: PlaywrightPassword + type: string + default: '' + + - name: ASPNETCORE_URLS + type: string + default: '' + +steps: + - task: NodeTool@0 + displayName: Use Node.js $(nodeVersion) + inputs: + versionSpec: $(nodeVersion) + + - pwsh: | + "UMBRACO_USER_LOGIN=${{ parameters.PlaywrightUserEmail }} + UMBRACO_USER_PASSWORD=${{ parameters.PlaywrightPassword }} + URL=${{ parameters.ASPNETCORE_URLS }} + STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json + CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env + displayName: Generate .env + workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest + + # Cache and restore NPM packages + - task: Cache@2 + displayName: Cache NPM packages + inputs: + key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json' + restoreKeys: | + npm_e2e | "$(Agent.OS)" + npm_e2e + path: ${{ parameters.npm_config_cache }} + + - script: npm ci --no-fund --no-audit --prefer-offline + workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest + displayName: Restore NPM packages From 96ecef0a9267b3de564ad027a984e2e731ee4493 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 31 Oct 2025 10:49:26 +0100 Subject: [PATCH 2/5] Performance: Request cache referenced entities when saving documents with block editors (#20590) * Added request cache to content and media lookups in mult URL picker. * Allow property editors to cache referenced entities from block data. * Update src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add obsoletions. * Minor spellcheck * Ensure request cache is available before relying on it. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: kjac --- .../PropertyEditors/DataValueEditor.cs | 156 ++++++++++++++++++ .../ICacheReferencedEntities.cs | 19 +++ .../BlockEditorPropertyValueEditor.cs | 2 + .../BlockValuePropertyValueEditorBase.cs | 39 +++++ .../MediaPicker3PropertyEditor.cs | 85 ++++++---- .../MultiUrlPickerValueEditor.cs | 104 +++++++++++- ...ultiUrlPickerValueEditorValidationTests.cs | 4 +- 7 files changed, 368 insertions(+), 41 deletions(-) create mode 100644 src/Umbraco.Core/PropertyEditors/ICacheReferencedEntities.cs diff --git a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs index 211d36f65f..1c32dc3de6 100644 --- a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs @@ -3,12 +3,14 @@ using System.Globalization; using System.Runtime.Serialization; using System.Xml.Linq; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.Models.Validation; using Umbraco.Cms.Core.PropertyEditors.Validators; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Extensions; @@ -20,6 +22,9 @@ namespace Umbraco.Cms.Core.PropertyEditors; [DataContract] public class DataValueEditor : IDataValueEditor { + private const string ContentCacheKeyFormat = nameof(DataValueEditor) + "_Content_{0}"; + private const string MediaCacheKeyFormat = nameof(DataValueEditor) + "_Media_{0}"; + private readonly IJsonSerializer? _jsonSerializer; private readonly IShortStringHelper _shortStringHelper; @@ -415,4 +420,155 @@ public class DataValueEditor : IDataValueEditor return value.TryConvertTo(valueType); } + + /// + /// Retrieves a instance by its unique identifier, using the provided request cache to avoid redundant + /// lookups within the same request. + /// + /// + /// This method caches content lookups for the duration of the current request to improve performance when the same content + /// item may be accessed multiple times. This is particularly useful in scenarios involving multiple languages or blocks. + /// + /// The unique identifier of the content item to retrieve. + /// The request-scoped cache used to store and retrieve content items for the duration of the current request. + /// The content service used to fetch the content item if it is not found in the cache. + /// The instance corresponding to the specified key, or null if no such content item exists. + [Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " + + "The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " + + "Scheduled for removal in Umbraco 19.")] + protected static IContent? GetAndCacheContentById(Guid key, IRequestCache requestCache, IContentService contentService) + { + if (requestCache.IsAvailable is false) + { + return contentService.GetById(key); + } + + var cacheKey = string.Format(ContentCacheKeyFormat, key); + IContent? content = requestCache.GetCacheItem(cacheKey); + if (content is null) + { + content = contentService.GetById(key); + if (content is not null) + { + requestCache.Set(cacheKey, content); + } + } + + return content; + } + + /// + /// Adds the specified item to the request cache using its unique key. + /// + /// The content item to cache. + /// The request cache in which to store the content item. + [Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " + + "The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " + + "Scheduled for removal in Umbraco 19.")] + protected static void CacheContentById(IContent content, IRequestCache requestCache) + { + if (requestCache.IsAvailable is false) + { + return; + } + + var cacheKey = string.Format(ContentCacheKeyFormat, content.Key); + requestCache.Set(cacheKey, content); + } + + /// + /// Retrieves a instance by its unique identifier, using the provided request cache to avoid redundant + /// lookups within the same request. + /// + /// + /// This method caches media lookups for the duration of the current request to improve performance when the same media + /// item may be accessed multiple times. This is particularly useful in scenarios involving multiple languages or blocks. + /// + /// The unique identifier of the media item to retrieve. + /// The request-scoped cache used to store and retrieve media items for the duration of the current request. + /// The media service used to fetch the media item if it is not found in the cache. + /// The instance corresponding to the specified key, or null if no such media item exists. + [Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " + + "The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " + + "Scheduled for removal in Umbraco 19.")] + protected static IMedia? GetAndCacheMediaById(Guid key, IRequestCache requestCache, IMediaService mediaService) + { + if (requestCache.IsAvailable is false) + { + return mediaService.GetById(key); + } + + var cacheKey = string.Format(MediaCacheKeyFormat, key); + IMedia? media = requestCache.GetCacheItem(cacheKey); + + if (media is null) + { + media = mediaService.GetById(key); + if (media is not null) + { + requestCache.Set(cacheKey, media); + } + } + + return media; + } + + /// + /// Adds the specified item to the request cache using its unique key. + /// + /// The media item to cache. + /// The request cache in which to store the media item. + [Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " + + "The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " + + "Scheduled for removal in Umbraco 19.")] + protected static void CacheMediaById(IMedia media, IRequestCache requestCache) + { + if (requestCache.IsAvailable is false) + { + return; + } + + var cacheKey = string.Format(MediaCacheKeyFormat, media.Key); + requestCache.Set(cacheKey, media); + } + + /// + /// Determines whether the content item identified by the specified key is present in the request cache. + /// + /// The unique identifier for the content item to check for in the cache. + /// The request cache in which to look for the content item. + /// true if the content item is already cached in the request cache; otherwise, false. + [Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " + + "The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " + + "Scheduled for removal in Umbraco 19.")] + protected static bool IsContentAlreadyCached(Guid key, IRequestCache requestCache) + { + if (requestCache.IsAvailable is false) + { + return false; + } + + var cacheKey = string.Format(ContentCacheKeyFormat, key); + return requestCache.GetCacheItem(cacheKey) is not null; + } + + /// + /// Determines whether the media item identified by the specified key is present in the request cache. + /// + /// The unique identifier for the media item to check for in the cache. + /// The request cache in which to look for the media item. + /// true if the media item is already cached in the request cache; otherwise, false. + [Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " + + "The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " + + "Scheduled for removal in Umbraco 19.")] + protected static bool IsMediaAlreadyCached(Guid key, IRequestCache requestCache) + { + if (requestCache.IsAvailable is false) + { + return false; + } + + var cacheKey = string.Format(MediaCacheKeyFormat, key); + return requestCache.GetCacheItem(cacheKey) is not null; + } } diff --git a/src/Umbraco.Core/PropertyEditors/ICacheReferencedEntities.cs b/src/Umbraco.Core/PropertyEditors/ICacheReferencedEntities.cs new file mode 100644 index 0000000000..cf655a9167 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/ICacheReferencedEntities.cs @@ -0,0 +1,19 @@ +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Optionally implemented by property editors, this defines a contract for caching entities that are referenced in block values. +/// +[Obsolete("This interface is available for support of request caching retrieved entities in property value editors that implement it. " + + "The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " + + "Scheduled for removal in Umbraco 19.")] +public interface ICacheReferencedEntities +{ + /// + /// Caches the entities referenced by the provided block data values. + /// + /// An enumerable collection of block values that may contain the entities to be cached. + [Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " + + "The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " + + "Scheduled for removal in Umbraco 19.")] + void CacheReferencedEntities(IEnumerable values); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs index 84299d6399..d328e2a2e6 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs @@ -135,6 +135,8 @@ public abstract class BlockEditorPropertyValueEditor : BlockVal BlockEditorData? currentBlockEditorData = SafeParseBlockEditorData(currentValue); BlockEditorData? blockEditorData = SafeParseBlockEditorData(editorValue.Value); + CacheReferencedEntities(blockEditorData); + // We can skip MapBlockValueFromEditor if both editorValue and currentValue values are empty. if (IsBlockEditorDataEmpty(currentBlockEditorData) && IsBlockEditorDataEmpty(blockEditorData)) { diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs index 18578d495f..6d6465a447 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs @@ -43,6 +43,45 @@ public abstract class BlockValuePropertyValueEditorBase : DataV _languageService = languageService; } + /// + /// Caches referenced entities for all property values with supporting property editors within the specified block editor data + /// optimising subsequent retrieval of entities when parsing and converting property values. + /// + /// + /// This method iterates through all property values associated with data editors in the provided + /// block editor data and invokes caching for referenced entities where supported by the property editor. + /// + /// The block editor data containing content and settings property values to analyze for referenced entities. + [Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " + + "The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " + + "Scheduled for removal in Umbraco 19.")] + protected void CacheReferencedEntities(BlockEditorData? blockEditorData) + { + // Group property values by their associated data editor alias. + IEnumerable> valuesByDataEditors = (blockEditorData?.BlockValue.ContentData ?? []).Union(blockEditorData?.BlockValue.SettingsData ?? []) + .SelectMany(x => x.Values) + .Where(x => x.EditorAlias is not null && x.Value is not null) + .GroupBy(x => x.EditorAlias!); + + // Iterate through each group and cache referenced entities if supported by the data editor. + foreach (IGrouping valueByDataEditor in valuesByDataEditors) + { + IDataEditor? dataEditor = _propertyEditors[valueByDataEditor.Key]; + if (dataEditor is null) + { + continue; + } + + IDataValueEditor valueEditor = dataEditor.GetValueEditor(); + + if (valueEditor is ICacheReferencedEntities valueEditorWithPrecaching) + { + valueEditorWithPrecaching.CacheReferencedEntities(valueByDataEditor.Select(x => x.Value!)); + } + } + } + + /// public abstract IEnumerable GetReferences(object? value); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index d4a9e492bd..50fe850e04 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -52,10 +52,8 @@ public class MediaPicker3PropertyEditor : DataEditor /// /// Defines the value editor for the media picker property editor. /// - internal sealed class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference + internal sealed class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference, ICacheReferencedEntities { - private const string MediaCacheKeyFormat = nameof(MediaPicker3PropertyValueEditor) + "_Media_{0}"; - private readonly IDataTypeConfigurationCache _dataTypeReadCache; private readonly IJsonSerializer _jsonSerializer; private readonly IMediaImportService _mediaImportService; @@ -107,6 +105,27 @@ public class MediaPicker3PropertyEditor : DataEditor Validators.Add(validators); } + /// + public void CacheReferencedEntities(IEnumerable values) + { + var mediaKeys = values + .SelectMany(value => Deserialize(_jsonSerializer, value)) + .Select(dto => dto.MediaKey) + .Distinct() + .Where(x => IsMediaAlreadyCached(x, _appCaches.RequestCache) is false) + .ToList(); + if (mediaKeys.Count == 0) + { + return; + } + + IEnumerable mediaItems = _mediaService.GetByIds(mediaKeys); + foreach (IMedia media in mediaItems) + { + CacheMediaById(media, _appCaches.RequestCache); + } + } + /// public IEnumerable GetReferences(object? value) { @@ -208,31 +227,13 @@ public class MediaPicker3PropertyEditor : DataEditor foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos) { - IMedia? media = GetMediaById(mediaWithCropsDto.MediaKey); + IMedia? media = GetAndCacheMediaById(mediaWithCropsDto.MediaKey, _appCaches.RequestCache, _mediaService); mediaWithCropsDto.MediaTypeAlias = media?.ContentType.Alias ?? unknownMediaType; } return mediaWithCropsDtos.Where(m => m.MediaTypeAlias != unknownMediaType).ToList(); } - private IMedia? GetMediaById(Guid key) - { - // Cache media lookups in case the same media is handled multiple times across a save operation, - // which is possible, particularly if we have multiple languages and blocks. - var cacheKey = string.Format(MediaCacheKeyFormat, key); - IMedia? media = _appCaches.RequestCache.GetCacheItem(cacheKey); - if (media is null) - { - media = _mediaService.GetById(key); - if (media is not null) - { - _appCaches.RequestCache.Set(cacheKey, media); - } - } - - return media; - } - private List HandleTemporaryMediaUploads(List mediaWithCropsDtos, MediaPicker3Configuration configuration) { var invalidDtos = new List(); @@ -240,7 +241,7 @@ public class MediaPicker3PropertyEditor : DataEditor foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos) { // if the media already exist, don't bother with it - if (GetMediaById(mediaWithCropsDto.MediaKey) != null) + if (GetAndCacheMediaById(mediaWithCropsDto.MediaKey, _appCaches.RequestCache, _mediaService) != null) { continue; } @@ -480,18 +481,7 @@ public class MediaPicker3PropertyEditor : DataEditor foreach (var typeAlias in distinctTypeAliases) { - // Cache media type lookups since the same media type is likely to be used multiple times in validation, - // particularly if we have multiple languages and blocks. - var cacheKey = string.Format(MediaTypeCacheKeyFormat, typeAlias); - string? typeKey = _appCaches.RequestCache.GetCacheItem(cacheKey); - if (typeKey is null) - { - typeKey = _mediaTypeService.Get(typeAlias)?.Key.ToString(); - if (typeKey is not null) - { - _appCaches.RequestCache.Set(cacheKey, typeKey); - } - } + string? typeKey = GetMediaTypeKey(typeAlias); if (typeKey is null || allowedTypes.Contains(typeKey) is false) { @@ -506,6 +496,31 @@ public class MediaPicker3PropertyEditor : DataEditor return []; } + + private string? GetMediaTypeKey(string typeAlias) + { + // Cache media type lookups since the same media type is likely to be used multiple times in validation, + // particularly if we have multiple languages and blocks. + string? GetMediaTypeKeyFromService(string typeAlias) => _mediaTypeService.Get(typeAlias)?.Key.ToString(); + + if (_appCaches.RequestCache.IsAvailable is false) + { + return GetMediaTypeKeyFromService(typeAlias); + } + + var cacheKey = string.Format(MediaTypeCacheKeyFormat, typeAlias); + string? typeKey = _appCaches.RequestCache.GetCacheItem(cacheKey); + if (typeKey is null) + { + typeKey = GetMediaTypeKeyFromService(typeAlias); + if (typeKey is not null) + { + _appCaches.RequestCache.Set(cacheKey, typeKey); + } + } + + return typeKey; + } } /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs index 2f0a2b279b..2359e5537d 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs @@ -3,7 +3,10 @@ using System.ComponentModel.DataAnnotations; using System.Runtime.Serialization; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; @@ -19,14 +22,16 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; -public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference +public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference, ICacheReferencedEntities { private readonly ILogger _logger; private readonly IPublishedUrlProvider _publishedUrlProvider; private readonly IJsonSerializer _jsonSerializer; private readonly IContentService _contentService; private readonly IMediaService _mediaService; + private readonly AppCaches _appCaches; + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")] public MultiUrlPickerValueEditor( ILogger logger, ILocalizedTextService localizedTextService, @@ -37,19 +42,102 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference IIOHelper ioHelper, IContentService contentService, IMediaService mediaService) + : this( + logger, + localizedTextService, + shortStringHelper, + attribute, + publishedUrlProvider, + jsonSerializer, + ioHelper, + contentService, + mediaService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public MultiUrlPickerValueEditor( + ILogger logger, + ILocalizedTextService localizedTextService, + IShortStringHelper shortStringHelper, + DataEditorAttribute attribute, + IPublishedUrlProvider publishedUrlProvider, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper, + IContentService contentService, + IMediaService mediaService, + AppCaches appCaches) : base(shortStringHelper, jsonSerializer, ioHelper, attribute) { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _logger = logger; _publishedUrlProvider = publishedUrlProvider; - _jsonSerializer = jsonSerializer; _contentService = contentService; _mediaService = mediaService; + _appCaches = appCaches; + Validators.Add(new TypedJsonValidatorRunner( _jsonSerializer, new MinMaxValidator(localizedTextService))); } + /// + public void CacheReferencedEntities(IEnumerable values) + { + var dtos = values + .Select(value => + { + var asString = value is string str ? str : value.ToString(); + if (string.IsNullOrEmpty(asString)) + { + return null; + } + + return _jsonSerializer.Deserialize>(asString); + }) + .WhereNotNull() + .SelectMany(x => x) + .Where(x => x.Type == Constants.UdiEntityType.Document || x.Type == Constants.UdiEntityType.Media) + .ToList(); + + IList contentKeys = GetKeys(Constants.UdiEntityType.Document, dtos); + IList mediaKeys = GetKeys(Constants.UdiEntityType.Media, dtos); + + if (contentKeys.Count > 0) + { + IEnumerable contentItems = _contentService.GetByIds(contentKeys); + foreach (IContent content in contentItems) + { + CacheContentById(content, _appCaches.RequestCache); + } + } + + if (mediaKeys.Count > 0) + { + IEnumerable mediaItems = _mediaService.GetByIds(mediaKeys); + foreach (IMedia media in mediaItems) + { + CacheMediaById(media, _appCaches.RequestCache); + } + } + } + + private IList GetKeys(string entityType, IEnumerable dtos) => + dtos + .Where(x => x.Type == entityType) + .Select(x => x.Unique ?? (x.Udi is not null ? x.Udi.Guid : Guid.Empty)) + .Where(x => x != Guid.Empty) + .Distinct() + .Where(x => IsAlreadyCached(x, entityType) is false) + .ToList(); + + private bool IsAlreadyCached(Guid key, string entityType) => entityType switch + { + Constants.UdiEntityType.Document => IsContentAlreadyCached(key, _appCaches.RequestCache), + Constants.UdiEntityType.Media => IsMediaAlreadyCached(key, _appCaches.RequestCache), + _ => false, + }; + public IEnumerable GetReferences(object? value) { var asString = value == null ? string.Empty : value is string str ? str : value.ToString(); @@ -105,7 +193,7 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference if (dto.Udi.EntityType == Constants.UdiEntityType.Document) { url = _publishedUrlProvider.GetUrl(dto.Udi.Guid, UrlMode.Relative, culture); - IContent? c = _contentService.GetById(dto.Udi.Guid); + IContent? c = GetAndCacheContentById(dto.Udi.Guid, _appCaches.RequestCache, _contentService); if (c is not null) { @@ -119,7 +207,7 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference else if (dto.Udi.EntityType == Constants.UdiEntityType.Media) { url = _publishedUrlProvider.GetMediaUrl(dto.Udi.Guid, UrlMode.Relative, culture); - IMedia? m = _mediaService.GetById(dto.Udi.Guid); + IMedia? m = GetAndCacheMediaById(dto.Udi.Guid, _appCaches.RequestCache, _mediaService); if (m is not null) { published = m.Trashed is false; @@ -207,6 +295,12 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference [DataMember(Name = "target")] public string? Target { get; set; } + [DataMember(Name = "unique")] + public Guid? Unique { get; set; } + + [DataMember(Name = "type")] + public string? Type { get; set; } + [DataMember(Name = "udi")] public GuidUdi? Udi { get; set; } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/MultiUrlPickerValueEditorValidationTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/MultiUrlPickerValueEditorValidationTests.cs index dae1d885b2..c9264d5935 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/MultiUrlPickerValueEditorValidationTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/MultiUrlPickerValueEditorValidationTests.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models.Validation; using Umbraco.Cms.Core.PropertyEditors; @@ -68,7 +69,8 @@ internal class MultiUrlPickerValueEditorValidationTests new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()), Mock.Of(), Mock.Of(), - Mock.Of()) + Mock.Of(), + AppCaches.Disabled) { ConfigurationObject = new MultiUrlPickerConfiguration(), }; From 43ac32282cdd3c5ae9f1303d3cecf7a417700b35 Mon Sep 17 00:00:00 2001 From: Rick Butterfield Date: Fri, 31 Oct 2025 12:34:36 +0000 Subject: [PATCH 3/5] Preview: Add `allow-forms` to iframe sandbox attributes (#20701) Add 'allow-forms' to iframe sandbox attributes --- src/Umbraco.Web.UI.Client/src/apps/preview/preview.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.element.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.element.ts index 4897f21190..41b13d8a66 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.element.ts @@ -63,7 +63,7 @@ export class UmbPreviewElement extends UmbLitElement { src=${this._previewUrl} title="Page preview" @load=${this.#onIFrameLoad} - sandbox="allow-scripts allow-same-origin"> + sandbox="allow-scripts allow-same-origin allow-forms">