From 35f73a8a31bb8156f8441fda905ca5103ee9fb93 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 1 Dec 2025 19:13:55 +0100 Subject: [PATCH 1/7] Migrations: Set a long timeout by default on the migration of system dates (closes #21013) (#21022) Set a long timeout by default on the migration of system dates. --- .../Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs index c4b1f3da47..6f290d455f 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/MigrateSystemDatesToUtc.cs @@ -96,6 +96,14 @@ public class MigrateSystemDatesToUtc : UnscopedMigrationBase using IScope scope = _scopeProvider.CreateScope(); using IDisposable notificationSuppression = scope.Notifications.Suppress(); + // Ensure we have a long command timeout as this migration can take a while on large tables within the database. + // If the command timeout is already longer, applied via the connection string with "Connect Timeout={timeout}" we leave it as is. + const int CommandTimeoutInSeconds = 300; + if (scope.Database.CommandTimeout < CommandTimeoutInSeconds) + { + scope.Database.CommandTimeout = CommandTimeoutInSeconds; + } + MigrateDateColumn(scope, "cmsMember", "emailConfirmedDate", timeZone); MigrateDateColumn(scope, "cmsMember", "lastLoginDate", timeZone); MigrateDateColumn(scope, "cmsMember", "lastLockoutDate", timeZone); From 1694e3bad23a95e0672f12b3fa44bd07b2f693c1 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Mon, 1 Dec 2025 20:12:23 +0100 Subject: [PATCH 2/7] Tree: Fix incorrect error notification when deleting last child (closes #20977) (#20985) * Fix infinite recursion and incorrect error notifications in tree children loading This commit addresses two critical issues in the tree item children manager: 1. **Infinite recursion vulnerability**: The #resetChildren() method called loadChildren(), which could recursively call #resetChildren() again if the underlying issue persisted, creating an infinite loop. 2. **Inappropriate error messages**: The "Menu loading failed" notification was shown even in legitimate scenarios, such as when deleting the last child of a node, where an empty tree is the expected outcome. Changes made: - Add ResetReason type ('error' | 'empty' | 'fallback') to differentiate between error states and expected empty states - Extract #loadChildrenWithOffsetPagination() as a terminal fallback method that uses only offset pagination and never calls #resetChildren(), structurally preventing recursion - Update #resetChildren() to: - Accept a reason parameter to determine whether to show error notification - Reset all retry counters (#loadChildrenRetries, #loadPrevItemsRetries, #loadNextItemsRetries) to ensure clean state - Call #loadChildrenWithOffsetPagination() instead of loadChildren() - Only show error notification when reason is 'error' - Update all call sites of #resetChildren() with appropriate reasons: - 'error' when retries are exhausted (actual failures) - 'empty' or 'fallback' when no new target is found (may be expected, e.g., after deleting items) The fix makes infinite recursion structurally impossible by creating a one-way flow: target-based loading can fall back to #resetChildren(), which calls offset-only loading that never recurses back. * Fix undefined items array causing tree to break after deletion This fixes the root cause of issue #20977 where deleting a document type would cause the tree to "forever load" with a JavaScript error. The error occurred in #getTargetResultHasValidParents() which called .every() on data without checking if it was undefined. When the API returned undefined items (e.g., after deleting the last child), this caused: TypeError: can't access property "every", e is undefined The fix adds a guard to check if data is undefined before calling .every(), returning false in that case to trigger the proper error handling flow. * Address code review feedback on terminal fallback method - Change error throwing to silent return for graceful failure handling - Remove target pagination state updates from offset-only loading method - Update JSDoc to clarify that method does not throw errors --- .../tree-item/tree-item-children.manager.ts | 91 +++++++++++++++++-- .../tree/tree-data.request-manager.ts | 5 +- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-children.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-children.manager.ts index 381dadae9b..7884dc02ce 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-children.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-children.manager.ts @@ -23,6 +23,8 @@ import { } from '@umbraco-cms/backoffice/entity-action'; import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +type ResetReason = 'error' | 'empty' | 'fallback'; + export class UmbTreeItemChildrenManager< TreeItemType extends UmbTreeItemModel = UmbTreeItemModel, TreeRootType extends UmbTreeRootModel = UmbTreeRootModel, @@ -218,7 +220,7 @@ export class UmbTreeItemChildrenManager< async #loadChildren(reload = false) { if (this.#loadChildrenRetries > this.#requestMaxRetries) { this.#loadChildrenRetries = 0; - this.#resetChildren(); + this.#resetChildren('error'); return; } @@ -302,7 +304,7 @@ export class UmbTreeItemChildrenManager< We cancel the base target and load using skip/take pagination instead. This can happen if deep linked to a non existing item or all retries have failed. */ - this.#resetChildren(); + this.#resetChildren(this.#children.getValue().length === 0 ? 'empty' : 'fallback'); } } @@ -329,7 +331,7 @@ export class UmbTreeItemChildrenManager< if (this.#loadPrevItemsRetries > this.#requestMaxRetries) { // If we have exceeded the maximum number of retries, we need to reset the base target and load from the top this.#loadPrevItemsRetries = 0; - this.#resetChildren(); + this.#resetChildren('error'); return; } @@ -378,7 +380,7 @@ export class UmbTreeItemChildrenManager< If we can't find a new end target we reload the children from the top. We cancel the base target and load using skip/take pagination instead. */ - this.#resetChildren(); + this.#resetChildren(this.#children.getValue().length === 0 ? 'empty' : 'fallback'); } } @@ -409,7 +411,7 @@ export class UmbTreeItemChildrenManager< if (this.#loadNextItemsRetries > this.#requestMaxRetries) { // If we have exceeded the maximum number of retries, we need to reset the base target and load from the top this.#loadNextItemsRetries = 0; - this.#resetChildren(); + this.#resetChildren('error'); return; } @@ -467,7 +469,7 @@ export class UmbTreeItemChildrenManager< If we can't find a new end target we reload the children from the top. We cancel the base target and load using skip/take pagination instead. */ - this.#resetChildren(); + this.#resetChildren(this.#children.getValue().length === 0 ? 'empty' : 'fallback'); } } @@ -520,12 +522,81 @@ export class UmbTreeItemChildrenManager< this.targetPagination.clear(); } - async #resetChildren() { + /** + * Loads children using offset pagination only. + * This is a "safe" fallback that does NOT: + * - Use target pagination + * - Retry with new targets + * - Call #resetChildren (preventing recursion) + * - Throw errors (fails gracefully) + */ + async #loadChildrenWithOffsetPagination(): Promise { + const repository = this.#treeContext?.getRepository(); + if (!repository) { + // Terminal fallback - fail silently rather than throwing + return; + } + + this.#isLoading.setValue(true); + + const parent = this.getStartNode() || this.getTreeItem(); + const foldersOnly = this.getFoldersOnly(); + const additionalArgs = this.getAdditionalRequestArgs(); + + const offsetPaging: UmbOffsetPaginationRequestModel = { + skip: 0, // Always from the start + take: this.offsetPagination.getPageSize(), + }; + + const { data } = parent?.unique + ? await repository.requestTreeItemsOf({ + parent: { unique: parent.unique, entityType: parent.entityType }, + skip: offsetPaging.skip, + take: offsetPaging.take, + paging: offsetPaging, + foldersOnly, + ...additionalArgs, + }) + : await repository.requestTreeRootItems({ + skip: offsetPaging.skip, + take: offsetPaging.take, + paging: offsetPaging, + foldersOnly, + ...additionalArgs, + }); + + if (data) { + const items = data.items as Array; + this.#children.setValue(items); + this.setHasChildren(data.total > 0); + this.offsetPagination.setTotalItems(data.total); + } + // Note: On error, we simply don't update state - UI shows stale data + // This is the terminal fallback, no further recovery + + this.#isLoading.setValue(false); + } + + async #resetChildren(reason: ResetReason = 'error'): Promise { + // Clear pagination state this.targetPagination.clear(); this.offsetPagination.clear(); - this.loadChildren(); - const notificationManager = await this.getContext(UMB_NOTIFICATION_CONTEXT); - notificationManager?.peek('danger', { data: { message: 'Menu loading failed. Showing the first items again.' } }); + + // Reset retry counters to prevent any lingering retry state + this.#loadChildrenRetries = 0; + this.#loadPrevItemsRetries = 0; + this.#loadNextItemsRetries = 0; + + // Load using offset pagination only - this is our terminal fallback + await this.#loadChildrenWithOffsetPagination(); + + // Only show notification for actual errors + if (reason === 'error') { + const notificationManager = await this.getContext(UMB_NOTIFICATION_CONTEXT); + notificationManager?.peek('danger', { + data: { message: 'Menu loading failed. Showing the first items again.' }, + }); + } } #onPageChange = () => this.loadNextChildren(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.ts index d30f9be8a8..c2653a2602 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/tree/tree-data.request-manager.ts @@ -229,7 +229,10 @@ export class UmbManagementApiTreeDataRequestManager< return args.take !== undefined ? args.take : this.#defaultTakeSize; } - #getTargetResultHasValidParents(data: Array, parentUnique: string | null): boolean { + #getTargetResultHasValidParents(data: Array | undefined, parentUnique: string | null): boolean { + if (!data) { + return false; + } return data.every((item) => { if (item.parent) { return item.parent.id === parentUnique; From f408d2a1b33525bb76a42f8248dc0ffdbb2340be Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 2 Dec 2025 02:09:54 +0100 Subject: [PATCH 3/7] Migrations: Optimise `ConvertLocalLinks` migration to process data in pages, to avoid having to load all property data into memory (#21003) * Optimize ConvertLocalLinks migration to process data in pages, to avoid having to load all property data into memory. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Updated obsoletion warning. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> (cherry picked from commit 742de79f4692dd6dccb671f5e1049fdf1abacfd5) --- .../Upgrade/V_15_0_0/ConvertLocalLinks.cs | 235 +++++++++++------- 1 file changed, 147 insertions(+), 88 deletions(-) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertLocalLinks.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertLocalLinks.cs index f3914b854c..73508bcc7b 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertLocalLinks.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_15_0_0/ConvertLocalLinks.cs @@ -17,6 +17,13 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0; +/// +/// Migrates local links in content and media properties from the legacy format using UDIs +/// to the new one with GUIDs. +/// +/// +/// See: https://github.com/umbraco/Umbraco-CMS/pull/17307. +/// public class ConvertLocalLinks : MigrationBase { private readonly IUmbracoContextFactory _umbracoContextFactory; @@ -30,7 +37,9 @@ public class ConvertLocalLinks : MigrationBase private readonly ICoreScopeProvider _coreScopeProvider; private readonly LocalLinkMigrationTracker _linkMigrationTracker; - [Obsolete("Use non obsoleted contructor instead")] + /// + /// Initializes a new instance of the class. + /// public ConvertLocalLinks( IMigrationContext context, IUmbracoContextFactory umbracoContextFactory, @@ -57,6 +66,10 @@ public class ConvertLocalLinks : MigrationBase _linkMigrationTracker = linkMigrationTracker; } + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal along with all other migrations to 17 in Umbraco 18.")] public ConvertLocalLinks( IMigrationContext context, IUmbracoContextFactory umbracoContextFactory, @@ -83,6 +96,7 @@ public class ConvertLocalLinks : MigrationBase { } + /// protected override void Migrate() { IEnumerable propertyEditorAliases = _localLinkProcessor.GetSupportedPropertyEditorAliases(); @@ -116,7 +130,7 @@ public class ConvertLocalLinks : MigrationBase _logger.LogInformation( "Migration starting for all properties of type: {propertyEditorAlias}", propertyEditorAlias); - if (ProcessPropertyTypes(propertyTypes, languagesById)) + if (ProcessPropertyTypes(propertyEditorAlias, propertyTypes, languagesById)) { _logger.LogInformation( "Migration succeeded for all properties of type: {propertyEditorAlias}", @@ -134,7 +148,7 @@ public class ConvertLocalLinks : MigrationBase RebuildCache = true; } - private bool ProcessPropertyTypes(IPropertyType[] propertyTypes, IDictionary languagesById) + private bool ProcessPropertyTypes(string propertyEditorAlias, IPropertyType[] propertyTypes, IDictionary languagesById) { foreach (IPropertyType propertyType in propertyTypes) { @@ -145,112 +159,157 @@ public class ConvertLocalLinks : MigrationBase ?? throw new InvalidOperationException( "The data type value editor could not be fetched."); - Sql sql = Sql() - .Select() - .From() - .InnerJoin() - .On((propertyData, contentVersion) => - propertyData.VersionId == contentVersion.Id) - .LeftJoin() - .On((contentVersion, documentVersion) => - contentVersion.Id == documentVersion.Id) - .Where( - (propertyData, contentVersion, documentVersion) => - (contentVersion.Current == true || documentVersion.Published == true) - && propertyData.PropertyTypeId == propertyType.Id); - - List propertyDataDtos = Database.Fetch(sql); - if (propertyDataDtos.Count < 1) + long propertyDataCount = Database.ExecuteScalar(BuildPropertyDataSql(propertyType, true)); + if (propertyDataCount == 0) { continue; } - var updateBatch = propertyDataDtos.Select(propertyDataDto => - UpdateBatch.For(propertyDataDto, Database.StartSnapshot(propertyDataDto))).ToList(); + _logger.LogInformation( + "Migrating {PropertyDataCount} property data values for property {PropertyTypeAlias} ({PropertyTypeKey}) with property editor alias {PropertyEditorAlias}", + propertyDataCount, + propertyType.Alias, + propertyType.Key, + propertyEditorAlias); - var updatesToSkip = new ConcurrentBag>(); - - var progress = 0; - - void HandleUpdateBatch(UpdateBatch update) + // Process in pages to avoid loading all property data from the database into memory at once. + Sql sql = BuildPropertyDataSql(propertyType); + const int PageSize = 10000; + long pageNumber = 1; + long pageCount = (propertyDataCount + PageSize - 1) / PageSize; + int processedCount = 0; + while (processedCount < propertyDataCount) { - using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); - - progress++; - if (progress % 100 == 0) + Page propertyDataDtoPage = Database.Page(pageNumber, PageSize, sql); + if (propertyDataDtoPage.Items.Count == 0) { - _logger.LogInformation(" - finíshed {progress} of {total} properties", progress, - updateBatch.Count); + break; } - PropertyDataDto propertyDataDto = update.Poco; + var updateBatchCollection = propertyDataDtoPage.Items + .Select(propertyDataDto => + UpdateBatch.For(propertyDataDto, Database.StartSnapshot(propertyDataDto))) + .ToList(); - if (ProcessPropertyDataDto(propertyDataDto, propertyType, languagesById, valueEditor) == false) - { - updatesToSkip.Add(update); - } - } + var updatesToSkip = new ConcurrentBag>(); - if (DatabaseType == DatabaseType.SQLite) - { - // SQLite locks up if we run the migration in parallel, so... let's not. - foreach (UpdateBatch update in updateBatch) + var progress = 0; + + void HandleUpdateBatch(UpdateBatch update) { - HandleUpdateBatch(update); - } - } - else - { - Parallel.ForEachAsync(updateBatch, async (update, token) => - { - //Foreach here, but we need to suppress the flow before each task, but not the actuall await of the task - Task task; - using (ExecutionContext.SuppressFlow()) + using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); + + progress++; + if (progress % 100 == 0) { - task = Task.Run( - () => - { - using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); - scope.Complete(); - HandleUpdateBatch(update); - }, - token); + _logger.LogInformation( + " - finished {Progress} of {PageTotal} properties in page {PageNumber} of {PageCount}", + progress, + updateBatchCollection.Count, + pageNumber, + pageCount); } - await task; - }).GetAwaiter().GetResult(); + PropertyDataDto propertyDataDto = update.Poco; + + if (ProcessPropertyDataDto(propertyDataDto, propertyType, languagesById, valueEditor) == false) + { + updatesToSkip.Add(update); + } + } + + if (DatabaseType == DatabaseType.SQLite) + { + // SQLite locks up if we run the migration in parallel, so... let's not. + foreach (UpdateBatch update in updateBatchCollection) + { + HandleUpdateBatch(update); + } + } + else + { + Parallel.ForEachAsync(updateBatchCollection, async (update, token) => + { + //Foreach here, but we need to suppress the flow before each task, but not the actual await of the task + Task task; + using (ExecutionContext.SuppressFlow()) + { + task = Task.Run( + () => + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.Complete(); + HandleUpdateBatch(update); + }, + token); + } + + await task; + }).GetAwaiter().GetResult(); + } + + updateBatchCollection.RemoveAll(updatesToSkip.Contains); + + if (updateBatchCollection.Any() is false) + { + _logger.LogDebug(" - no properties to convert, continuing"); + + pageNumber++; + processedCount += propertyDataDtoPage.Items.Count; + + continue; + } + + _logger.LogInformation(" - {totalConverted} properties converted, saving...", updateBatchCollection.Count); + var result = Database.UpdateBatch(updateBatchCollection, new BatchOptions { BatchSize = 100 }); + if (result != updateBatchCollection.Count) + { + throw new InvalidOperationException( + $"The database batch update was supposed to update {updateBatchCollection.Count} property DTO entries, but it updated {result} entries."); + } + + _logger.LogDebug( + "Migration completed for property type: {propertyTypeName} (id: {propertyTypeId}, alias: {propertyTypeAlias}, editor alias: {propertyTypeEditorAlias}) - {updateCount} property DTO entries updated.", + propertyType.Name, + propertyType.Id, + propertyType.Alias, + propertyType.PropertyEditorAlias, + result); + + pageNumber++; + processedCount += propertyDataDtoPage.Items.Count; } - - updateBatch.RemoveAll(updatesToSkip.Contains); - - if (updateBatch.Any() is false) - { - _logger.LogDebug(" - no properties to convert, continuing"); - continue; - } - - _logger.LogInformation(" - {totalConverted} properties converted, saving...", updateBatch.Count); - var result = Database.UpdateBatch(updateBatch, new BatchOptions { BatchSize = 100 }); - if (result != updateBatch.Count) - { - throw new InvalidOperationException( - $"The database batch update was supposed to update {updateBatch.Count} property DTO entries, but it updated {result} entries."); - } - - _logger.LogDebug( - "Migration completed for property type: {propertyTypeName} (id: {propertyTypeId}, alias: {propertyTypeAlias}, editor alias: {propertyTypeEditorAlias}) - {updateCount} property DTO entries updated.", - propertyType.Name, - propertyType.Id, - propertyType.Alias, - propertyType.PropertyEditorAlias, - result); } return true; } - private bool ProcessPropertyDataDto(PropertyDataDto propertyDataDto, IPropertyType propertyType, - IDictionary languagesById, IDataValueEditor valueEditor) + private Sql BuildPropertyDataSql(IPropertyType propertyType, bool isCount = false) + { + Sql sql = isCount + ? Sql().SelectCount() + : Sql().Select(); + + sql = sql.From() + .InnerJoin() + .On((propertyData, contentVersion) => + propertyData.VersionId == contentVersion.Id) + .LeftJoin() + .On((contentVersion, documentVersion) => + contentVersion.Id == documentVersion.Id) + .Where( + (propertyData, contentVersion, documentVersion) => + (contentVersion.Current || documentVersion.Published) + && propertyData.PropertyTypeId == propertyType.Id); + + return sql; + } + + private bool ProcessPropertyDataDto( + PropertyDataDto propertyDataDto, + IPropertyType propertyType, + IDictionary languagesById, + IDataValueEditor valueEditor) { // NOTE: some old property data DTOs can have variance defined, even if the property type no longer varies var culture = propertyType.VariesByCulture() From 706ac2d8f6f31185af5cde7fc154aaa48e7e23c3 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 2 Dec 2025 10:20:08 +0900 Subject: [PATCH 4/7] Static files: Fix tree to only provide items from expected folders (closes #20962) (#21001) * Applies checks for root folders to static file tree service. * Add integration tests. * Fix ancestor test. * Amends from code review. * Integration test compatibility suppressions. * Reverted breaking change in test base class. (cherry picked from commit 84c15ff4d7b15a7bcfa93af4dbb9631384cc84af) --- .../Tree/StaticFileTreeControllerBase.cs | 4 +- .../FileSystem/FileSystemTreeServiceBase.cs | 6 +- .../PhysicalFileSystemTreeService.cs | 21 ++++ src/Umbraco.Web.UI/Program.cs | 2 - .../CompatibilitySuppressions.xml | 75 ++++++++++++++- .../Trees/FileSystemTreeServiceTestsBase.cs | 16 ++-- .../Trees/PartialViewTreeServiceTests.cs | 16 ++-- .../PhysicalFileSystemTreeServiceTests.cs | 95 +++++++++++++++++++ .../Services/Trees/ScriptTreeServiceTests.cs | 17 ++-- .../Trees/StyleSheetTreeServiceTests.cs | 16 ++-- 10 files changed, 229 insertions(+), 39 deletions(-) create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PhysicalFileSystemTreeServiceTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs index f7f291a663..7d75b2df2b 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs @@ -34,14 +34,14 @@ public class StaticFileTreeControllerBase : FileSystemTreeControllerBase protected override IFileSystem FileSystem { get; } - protected string[] GetDirectories(string path) => + protected override string[] GetDirectories(string path) => IsTreeRootPath(path) ? _allowedRootFolders : IsAllowedPath(path) ? _fileSystemTreeService.GetDirectories(path) : Array.Empty(); - protected string[] GetFiles(string path) + protected override string[] GetFiles(string path) => IsTreeRootPath(path) || IsAllowedPath(path) == false ? Array.Empty() : _fileSystemTreeService.GetFiles(path); diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs index 894c837dd8..368684dea7 100644 --- a/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Api.Management.Extensions; +using Umbraco.Cms.Api.Management.Extensions; using Umbraco.Cms.Api.Management.ViewModels.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.IO; @@ -68,12 +68,12 @@ public abstract class FileSystemTreeServiceBase : IFileSystemTreeService .ToArray(); } - public string[] GetDirectories(string path) => FileSystem + public virtual string[] GetDirectories(string path) => FileSystem .GetDirectories(path) .OrderBy(directory => directory) .ToArray(); - public string[] GetFiles(string path) => FileSystem + public virtual string[] GetFiles(string path) => FileSystem .GetFiles(path) .Where(FilterFile) .OrderBy(file => file) diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/PhysicalFileSystemTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/PhysicalFileSystemTreeService.cs index 3ee03e4927..22943eb0e9 100644 --- a/src/Umbraco.Cms.Api.Management/Services/FileSystem/PhysicalFileSystemTreeService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/PhysicalFileSystemTreeService.cs @@ -4,10 +4,31 @@ namespace Umbraco.Cms.Api.Management.Services.FileSystem; public class PhysicalFileSystemTreeService : FileSystemTreeServiceBase, IPhysicalFileSystemTreeService { + private static readonly string[] _allowedRootFolders = { $"{Path.DirectorySeparatorChar}App_Plugins", $"{Path.DirectorySeparatorChar}wwwroot" }; + private readonly IFileSystem _physicalFileSystem; protected override IFileSystem FileSystem => _physicalFileSystem; public PhysicalFileSystemTreeService(IPhysicalFileSystem physicalFileSystem) => _physicalFileSystem = physicalFileSystem; + + /// + public override string[] GetDirectories(string path) => + IsTreeRootPath(path) + ? _allowedRootFolders + : IsAllowedPath(path) + ? base.GetDirectories(path) + : Array.Empty(); + + /// + public override string[] GetFiles(string path) + => IsTreeRootPath(path) || IsAllowedPath(path) is false + ? [] + : base.GetFiles(path); + + private static bool IsTreeRootPath(string path) => path == Path.DirectorySeparatorChar.ToString(); + + private static bool IsAllowedPath(string path) => _allowedRootFolders.Contains(path) || _allowedRootFolders.Any(folder => path.StartsWith($"{folder}{Path.DirectorySeparatorChar}")); + } diff --git a/src/Umbraco.Web.UI/Program.cs b/src/Umbraco.Web.UI/Program.cs index a9834ed784..aa718b5ec3 100644 --- a/src/Umbraco.Web.UI/Program.cs +++ b/src/Umbraco.Web.UI/Program.cs @@ -3,9 +3,7 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.CreateUmbracoBuilder() .AddBackOffice() .AddWebsite() -#if UseDeliveryApi .AddDeliveryApi() -#endif .AddComposers() .Build(); diff --git a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml index f5c79dd905..44e4a17dc5 100644 --- a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml +++ b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml @@ -6,6 +6,79 @@ M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.ContentEditingServiceTests.Updating_Single_Variant_Name_Does_Not_Change_Update_Dates_Of_Other_Vaiants lib/net9.0/Umbraco.Tests.Integration.dll lib/net9.0/Umbraco.Tests.Integration.dll + M:Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees.PartialViewTreeServiceTests.Can_Get_Ancestors_From_StyleSheet_Tree_Service + lib/net10.0/Umbraco.Tests.Integration.dll + lib/net10.0/Umbraco.Tests.Integration.dll true - \ No newline at end of file + + CP0002 + M:Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees.PartialViewTreeServiceTests.Can_Get_PathViewModels_From_StyleSheet_Tree_Service + lib/net10.0/Umbraco.Tests.Integration.dll + lib/net10.0/Umbraco.Tests.Integration.dll + true + + + CP0002 + M:Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees.PartialViewTreeServiceTests.Can_Get_Siblings_From_PartialView_Tree_Service + lib/net10.0/Umbraco.Tests.Integration.dll + lib/net10.0/Umbraco.Tests.Integration.dll + true + + + CP0002 + M:Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees.ScriptTreeServiceTests.Can_Get_Ancestors_From_StyleSheet_Tree_Service + lib/net10.0/Umbraco.Tests.Integration.dll + lib/net10.0/Umbraco.Tests.Integration.dll + true + + + CP0002 + M:Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees.ScriptTreeServiceTests.Can_Get_PathViewModels_From_StyleSheet_Tree_Service + lib/net10.0/Umbraco.Tests.Integration.dll + lib/net10.0/Umbraco.Tests.Integration.dll + true + + + CP0002 + M:Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees.ScriptTreeServiceTests.Can_Get_Siblings_From_Script_Tree_Service + lib/net10.0/Umbraco.Tests.Integration.dll + lib/net10.0/Umbraco.Tests.Integration.dll + true + + + CP0002 + M:Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees.StyleSheetTreeServiceTests.Can_Get_Ancestors_From_StyleSheet_Tree_Service + lib/net10.0/Umbraco.Tests.Integration.dll + lib/net10.0/Umbraco.Tests.Integration.dll + true + + + CP0002 + M:Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees.StyleSheetTreeServiceTests.Can_Get_PathViewModels_From_StyleSheet_Tree_Service + lib/net10.0/Umbraco.Tests.Integration.dll + lib/net10.0/Umbraco.Tests.Integration.dll + true + + + CP0002 + M:Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees.StyleSheetTreeServiceTests.Can_Get_Siblings_From_StyleSheet_Tree_Service + lib/net10.0/Umbraco.Tests.Integration.dll + lib/net10.0/Umbraco.Tests.Integration.dll + true + + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Examine.Lucene.UmbracoExamine.IndexInitializer.#ctor(Umbraco.Cms.Core.Strings.IShortStringHelper,Umbraco.Cms.Core.PropertyEditors.PropertyEditorCollection,Umbraco.Cms.Core.PropertyEditors.MediaUrlGeneratorCollection,Umbraco.Cms.Core.Scoping.IScopeProvider,Microsoft.Extensions.Logging.ILoggerFactory,Microsoft.Extensions.Options.IOptions{Umbraco.Cms.Core.Configuration.Models.ContentSettings},Umbraco.Cms.Core.Services.ILocalizationService,Umbraco.Cms.Core.Services.IContentTypeService,Umbraco.Cms.Core.Services.IDocumentUrlService,Umbraco.Cms.Core.Services.ILanguageService) + lib/net10.0/Umbraco.Tests.Integration.dll + lib/net10.0/Umbraco.Tests.Integration.dll + true + + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.ContentEditingServiceTests.Relate(Umbraco.Cms.Core.Models.IContent,Umbraco.Cms.Core.Models.IContent) + lib/net10.0/Umbraco.Tests.Integration.dll + lib/net10.0/Umbraco.Tests.Integration.dll + true + + diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/FileSystemTreeServiceTestsBase.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/FileSystemTreeServiceTestsBase.cs index 0ae45feb11..9e9c3af00f 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/FileSystemTreeServiceTestsBase.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/FileSystemTreeServiceTestsBase.cs @@ -36,21 +36,23 @@ public abstract class FileSystemTreeServiceTestsBase : UmbracoIntegrationTest GetStylesheetsFileSystem(), GetScriptsFileSystem(), null); + + CreateFiles(); + } + + protected virtual void CreateFiles() + { for (int i = 0; i < 10; i++) { - using var stream = CreateStream(Path.Join("tests")); + using var stream = CreateStream(); TestFileSystem.AddFile($"file{i}{FileExtension}", stream); } } protected static Stream CreateStream(string contents = null) { - if (string.IsNullOrEmpty(contents)) - { - contents = "/* test */"; - } - - var bytes = Encoding.UTF8.GetBytes(contents); + const string DefaultFileContent = "/* test */"; + var bytes = Encoding.UTF8.GetBytes(contents ?? DefaultFileContent); return new MemoryStream(bytes); } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PartialViewTreeServiceTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PartialViewTreeServiceTests.cs index f2068a4b70..03179f8321 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PartialViewTreeServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PartialViewTreeServiceTests.cs @@ -15,7 +15,7 @@ public class PartialViewTreeServiceTests : FileSystemTreeServiceTestsBase protected override IFileSystem? GetPartialViewsFileSystem() => TestFileSystem; [Test] - public void Can_Get_Siblings_From_PartialView_Tree_Service() + public void Can_Get_Siblings() { var service = new PartialViewTreeService(FileSystems); @@ -31,20 +31,20 @@ public class PartialViewTreeServiceTests : FileSystemTreeServiceTestsBase } [Test] - public void Can_Get_Ancestors_From_StyleSheet_Tree_Service() + public void Can_Get_Ancestors() { var service = new PartialViewTreeService(FileSystems); var path = Path.Join("tests", $"file5{FileExtension}"); - FileSystemTreeItemPresentationModel[] treeModel = service.GetAncestorModels(path, true); + FileSystemTreeItemPresentationModel[] treeModels = service.GetAncestorModels(path, true); - Assert.IsNotEmpty(treeModel); - Assert.AreEqual(treeModel.Length, 2); - Assert.AreEqual(treeModel[0].Name, "tests"); + Assert.IsNotEmpty(treeModels); + Assert.AreEqual(treeModels.Length, 2); + Assert.AreEqual(treeModels[0].Name, "tests"); } [Test] - public void Can_Get_PathViewModels_From_StyleSheet_Tree_Service() + public void Can_Get_PathViewModels() { var service = new PartialViewTreeService(FileSystems); @@ -60,7 +60,7 @@ public class PartialViewTreeServiceTests : FileSystemTreeServiceTestsBase var service = new PartialViewTreeService(FileSystems); for (int i = 0; i < 2; i++) { - using var stream = CreateStream(Path.Join("tests")); + using var stream = CreateStream(); TestFileSystem.AddFile($"file{i}.invalid", stream); } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PhysicalFileSystemTreeServiceTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PhysicalFileSystemTreeServiceTests.cs new file mode 100644 index 0000000000..cfc9ea9068 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PhysicalFileSystemTreeServiceTests.cs @@ -0,0 +1,95 @@ +using System.IO; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Services.FileSystem; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees; + +public class PhysicalFileSystemTreeServiceTests : FileSystemTreeServiceTestsBase +{ + protected override string FileExtension { get; set; } = string.Empty; + + protected override string FileSystemPath => "/"; + + protected override void CreateFiles() + { + var paths = new[] + { + Path.Join("App_Plugins", "test-extension", "test.js"), + Path.Join("wwwroot", "css", "test.css"), + Path.Join("wwwroot", "css", "test2.css"), + Path.Join("wwwroot", "css", "test3.css"), + Path.Join("wwwroot", "css", "test4.css"), + Path.Join("Program.cs"), + }; + foreach (var path in paths) + { + var stream = CreateStream(); + TestFileSystem.AddFile(path, stream); + } + } + + [Test] + public void Can_Get_Siblings() + { + var service = CreateService(); + + FileSystemTreeItemPresentationModel[] treeModels = service.GetSiblingsViewModels("wwwroot/css/test2.css", 1, 1, out long before, out var after); + + Assert.AreEqual(3, treeModels.Length); + Assert.AreEqual(treeModels[0].Name, "test.css"); + Assert.AreEqual(treeModels[1].Name, "test2.css"); + Assert.AreEqual(treeModels[2].Name, "test3.css"); + Assert.AreEqual(before, 0); + Assert.AreEqual(after, 1); + } + + [Test] + public void Can_Get_Ancestors() + { + var service = CreateService(); + + FileSystemTreeItemPresentationModel[] treeModels = service.GetAncestorModels(Path.Join("wwwroot", "css", "test.css"), true); + + Assert.IsNotEmpty(treeModels); + Assert.AreEqual(treeModels.Length, 3); + Assert.AreEqual(treeModels[0].Name, "wwwroot"); + Assert.AreEqual(treeModels[1].Name, "css"); + Assert.AreEqual(treeModels[2].Name, "test.css"); + } + + [Test] + public void Can_Get_Root_PathViewModels() + { + var service = CreateService(); + + FileSystemTreeItemPresentationModel[] treeModels = service.GetPathViewModels(string.Empty, 0, int.MaxValue, out var totalItems); + + Assert.IsNotEmpty(treeModels); + Assert.AreEqual(totalItems, 2); + Assert.AreEqual(treeModels.Length, totalItems); + Assert.AreEqual(treeModels[0].Name, "App_Plugins"); + Assert.AreEqual(treeModels[1].Name, "wwwroot"); + } + + [Test] + public void Can_Get_Child_PathViewModels() + { + var service = CreateService(); + + FileSystemTreeItemPresentationModel[] treeModels = service.GetPathViewModels("App_Plugins/test-extension", 0, int.MaxValue, out var totalItems); + + Assert.IsNotEmpty(treeModels); + Assert.AreEqual(totalItems, 1); + Assert.AreEqual(treeModels.Length, totalItems); + Assert.AreEqual(treeModels[0].Name, "test.js"); + } + + private PhysicalFileSystemTreeService CreateService() + { + var physicalFileSystem = new PhysicalFileSystem(IOHelper, HostingEnvironment, LoggerFactory.CreateLogger(), HostingEnvironment.MapPathWebRoot(FileSystemPath), HostingEnvironment.ToAbsolute(FileSystemPath)); + return new PhysicalFileSystemTreeService(physicalFileSystem); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/ScriptTreeServiceTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/ScriptTreeServiceTests.cs index 874d59fd15..c8290f9267 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/ScriptTreeServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/ScriptTreeServiceTests.cs @@ -8,12 +8,13 @@ namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees; public class ScriptTreeServiceTests : FileSystemTreeServiceTestsBase { protected override string FileExtension { get; set; } = ".js"; + protected override string FileSystemPath => GlobalSettings.UmbracoScriptsPath; protected override IFileSystem? GetScriptsFileSystem() => TestFileSystem; [Test] - public void Can_Get_Siblings_From_Script_Tree_Service() + public void Can_Get_Siblings() { var service = new ScriptTreeService(FileSystems); @@ -29,20 +30,20 @@ public class ScriptTreeServiceTests : FileSystemTreeServiceTestsBase } [Test] - public void Can_Get_Ancestors_From_StyleSheet_Tree_Service() + public void Can_Get_Ancestors() { var service = new ScriptTreeService(FileSystems); var path = Path.Join("tests", $"file5{FileExtension}"); - FileSystemTreeItemPresentationModel[] treeModel = service.GetAncestorModels(path, true); + FileSystemTreeItemPresentationModel[] treeModels = service.GetAncestorModels(path, true); - Assert.IsNotEmpty(treeModel); - Assert.AreEqual(treeModel.Length, 2); - Assert.AreEqual(treeModel[0].Name, "tests"); + Assert.IsNotEmpty(treeModels); + Assert.AreEqual(treeModels.Length, 2); + Assert.AreEqual(treeModels[0].Name, "tests"); } [Test] - public void Can_Get_PathViewModels_From_StyleSheet_Tree_Service() + public void Can_Get_PathViewModels() { var service = new ScriptTreeService(FileSystems); @@ -58,7 +59,7 @@ public class ScriptTreeServiceTests : FileSystemTreeServiceTestsBase var service = new ScriptTreeService(FileSystems); for (int i = 0; i < 2; i++) { - using var stream = CreateStream(Path.Join("tests")); + using var stream = CreateStream(); TestFileSystem.AddFile($"file{i}.invalid", stream); } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/StyleSheetTreeServiceTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/StyleSheetTreeServiceTests.cs index c3300bcbb8..8c9cfd690a 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/StyleSheetTreeServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/StyleSheetTreeServiceTests.cs @@ -14,7 +14,7 @@ public class StyleSheetTreeServiceTests : FileSystemTreeServiceTestsBase protected override IFileSystem? GetStylesheetsFileSystem() => TestFileSystem; [Test] - public void Can_Get_Siblings_From_StyleSheet_Tree_Service() + public void Can_Get_Siblings() { var service = new StyleSheetTreeService(FileSystems); @@ -30,20 +30,20 @@ public class StyleSheetTreeServiceTests : FileSystemTreeServiceTestsBase } [Test] - public void Can_Get_Ancestors_From_StyleSheet_Tree_Service() + public void Can_Get_Ancestors() { var service = new StyleSheetTreeService(FileSystems); var path = Path.Join("tests", $"file5{FileExtension}"); - FileSystemTreeItemPresentationModel[] treeModel = service.GetAncestorModels(path, true); + FileSystemTreeItemPresentationModel[] treeModels = service.GetAncestorModels(path, true); - Assert.IsNotEmpty(treeModel); - Assert.AreEqual(treeModel.Length, 2); - Assert.AreEqual(treeModel[0].Name, "tests"); + Assert.IsNotEmpty(treeModels); + Assert.AreEqual(treeModels.Length, 2); + Assert.AreEqual(treeModels[0].Name, "tests"); } [Test] - public void Can_Get_PathViewModels_From_StyleSheet_Tree_Service() + public void Can_Get_PathViewModels() { var service = new StyleSheetTreeService(FileSystems); @@ -59,7 +59,7 @@ public class StyleSheetTreeServiceTests : FileSystemTreeServiceTestsBase var service = new StyleSheetTreeService(FileSystems); for (int i = 0; i < 2; i++) { - using var stream = CreateStream(Path.Join("tests")); + using var stream = CreateStream(); TestFileSystem.AddFile($"file{i}.invalid", stream); } From 7d0101170eb4b0e0430612282bd89b9c41a39d4d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 2 Dec 2025 05:25:43 +0100 Subject: [PATCH 5/7] Management API: Return not found from request for content references when entity does not exist (closes #20997) (#20999) * Return not found when request for content references when entity does not exist. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Move check for entity existence from controller to the service. * Update OpenApi.json. * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Addressed points raised in code review. * Update OpenApi.json * Resolved breaking changes. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> (cherry picked from commit da94e0953bb0c7f701e21eb8d886b239b0aeeac3) --- .../Content/ContentControllerBase.cs | 16 +++- .../ReferencedByDocumentController.cs | 51 ++++++++--- ...ReferencedDescendantsDocumentController.cs | 41 +++++++-- .../References/ReferencedByMediaController.cs | 51 ++++++++--- .../ReferencedDescendantsMediaController.cs | 42 +++++++-- .../ReferencedByMemberController.cs | 51 ++++++++--- .../ReferencedDescendantsMemberController.cs | 53 +++++++++--- src/Umbraco.Cms.Api.Management/OpenApi.json | 86 ++++++++++++++++++- .../Services/ITrackedReferencesService.cs | 38 ++++++++ .../GetReferencesOperationStatus.cs | 7 ++ .../Services/TrackedReferencesService.cs | 34 ++++++++ .../Services/TrackedReferencesServiceTests.cs | 41 +++++++-- 12 files changed, 443 insertions(+), 68 deletions(-) create mode 100644 src/Umbraco.Core/Services/OperationStatus/GetReferencesOperationStatus.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs index 24ba42b2d5..aeb99e2442 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Content/ContentControllerBase.cs @@ -1,7 +1,5 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Management.ViewModels.Content; -using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.ContentEditing.Validation; using Umbraco.Cms.Core.PropertyEditors.Validation; @@ -12,7 +10,6 @@ namespace Umbraco.Cms.Api.Management.Controllers.Content; public abstract class ContentControllerBase : ManagementApiControllerBase { - protected IActionResult ContentEditingOperationStatusResult(ContentEditingOperationStatus status) => OperationStatusResult(status, problemDetailsBuilder => status switch { @@ -98,6 +95,17 @@ public abstract class ContentControllerBase : ManagementApiControllerBase .Build()), }); + protected IActionResult GetReferencesOperationStatusResult(GetReferencesOperationStatus status) + => OperationStatusResult(status, problemDetailsBuilder => status switch + { + GetReferencesOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder + .WithTitle("The requested content could not be found") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder + .WithTitle("Unknown get references operation status.") + .Build()), + }); + protected IActionResult ContentEditingOperationStatusResult( ContentEditingOperationStatus status, TContentModelBase requestModel, diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs index 6a1d9b2824..a695895d1d 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs @@ -4,8 +4,10 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Document.References; @@ -15,22 +17,16 @@ public class ReferencedByDocumentController : DocumentControllerBase private readonly ITrackedReferencesService _trackedReferencesService; private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; - public ReferencedByDocumentController(ITrackedReferencesService trackedReferencesService, IRelationTypePresentationFactory relationTypePresentationFactory) + public ReferencedByDocumentController( + ITrackedReferencesService trackedReferencesService, + IRelationTypePresentationFactory relationTypePresentationFactory) { _trackedReferencesService = trackedReferencesService; _relationTypePresentationFactory = relationTypePresentationFactory; } - /// - /// Gets a paged list of tracked references for the current item, so you can see where an item is being used. - /// - /// - /// Used by info tabs on content, media etc. and for the delete and unpublish of single items. - /// This is basically finding parents of relations. - /// - [HttpGet("{id:guid}/referenced-by")] - [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [Obsolete("Use the ReferencedBy2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedBy2 will be renamed back to ReferencedBy.")] + [NonAction] public async Task>> ReferencedBy( CancellationToken cancellationToken, Guid id, @@ -47,4 +43,37 @@ public class ReferencedByDocumentController : DocumentControllerBase return pagedViewModel; } + + /// + /// Gets a paged list of tracked references for the current item, so you can see where an item is being used. + /// + /// + /// Used by info tabs on content, media etc. and for the delete and unpublish of single items. + /// This is basically finding parents of relations. + /// + [HttpGet("{id:guid}/referenced-by")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedBy2( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Document, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } + + var pagedViewModel = new PagedViewModel + { + Total = relationItemsAttempt.Result.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItemsAttempt.Result.Items), + }; + + return Ok(pagedViewModel); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedDescendantsDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedDescendantsDocumentController.cs index 138b919628..3940153308 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedDescendantsDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedDescendantsDocumentController.cs @@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Document.References; @@ -15,12 +17,32 @@ public class ReferencedDescendantsDocumentController : DocumentControllerBase private readonly ITrackedReferencesService _trackedReferencesSkipTakeService; private readonly IUmbracoMapper _umbracoMapper; - public ReferencedDescendantsDocumentController(ITrackedReferencesService trackedReferencesSkipTakeService, IUmbracoMapper umbracoMapper) + public ReferencedDescendantsDocumentController( + ITrackedReferencesService trackedReferencesSkipTakeService, + IUmbracoMapper umbracoMapper) { _trackedReferencesSkipTakeService = trackedReferencesSkipTakeService; _umbracoMapper = umbracoMapper; } + [Obsolete("Use the ReferencedDescendants2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedDescendants2 will be renamed back to ReferencedDescendants.")] + [NonAction] + public async Task>> ReferencedDescendants( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, skip, take, true); + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = _umbracoMapper.MapEnumerable(relationItems.Items), + }; + + return pagedViewModel; + } + /// /// Gets a paged list of the descendant nodes of the current item used in any kind of relation. /// @@ -32,19 +54,26 @@ public class ReferencedDescendantsDocumentController : DocumentControllerBase [HttpGet("{id:guid}/referenced-descendants")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] - public async Task>> ReferencedDescendants( + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedDescendants2( CancellationToken cancellationToken, Guid id, int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, skip, take, true); + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, UmbracoObjectTypes.Document, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } + var pagedViewModel = new PagedViewModel { - Total = relationItems.Total, - Items = _umbracoMapper.MapEnumerable(relationItems.Items), + Total = relationItemsAttempt.Result.Total, + Items = _umbracoMapper.MapEnumerable(relationItemsAttempt.Result.Items), }; - return pagedViewModel; + return Ok(pagedViewModel); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs index e9e62504ed..eaee4269cd 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs @@ -4,8 +4,10 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Media.References; @@ -15,22 +17,16 @@ public class ReferencedByMediaController : MediaControllerBase private readonly ITrackedReferencesService _trackedReferencesService; private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; - public ReferencedByMediaController(ITrackedReferencesService trackedReferencesService, IRelationTypePresentationFactory relationTypePresentationFactory) + public ReferencedByMediaController( + ITrackedReferencesService trackedReferencesService, + IRelationTypePresentationFactory relationTypePresentationFactory) { _trackedReferencesService = trackedReferencesService; _relationTypePresentationFactory = relationTypePresentationFactory; } - /// - /// Gets a page list of tracked references for the current item, so you can see where an item is being used. - /// - /// - /// Used by info tabs on content, media etc. and for the delete and unpublish of single items. - /// This is basically finding parents of relations. - /// - [HttpGet("{id:guid}/referenced-by")] - [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [Obsolete("Use the ReferencedBy2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedBy2 will be renamed back to ReferencedBy.")] + [NonAction] public async Task>> ReferencedBy( CancellationToken cancellationToken, Guid id, @@ -47,4 +43,37 @@ public class ReferencedByMediaController : MediaControllerBase return pagedViewModel; } + + /// + /// Gets a page list of tracked references for the current item, so you can see where an item is being used. + /// + /// + /// Used by info tabs on content, media etc. and for the delete and unpublish of single items. + /// This is basically finding parents of relations. + /// + [HttpGet("{id:guid}/referenced-by")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedBy2( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Media, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } + + var pagedViewModel = new PagedViewModel + { + Total = relationItemsAttempt.Result.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItemsAttempt.Result.Items), + }; + + return Ok(pagedViewModel); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedDescendantsMediaController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedDescendantsMediaController.cs index c6c16cb222..c78b34a7ef 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedDescendantsMediaController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedDescendantsMediaController.cs @@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Media.References; @@ -14,13 +16,32 @@ public class ReferencedDescendantsMediaController : MediaControllerBase { private readonly ITrackedReferencesService _trackedReferencesSkipTakeService; private readonly IUmbracoMapper _umbracoMapper; - - public ReferencedDescendantsMediaController(ITrackedReferencesService trackedReferencesSkipTakeService, IUmbracoMapper umbracoMapper) + public ReferencedDescendantsMediaController( + ITrackedReferencesService trackedReferencesSkipTakeService, + IUmbracoMapper umbracoMapper) { _trackedReferencesSkipTakeService = trackedReferencesSkipTakeService; _umbracoMapper = umbracoMapper; } + [Obsolete("Use the ReferencedDescendants2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedDescendants2 will be renamed back to ReferencedDescendants.")] + [NonAction] + public async Task>> ReferencedDescendants( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + PagedModel relationItems = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, skip, take, true); + var pagedViewModel = new PagedViewModel + { + Total = relationItems.Total, + Items = _umbracoMapper.MapEnumerable(relationItems.Items), + }; + + return pagedViewModel; + } + /// /// Gets a page list of the child nodes of the current item used in any kind of relation. /// @@ -32,19 +53,26 @@ public class ReferencedDescendantsMediaController : MediaControllerBase [HttpGet("{id:guid}/referenced-descendants")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] - public async Task>> ReferencedDescendants( + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedDescendants2( CancellationToken cancellationToken, Guid id, int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, skip, take, true); + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, UmbracoObjectTypes.Media, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } + var pagedViewModel = new PagedViewModel { - Total = relationItems.Total, - Items = _umbracoMapper.MapEnumerable(relationItems.Items), + Total = relationItemsAttempt.Result.Total, + Items = _umbracoMapper.MapEnumerable(relationItemsAttempt.Result.Items), }; - return pagedViewModel; + return Ok(pagedViewModel); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs index 69237278a5..4e03fbfbf2 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedByMemberController.cs @@ -4,8 +4,10 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Member.References; @@ -15,22 +17,16 @@ public class ReferencedByMemberController : MemberControllerBase private readonly ITrackedReferencesService _trackedReferencesService; private readonly IRelationTypePresentationFactory _relationTypePresentationFactory; - public ReferencedByMemberController(ITrackedReferencesService trackedReferencesService, IRelationTypePresentationFactory relationTypePresentationFactory) + public ReferencedByMemberController( + ITrackedReferencesService trackedReferencesService, + IRelationTypePresentationFactory relationTypePresentationFactory) { _trackedReferencesService = trackedReferencesService; _relationTypePresentationFactory = relationTypePresentationFactory; } - /// - /// Gets a page list of tracked references for the current item, so you can see where an item is being used. - /// - /// - /// Used by info tabs on content, media etc. and for the delete and unpublish of single items. - /// This is basically finding parents of relations. - /// - [HttpGet("{id:guid}/referenced-by")] - [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [Obsolete("Use the ReferencedBy2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedBy2 will be renamed back to ReferencedBy.")] + [NonAction] public async Task>> ReferencedBy( CancellationToken cancellationToken, Guid id, @@ -47,4 +43,37 @@ public class ReferencedByMemberController : MemberControllerBase return pagedViewModel; } + + /// + /// Gets a page list of tracked references for the current item, so you can see where an item is being used. + /// + /// + /// Used by info tabs on content, media etc. and for the delete and unpublish of single items. + /// This is basically finding parents of relations. + /// + [HttpGet("{id:guid}/referenced-by")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedBy2( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } + + var pagedViewModel = new PagedViewModel + { + Total = relationItemsAttempt.Result.Total, + Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItemsAttempt.Result.Items), + }; + + return Ok(pagedViewModel); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedDescendantsMemberController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedDescendantsMemberController.cs index aa86950e6e..8521ea3805 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedDescendantsMemberController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Member/References/ReferencedDescendantsMemberController.cs @@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Api.Management.Controllers.Member.References; @@ -15,23 +17,16 @@ public class ReferencedDescendantsMemberController : MemberControllerBase private readonly ITrackedReferencesService _trackedReferencesSkipTakeService; private readonly IUmbracoMapper _umbracoMapper; - public ReferencedDescendantsMemberController(ITrackedReferencesService trackedReferencesSkipTakeService, IUmbracoMapper umbracoMapper) + public ReferencedDescendantsMemberController( + ITrackedReferencesService trackedReferencesSkipTakeService, + IUmbracoMapper umbracoMapper) { _trackedReferencesSkipTakeService = trackedReferencesSkipTakeService; _umbracoMapper = umbracoMapper; } - /// - /// Gets a page list of the child nodes of the current item used in any kind of relation. - /// - /// - /// Used when deleting and unpublishing a single item to check if this item has any descending items that are in any - /// kind of relation. - /// This is basically finding the descending items which are children in relations. - /// - [HttpGet("{id:guid}/referenced-descendants")] - [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [Obsolete("Use the ReferencedDescendants2 action method instead. Scheduled for removal in Umbraco 19, when ReferencedDescendants2 will be renamed back to ReferencedDescendants.")] + [NonAction] public async Task>> ReferencedDescendants( CancellationToken cancellationToken, Guid id, @@ -47,4 +42,38 @@ public class ReferencedDescendantsMemberController : MemberControllerBase return pagedViewModel; } + + /// + /// Gets a page list of the child nodes of the current item used in any kind of relation. + /// + /// + /// Used when deleting and unpublishing a single item to check if this item has any descending items that are in any + /// kind of relation. + /// This is basically finding the descending items which are children in relations. + /// + [HttpGet("{id:guid}/referenced-descendants")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ReferencedDescendants2( + CancellationToken cancellationToken, + Guid id, + int skip = 0, + int take = 20) + { + Attempt, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesSkipTakeService.GetPagedDescendantsInReferencesAsync(id, UmbracoObjectTypes.Member, skip, take, true); + + if (relationItemsAttempt.Success is false) + { + return GetReferencesOperationStatusResult(relationItemsAttempt.Status); + } + + var pagedViewModel = new PagedViewModel + { + Total = relationItemsAttempt.Result.Total, + Items = _umbracoMapper.MapEnumerable(relationItemsAttempt.Result.Items), + }; + + return Ok(pagedViewModel); + } } diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 86d522dd3a..6c6801fd24 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -9628,6 +9628,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -9692,6 +9706,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -17348,6 +17376,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -17412,6 +17454,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -21653,6 +21709,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -21717,6 +21787,20 @@ } } }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, "401": { "description": "The resource is protected and requires an authentication token" }, @@ -49575,4 +49659,4 @@ "name": "Webhook" } ] -} \ No newline at end of file +} diff --git a/src/Umbraco.Core/Services/ITrackedReferencesService.cs b/src/Umbraco.Core/Services/ITrackedReferencesService.cs index cc97954846..ad84c79fcc 100644 --- a/src/Umbraco.Core/Services/ITrackedReferencesService.cs +++ b/src/Umbraco.Core/Services/ITrackedReferencesService.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -16,8 +17,27 @@ public interface ITrackedReferencesService /// dependencies (isDependency field is set to true). /// /// A paged result of objects. + [Obsolete("Use GetPagedRelationsForItemAsync which returns an Attempt with operation status. Scheduled for removal in Umbraco 19.")] Task> GetPagedRelationsForItemAsync(Guid key, long skip, long take, bool filterMustBeIsDependency); + /// + /// Gets a paged result of items which are in relation with the current item. + /// Basically, shows the items which depend on the current item. + /// + /// The identifier of the entity to retrieve relations for. + /// The Umbraco object type of the parent. + /// The amount of items to skip + /// The amount of items to take. + /// + /// A boolean indicating whether to filter only the RelationTypes which are + /// dependencies (isDependency field is set to true). + /// + /// A paged result of objects. + async Task, GetReferencesOperationStatus>> GetPagedRelationsForItemAsync(Guid key, UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) +#pragma warning disable CS0618 // Type or member is obsolete + => Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, await GetPagedRelationsForItemAsync(key, skip, take, filterMustBeIsDependency)); +#pragma warning restore CS0618 // Type or member is obsolete + /// /// Gets a paged result of items which are in relation with an item in the recycle bin. /// @@ -42,8 +62,26 @@ public interface ITrackedReferencesService /// dependencies (isDependency field is set to true). /// /// A paged result of objects. + [Obsolete("Use GetPagedDescendantsInReferencesAsync which returns an Attempt with operation status. Scheduled for removal in Umbraco 19.")] Task> GetPagedDescendantsInReferencesAsync(Guid parentKey, long skip, long take, bool filterMustBeIsDependency); + /// + /// Gets a paged result of the descending items that have any references, given a parent id. + /// + /// The unique identifier of the parent to retrieve descendants for. + /// The Umbraco object type of the parent. + /// The amount of items to skip + /// The amount of items to take. + /// + /// A boolean indicating whether to filter only the RelationTypes which are + /// dependencies (isDependency field is set to true). + /// + /// An wrapping a paged result of objects. + async Task, GetReferencesOperationStatus>> GetPagedDescendantsInReferencesAsync(Guid parentKey, UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) +#pragma warning disable CS0618 // Type or member is obsolete + => Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, await GetPagedDescendantsInReferencesAsync(parentKey, skip, take, filterMustBeIsDependency)); +#pragma warning restore CS0618 // Type or member is obsolete + /// /// Gets a paged result of items used in any kind of relation from selected integer ids. /// diff --git a/src/Umbraco.Core/Services/OperationStatus/GetReferencesOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/GetReferencesOperationStatus.cs new file mode 100644 index 0000000000..2ba0e80dc8 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/GetReferencesOperationStatus.cs @@ -0,0 +1,7 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum GetReferencesOperationStatus +{ + Success, + ContentNotFound +} diff --git a/src/Umbraco.Core/Services/TrackedReferencesService.cs b/src/Umbraco.Core/Services/TrackedReferencesService.cs index 6babae6e05..24332dcfa5 100644 --- a/src/Umbraco.Core/Services/TrackedReferencesService.cs +++ b/src/Umbraco.Core/Services/TrackedReferencesService.cs @@ -1,6 +1,8 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -20,6 +22,7 @@ public class TrackedReferencesService : ITrackedReferencesService _entityService = entityService; } + [Obsolete("Use the GetPagedRelationsForItemAsync overload which returns an Attempt with operation status. Scheduled for removal in Umbraco 19.")] public Task> GetPagedRelationsForItemAsync(Guid key, long skip, long take, bool filterMustBeIsDependency) { using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); @@ -29,6 +32,21 @@ public class TrackedReferencesService : ITrackedReferencesService return Task.FromResult(pagedModel); } + public async Task, GetReferencesOperationStatus>> GetPagedRelationsForItemAsync(Guid key, UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) + { + IEntitySlim? entity = _entityService.Get(key, objectType); + if (entity is null) + { + return Attempt.FailWithStatus(GetReferencesOperationStatus.ContentNotFound, new PagedModel()); + } + +#pragma warning disable CS0618 // Type or member is obsolete (but using whilst it exists to avoid code repetition) + PagedModel pagedModel = await GetPagedRelationsForItemAsync(key, skip, take, filterMustBeIsDependency); +#pragma warning restore CS0618 // Type or member is obsolete + + return Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, pagedModel); + } + public Task> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) { Guid objectTypeKey = objectType switch @@ -44,6 +62,7 @@ public class TrackedReferencesService : ITrackedReferencesService return Task.FromResult(pagedModel); } + [Obsolete("Use GetPagedDescendantsInReferencesAsync which returns an Attempt with operation status. Scheduled for removal in Umbraco 19.")] public Task> GetPagedDescendantsInReferencesAsync(Guid parentKey, long skip, long take, bool filterMustBeIsDependency) { using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); @@ -59,6 +78,21 @@ public class TrackedReferencesService : ITrackedReferencesService return Task.FromResult(pagedModel); } + public async Task, GetReferencesOperationStatus>> GetPagedDescendantsInReferencesAsync(Guid parentKey, UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency) + { + IEntitySlim? entity = _entityService.Get(parentKey, objectType); + if (entity is null) + { + return Attempt.FailWithStatus(GetReferencesOperationStatus.ContentNotFound, new PagedModel()); + } + +#pragma warning disable CS0618 // Type or member is obsolete (but using whilst it exists to avoid code repetition) + PagedModel pagedModel = await GetPagedDescendantsInReferencesAsync(parentKey, skip, take, filterMustBeIsDependency); +#pragma warning restore CS0618 // Type or member is obsolete + + return Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, pagedModel); + } + public Task> GetPagedItemsWithRelationsAsync(ISet keys, long skip, long take, bool filterMustBeIsDependency) { using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs index ae868d00fc..71e9020c8d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/TrackedReferencesServiceTests.cs @@ -5,6 +5,7 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Infrastructure.Persistence.Relations; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; @@ -77,25 +78,55 @@ internal class TrackedReferencesServiceTests : UmbracoIntegrationTest { var sut = GetRequiredService(); - var actual = await sut.GetPagedRelationsForItemAsync(Root1.Key, 0, 10, true); + var actual = await sut.GetPagedRelationsForItemAsync(Root1.Key, UmbracoObjectTypes.Document, 0, 10, true); Assert.Multiple(() => { - Assert.AreEqual(1, actual.Total); - var item = actual.Items.FirstOrDefault(); + Assert.IsTrue(actual.Success); + Assert.AreEqual(1, actual.Result.Total); + var item = actual.Result.Items.FirstOrDefault(); Assert.AreEqual(Root2.ContentType.Alias, item?.ContentTypeAlias); Assert.AreEqual(Root2.Key, item?.NodeKey); }); } + [Test] + public async Task Get_Relations_For_Non_Existing_Page_Returns_Not_Found() + { + var sut = GetRequiredService(); + + var actual = await sut.GetPagedRelationsForItemAsync(Guid.NewGuid(), UmbracoObjectTypes.Document, 0, 10, true); + + Assert.Multiple(() => + { + Assert.IsFalse(actual.Success); + Assert.AreEqual(GetReferencesOperationStatus.ContentNotFound, actual.Status); + }); + } + + [Test] + public async Task Get_Descendants_In_References_For_Non_Existing_Page_Returns_Not_Found() + { + var sut = GetRequiredService(); + + var actual = await sut.GetPagedDescendantsInReferencesAsync(Guid.NewGuid(), UmbracoObjectTypes.Document, 0, 10, true); + + Assert.Multiple(() => + { + Assert.IsFalse(actual.Success); + Assert.AreEqual(GetReferencesOperationStatus.ContentNotFound, actual.Status); + }); + } + [Test] public async Task Does_Not_Return_References_If_Item_Is_Not_Referenced() { var sut = GetRequiredService(); - var actual = await sut.GetPagedRelationsForItemAsync(Root2.Key, 0, 10, true); + var actual = await sut.GetPagedRelationsForItemAsync(Root2.Key, UmbracoObjectTypes.Document, 0, 10, true); - Assert.AreEqual(0, actual.Total); + Assert.IsTrue(actual.Success); + Assert.AreEqual(0, actual.Result.Total); } [Test] From 657ccbd104fd17d54329053440d04aafa0b858ab Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 2 Dec 2025 11:02:18 +0100 Subject: [PATCH 6/7] Delivery API: Retain the Delivery API login redirect behavior in .NET 10 (closes #21000) (#21023) * Retain the Delivery API login redirect behavior in .NET 10 * Retrofit fix for backwards compatability --- .../Security/ConfigureMemberCookieOptions.cs | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs b/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs index 1ba9a52526..39a5d7a30f 100644 --- a/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs +++ b/src/Umbraco.Web.Common/Security/ConfigureMemberCookieOptions.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Controllers; @@ -58,21 +59,48 @@ public sealed class ConfigureMemberCookieOptions : IConfigureNamedOptions + // retain the login redirect behavior in .NET 10 + // - see https://learn.microsoft.com/en-us/dotnet/core/compatibility/aspnet-core/10/cookie-authentication-api-endpoints + OnRedirectToLogin = context => { - // When the controller is an UmbracoAPIController, we want to return a StatusCode instead of a redirect. - // All other cases should use the default Redirect of the CookieAuthenticationEvent. - var controllerDescriptor = ctx.HttpContext.GetEndpoint()?.Metadata - .OfType() - .FirstOrDefault(); - - if (!controllerDescriptor?.ControllerTypeInfo.IsSubclassOf(typeof(UmbracoApiController)) ?? false) + if (IsXhr(context.Request)) { - new CookieAuthenticationEvents().OnRedirectToAccessDenied(ctx); + context.Response.Headers.Location = context.RedirectUri; + context.Response.StatusCode = 401; + } + else + { + context.Response.Redirect(context.RedirectUri); + } + + return Task.CompletedTask; + }, + OnRedirectToAccessDenied = context => + { + // TODO: rewrite this to match OnRedirectToLogin (with a 403 status code) when UmbracoApiController is removed + // When the controller is an UmbracoAPIController, or if the request is an XHR, we want to return a + // StatusCode instead of a redirect. + // All other cases should use the default Redirect of the CookieAuthenticationEvent. + if (IsXhr(context.Request) is false && IsUmbracoApiControllerRequest(context.HttpContext) is false) + { + new CookieAuthenticationEvents().OnRedirectToAccessDenied(context); } return Task.CompletedTask; }, }; + return; + + bool IsUmbracoApiControllerRequest(HttpContext context) + => context.GetEndpoint() + ?.Metadata + .OfType() + .FirstOrDefault() + ?.ControllerTypeInfo + .IsSubclassOf(typeof(UmbracoApiController)) is true; + + bool IsXhr(HttpRequest request) => + string.Equals(request.Query[HeaderNames.XRequestedWith], "XMLHttpRequest", StringComparison.Ordinal) || + string.Equals(request.Headers.XRequestedWith, "XMLHttpRequest", StringComparison.Ordinal); } } From 857f2900bb389341540434a45635c4b66f00d71c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Tue, 2 Dec 2025 15:00:25 +0100 Subject: [PATCH 7/7] Segments: Fix for processing data for Segments-variants (#21018) * refactor to load segments before processing incoming data * clean up * remove unused segment promise --- .../content-type-workspace-context-base.ts | 1 - .../content-detail-workspace-base.ts | 45 +++++++++++-------- .../entity-detail-workspace-base.ts | 5 +-- .../workspace/document-workspace.context.ts | 26 +++++------ 4 files changed, 38 insertions(+), 39 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts index 67873bffb9..2d23f998dd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts @@ -92,7 +92,6 @@ export abstract class UmbContentTypeWorkspaceContextBase< let { data } = await request; if (data) { - data = await this._processIncomingData(data); data = await this._scaffoldProcessData(data); if (this.modalContext) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts index caa326e926..5abb4d4f7f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts @@ -368,11 +368,6 @@ export abstract class UmbContentDetailWorkspaceContextBase< (varies) => { this._data.setVariesBySegment(varies); this.#variesBySegment = varies; - if (varies) { - this.loadSegments(); - } else { - this._segments.setValue([]); - } }, null, ); @@ -393,20 +388,28 @@ export abstract class UmbContentDetailWorkspaceContextBase< this.#languages.setValue(data?.items ?? []); } + /** + * @deprecated Call `_loadSegmentsFor` instead. `loadSegments` will be removed in v.18. + * (note this was introduced in v.17, and deprecated in v.17.0.1) + */ protected async loadSegments() { + console.warn('Stop using loadSegments, call _loadSegmentsFor instead. loadSegments will be removed in v.18.'); + const unique = await firstValueFrom(this.unique); + if (!unique) { + this._segments.setValue([]); + return; + } + this._loadSegmentsFor(unique); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async _loadSegmentsFor(unique: string): Promise { console.warn( `UmbContentDetailWorkspaceContextBase: Segments are not implemented in the workspace context for "${this.getEntityType()}" types.`, ); this._segments.setValue([]); } - /** - * @deprecated Call `_processIncomingData` instead. `_scaffoldProcessData` will be removed in v.18. - */ - protected override _scaffoldProcessData(data: DetailModelType): Promise { - return this._processIncomingData(data); - } - protected override async _processIncomingData(data: DetailModelType): Promise { const contentTypeUnique: string | undefined = (data as any)[this.#contentTypePropertyName].unique; if (!contentTypeUnique) { @@ -415,24 +418,28 @@ export abstract class UmbContentDetailWorkspaceContextBase< // Load the content type structure, usually this comes from the data, but in this case we are making the data, and we need this to be able to complete the data. [NL] await this.structure.loadType(contentTypeUnique); + // Load segments if varying by segment, or reset to empty array: + if (this.#variesBySegment) { + await this._loadSegmentsFor(data.unique); + } else { + this._segments.setValue([]); + } + // Set culture and segment for all values: const cultures = this.#languages.getValue().map((x) => x.unique); - if (this.structure.variesBySegment) { - // TODO: v.17 Engage please note we have not implemented support for segments yet. [NL] - console.warn('Segments are not yet implemented for preset'); + let segments: Array | undefined; + if (this.#variesBySegment) { + segments = this._segments.getValue().map((s) => s.alias); } - // TODO: Add Segments for Presets: - const segments: Array | undefined = this.structure.variesBySegment ? [] : undefined; const repo = new UmbDataTypeDetailRepository(this); const propertyTypes = await this.structure.getContentTypeProperties(); const contentTypeVariesByCulture = this.structure.getVariesByCulture(); - const contentTypeVariesBySegment = this.structure.getVariesByCulture(); + const contentTypeVariesBySegment = this.structure.getVariesBySegment(); const valueDefinitions = await Promise.all( propertyTypes.map(async (property) => { - // TODO: Implement caching for data-type requests. [NL] const dataType = (await repo.requestByUnique(property.dataType.unique)).data; // This means if its not loaded this will never resolve and the error below will never happen. if (!dataType) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts index c1e0912a3f..b9e95b34ef 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-detail-workspace-base.ts @@ -253,7 +253,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase< } } } else if (data) { - const processedData = await this._processIncomingData(data); + const processedData = await this._scaffoldProcessData(data); this._data.setPersisted(processedData); this._data.setCurrent(processedData); @@ -311,7 +311,6 @@ export abstract class UmbEntityDetailWorkspaceContextBase< let { data } = await request; if (data) { - data = await this._processIncomingData(data); data = await this._scaffoldProcessData(data); if (this.modalContext) { @@ -336,7 +335,7 @@ export abstract class UmbEntityDetailWorkspaceContextBase< * @returns {Promise} The processed data. */ protected async _scaffoldProcessData(data: DetailModelType): Promise { - return data; + return await this._processIncomingData(data); } protected async _processIncomingData(data: DetailModelType): Promise { return data; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts index 0d199d874c..7ddd97262f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts @@ -227,22 +227,16 @@ export class UmbDocumentWorkspaceContext this.#isTrashedContext.setIsTrashed(false); } - protected override async loadSegments(): Promise { - this.observe( - this.unique, - async (unique) => { - if (!unique) { - this._segments.setValue([]); - return; - } - const { data } = await this.#documentSegmentRepository.getDocumentByIdSegmentOptions(unique, { - skip: 0, - take: 9999, - }); - this._segments.setValue(data?.items ?? []); - }, - '_loadSegmentsUnique', - ); + protected override async _loadSegmentsFor(unique: string): Promise { + if (!unique) { + this._segments.setValue([]); + return; + } + const { data } = await this.#documentSegmentRepository.getDocumentByIdSegmentOptions(unique, { + skip: 0, + take: 9999, + }); + this._segments.setValue(data?.items ?? []); } async create(parent: UmbEntityModel, documentTypeUnique: string, blueprintUnique?: string) {