Merge branch 'v15/dev' into v16/merge-from-15

# Conflicts:
#	src/Umbraco.Cms.Api.Management/Controllers/PublishedCache/RebuildPublishedCacheController.cs
#	src/Umbraco.Cms.Api.Management/Factories/UserPresentationFactory.cs
#	src/Umbraco.Core/Persistence/Repositories/ITrackedReferencesRepository.cs
#	src/Umbraco.Core/Services/ContentEditingService.cs
#	src/Umbraco.Core/Services/DataTypeService.cs
#	src/Umbraco.Core/Services/IContentEditingService.cs
#	src/Umbraco.Core/Services/IDataTypeService.cs
#	src/Umbraco.Core/Services/ITrackedReferencesService.cs
#	src/Umbraco.Core/Services/RelationService.cs
#	src/Umbraco.Core/Services/TrackedReferencesService.cs
#	src/Umbraco.Infrastructure/Examine/Deferred/DeliveryApiContentIndexHandleContentTypeChanges.cs
#	src/Umbraco.Infrastructure/Examine/DeliveryApiIndexingHandler.cs
#	src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TrackedReferencesRepository.cs
#	src/Umbraco.Web.UI.Client/src/external/backend-api/src/sdk.gen.ts
#	src/Umbraco.Web.UI.Client/src/mocks/data/document-blueprint/document-blueprint.data.ts
#	src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts
#	src/Umbraco.Web.UI.Client/src/packages/core/router/modal-registration/modal-route-registration.controller.ts
#	src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.context.ts
#	src/Umbraco.Web.UI.Client/src/packages/core/router/route/route.interface.ts
#	src/Umbraco.Web.UI.Client/src/packages/core/router/route/router-slot.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/core/router/router-slot/model.ts
#	src/Umbraco.Web.UI.Client/src/packages/data-type/reference/repository/data-type-reference.server.data.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.server.data-source.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/rollback/entity-action/rollback.action.ts
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	tests/Umbraco.Tests.Common/Builders/UserGroupBuilder.cs
#	tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TemporaryFileServiceTests.cs
This commit is contained in:
Andy Butland
2025-04-09 22:05:59 +02:00
91 changed files with 2817 additions and 368 deletions

View File

@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Extensions;
@@ -19,6 +20,7 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
private readonly IPublishedContentCache _contentCache;
private readonly IDocumentNavigationQueryService _navigationQueryService;
private readonly IPublishStatusQueryService _publishStatusQueryService;
private readonly IDocumentUrlService _documentUrlService;
private RequestHandlerSettings _requestSettings;
public ApiContentRouteBuilder(
@@ -29,7 +31,8 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
IOptionsMonitor<RequestHandlerSettings> requestSettings,
IPublishedContentCache contentCache,
IDocumentNavigationQueryService navigationQueryService,
IPublishStatusQueryService publishStatusQueryService)
IPublishStatusQueryService publishStatusQueryService,
IDocumentUrlService documentUrlService)
{
_apiContentPathProvider = apiContentPathProvider;
_variationContextAccessor = variationContextAccessor;
@@ -37,11 +40,35 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
_contentCache = contentCache;
_navigationQueryService = navigationQueryService;
_publishStatusQueryService = publishStatusQueryService;
_documentUrlService = documentUrlService;
_globalSettings = globalSettings.Value;
_requestSettings = requestSettings.CurrentValue;
requestSettings.OnChange(settings => _requestSettings = settings);
}
[Obsolete("Use the non-obsolete constructor, scheduled for removal in v17")]
public ApiContentRouteBuilder(
IApiContentPathProvider apiContentPathProvider,
IOptions<GlobalSettings> globalSettings,
IVariationContextAccessor variationContextAccessor,
IRequestPreviewService requestPreviewService,
IOptionsMonitor<RequestHandlerSettings> requestSettings,
IPublishedContentCache contentCache,
IDocumentNavigationQueryService navigationQueryService,
IPublishStatusQueryService publishStatusQueryService)
: this(
apiContentPathProvider,
globalSettings,
variationContextAccessor,
requestPreviewService,
requestSettings,
contentCache,
navigationQueryService,
publishStatusQueryService,
StaticServiceProvider.Instance.GetRequiredService<IDocumentUrlService>())
{
}
[Obsolete("Use the non-obsolete constructor, scheduled for removal in v17")]
public ApiContentRouteBuilder(
IApiContentPathProvider apiContentPathProvider,
@@ -59,7 +86,8 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
requestSettings,
contentCache,
navigationQueryService,
StaticServiceProvider.Instance.GetRequiredService<IPublishStatusQueryService>())
StaticServiceProvider.Instance.GetRequiredService<IPublishStatusQueryService>(),
StaticServiceProvider.Instance.GetRequiredService<IDocumentUrlService>())
{
}
@@ -113,7 +141,7 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
// we can perform fallback to the content route.
if (IsInvalidContentPath(contentPath))
{
contentPath = _contentCache.GetRouteById(content.Id, culture) ?? contentPath;
contentPath = _documentUrlService.GetLegacyRouteFormat(content.Key, culture ?? _variationContextAccessor.VariationContext?.Culture, isPreview);
}
// if the content path has still not been resolved as a valid path, the content is un-routable in this culture
@@ -125,7 +153,9 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
: null;
}
return contentPath;
return _requestSettings.AddTrailingSlash
? contentPath?.EnsureEndsWith('/')
: contentPath?.TrimEnd('/');
}
private string ContentPreviewPath(IPublishedContent content) => $"{Constants.DeliveryApi.Routing.PreviewContentPathPrefix}{content.Key:D}{(_requestSettings.AddTrailingSlash ? "/" : string.Empty)}";

View File

@@ -86,10 +86,35 @@ public sealed class ApiPublishedContentCache : IApiPublishedContentCache
_variationContextAccessor.VariationContext?.Culture,
_requestPreviewService.IsPreview());
// in multi-root settings, we've historically resolved all but the first root by their ID + URL segment,
// e.g. "1234/second-root-url-segment". in V15+, IDocumentUrlService won't resolve this anymore; it will
// however resolve "1234/" correctly, so to remain backwards compatible, we need to perform this extra step.
var verifyUrlSegment = false;
if (documentKey is null && route.TrimEnd('/').CountOccurrences("/") is 1)
{
documentKey = _apiDocumentUrlService.GetDocumentKeyByRoute(
route[..(route.IndexOf('/') + 1)],
_variationContextAccessor.VariationContext?.Culture,
_requestPreviewService.IsPreview());
verifyUrlSegment = true;
}
IPublishedContent? content = documentKey.HasValue
? _publishedContentCache.GetById(isPreviewMode, documentKey.Value)
: null;
// the additional look-up above can result in false positives; if attempting to request a non-existing child to
// the currently contextualized request root (either by start item or by domain), the root content key might
// get resolved. to counter for this, we compare the requested URL segment with the resolved content URL segment.
if (content is not null && verifyUrlSegment)
{
var expectedUrlSegment = route[(route.IndexOf('/') + 1)..];
if (content.UrlSegment != expectedUrlSegment)
{
content = null;
}
}
return ContentOrNullIfDisallowed(content);
}

View File

@@ -0,0 +1,13 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models.Membership;
namespace Umbraco.Cms.Core.Notifications;
public class UserPasswordResettingNotification : CancelableObjectNotification<IUser>
{
public UserPasswordResettingNotification(IUser target, EventMessages messages) : base(target, messages)
{
}
public IUser User => Target;
}

View File

@@ -41,11 +41,8 @@ public interface ITrackedReferencesRepository
long skip,
long take,
bool filterMustBeIsDependency,
out long totalRecords)
{
totalRecords = 0;
return [];
}
out long totalRecords);
/// <summary>
/// Gets a page of items used in any kind of relation from selected integer ids.

View File

@@ -29,8 +29,7 @@ public interface ITrackedReferencesService
/// dependencies (isDependency field is set to true).
/// </param>
/// <returns>A paged result of <see cref="RelationItemModel" /> objects.</returns>
Task<PagedModel<RelationItemModel>> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency)
=> Task.FromResult(new PagedModel<RelationItemModel>(0, []));
Task<PagedModel<RelationItemModel>> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency);
/// <summary>
/// Gets a paged result of the descending items that have any references, given a parent id.

View File

@@ -10,7 +10,6 @@ public class TrackedReferencesService : ITrackedReferencesService
private readonly IEntityService _entityService;
private readonly ITrackedReferencesRepository _trackedReferencesRepository;
public TrackedReferencesService(
ITrackedReferencesRepository trackedReferencesRepository,
ICoreScopeProvider scopeProvider,
@@ -30,7 +29,7 @@ public class TrackedReferencesService : ITrackedReferencesService
return Task.FromResult(pagedModel);
}
public async Task<PagedModel<RelationItemModel>> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency)
public Task<PagedModel<RelationItemModel>> GetPagedRelationsForRecycleBinAsync(UmbracoObjectTypes objectType, long skip, long take, bool filterMustBeIsDependency)
{
Guid objectTypeKey = objectType switch
{
@@ -42,7 +41,7 @@ public class TrackedReferencesService : ITrackedReferencesService
using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true);
IEnumerable<RelationItemModel> items = _trackedReferencesRepository.GetPagedRelationsForRecycleBin(objectTypeKey, skip, take, filterMustBeIsDependency, out var totalItems);
var pagedModel = new PagedModel<RelationItemModel>(totalItems, items);
return await Task.FromResult(pagedModel);
return Task.FromResult(pagedModel);
}
public Task<PagedModel<RelationItemModel>> GetPagedDescendantsInReferencesAsync(Guid parentKey, long skip, long take, bool filterMustBeIsDependency)

View File

@@ -1130,7 +1130,21 @@ internal partial class UserService : RepositoryService, IUserService
return keys;
}
/// <inheritdoc/>
public async Task<Attempt<PasswordChangedModel, UserOperationStatus>> ChangePasswordAsync(Guid performingUserKey, ChangeUserPasswordModel model)
{
IServiceScope serviceScope = _serviceScopeFactory.CreateScope();
IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService<IBackOfficeUserStore>();
IUser? performingUser = await userStore.GetAsync(performingUserKey);
if (performingUser is null)
{
return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new PasswordChangedModel());
}
return await ChangePasswordAsync(performingUser, model);
}
private async Task<Attempt<PasswordChangedModel, UserOperationStatus>> ChangePasswordAsync(IUser performingUser, ChangeUserPasswordModel model)
{
IServiceScope serviceScope = _serviceScopeFactory.CreateScope();
using ICoreScope scope = ScopeProvider.CreateCoreScope();
@@ -1147,12 +1161,6 @@ internal partial class UserService : RepositoryService, IUserService
return Attempt.FailWithStatus(UserOperationStatus.InvalidUserType, new PasswordChangedModel());
}
IUser? performingUser = await userStore.GetAsync(performingUserKey);
if (performingUser is null)
{
return Attempt.FailWithStatus(UserOperationStatus.MissingUser, new PasswordChangedModel());
}
// require old password for self change when outside of invite or resetByToken flows
if (performingUser.UserState != UserState.Invited && performingUser.Username == user.Username && string.IsNullOrEmpty(model.OldPassword) && string.IsNullOrEmpty(model.ResetPasswordToken))
{
@@ -1176,12 +1184,13 @@ internal partial class UserService : RepositoryService, IUserService
IBackOfficePasswordChanger passwordChanger = serviceScope.ServiceProvider.GetRequiredService<IBackOfficePasswordChanger>();
Attempt<PasswordChangedModel?> result = await passwordChanger.ChangeBackOfficePassword(
new ChangeBackOfficeUserPasswordModel
{
NewPassword = model.NewPassword,
OldPassword = model.OldPassword,
User = user,
ResetPasswordToken = model.ResetPasswordToken,
}, performingUser);
{
NewPassword = model.NewPassword,
OldPassword = model.OldPassword,
User = user,
ResetPasswordToken = model.ResetPasswordToken,
},
performingUser);
if (result.Success is false)
{
@@ -1967,9 +1976,26 @@ internal partial class UserService : RepositoryService, IUserService
public async Task<Attempt<PasswordChangedModel, UserOperationStatus>> ResetPasswordAsync(Guid userKey, string token, string password)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IServiceScope serviceScope = _serviceScopeFactory.CreateScope();
EventMessages evtMsgs = EventMessagesFactory.Get();
IBackOfficeUserStore userStore = serviceScope.ServiceProvider.GetRequiredService<IBackOfficeUserStore>();
IUser? user = await userStore.GetAsync(userKey);
if (user is null)
{
return Attempt.FailWithStatus(UserOperationStatus.UserNotFound, new PasswordChangedModel());
}
var savingNotification = new UserPasswordResettingNotification(user, evtMsgs);
if (await scope.Notifications.PublishCancelableAsync(savingNotification))
{
scope.Complete();
return Attempt.FailWithStatus(UserOperationStatus.CancelledByNotification, new PasswordChangedModel());
}
Attempt<PasswordChangedModel, UserOperationStatus> changePasswordAttempt =
await ChangePasswordAsync(userKey, new ChangeUserPasswordModel
await ChangePasswordAsync(user, new ChangeUserPasswordModel
{
NewPassword = password,
UserKey = userKey,