Merge branch 'v13/dev' into v14/dev

# Conflicts:
#	Directory.Packages.props
#	build/azure-pipelines.yml
#	src/Umbraco.Cms.Api.Common/DependencyInjection/UmbracoBuilderAuthExtensions.cs
#	src/Umbraco.Cms.Persistence.EFCore/Locking/SqlServerEFCoreDistributedLockingMechanism.cs
#	src/Umbraco.Core/Configuration/Models/RichTextEditorSettings.cs
#	src/Umbraco.Core/EmbeddedResources/Lang/da.xml
#	src/Umbraco.Core/EmbeddedResources/Lang/en.xml
#	src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml
#	src/Umbraco.Core/Services/ContentService.cs
#	src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsQueryStringHandler.cs
#	src/Umbraco.Web.BackOffice/Authorization/ContentPermissionsResourceHandler.cs
#	src/Umbraco.Web.BackOffice/Controllers/ContentController.cs
#	src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs
#	src/Umbraco.Web.BackOffice/Controllers/MediaController.cs
#	src/Umbraco.Web.BackOffice/Trees/StaticFilesTreeController.cs
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js
#	src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js
#	src/Umbraco.Web.UI.Client/src/common/filters/simpleMarkdown.filter.js
#	src/Umbraco.Web.UI.Client/src/common/filters/simpleMarkdown.filter.js.js
#	src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js
#	src/Umbraco.Web.UI.Client/src/less/components/umb-group-builder.less
#	src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.less
#	src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/propertysettings/propertysettings.html
#	src/Umbraco.Web.UI.Client/src/views/common/overlays/ysod/ysod.controller.js
#	src/Umbraco.Web.UI.Client/src/views/common/overlays/ysod/ysod.html
#	src/Umbraco.Web.UI.Client/src/views/components/buttons/umb-button-group.html
#	src/Umbraco.Web.UI.Client/src/views/content/overlays/sendtopublish.controller.js
#	src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.controller.js
#	src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.html
#	src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.overlay.controller.js
#	src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.overlay.html
#	src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js
#	src/Umbraco.Web.UI.Client~HEAD
#	src/Umbraco.Web.UI.Login/package-lock.json
#	src/Umbraco.Web.UI.Login/package.json
#	tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/BlockGridEditor/Content/blockGridEditorContent.spec.ts
#	tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceNotificationTests.cs
#	tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs
#	tools/Umbraco.JsonSchema/UmbracoCmsSchema.cs
#	version.json
This commit is contained in:
Sven Geusens
2025-01-20 14:00:18 +01:00
35 changed files with 11017 additions and 107 deletions

View File

@@ -12,25 +12,25 @@
</ItemGroup>
<!-- Microsoft packages -->
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.11" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.11" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="8.0.8" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="8.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="8.0.11" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="8.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="8.0.0" />
@@ -45,14 +45,14 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
<PackageVersion Include="Examine" Version="3.3.0" />
<PackageVersion Include="Examine.Core" Version="3.3.0" />
<PackageVersion Include="HtmlAgilityPack" Version="1.11.65" />
<PackageVersion Include="Examine" Version="3.5.0" />
<PackageVersion Include="Examine.Core" Version="3.5.0" />
<PackageVersion Include="HtmlAgilityPack" Version="1.11.71" />
<PackageVersion Include="JsonPatch.Net" Version="3.1.1" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
<PackageVersion Include="MailKit" Version="4.7.1.1" />
<PackageVersion Include="MailKit" Version="4.8.0" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="2.5.187" />
<PackageVersion Include="MessagePack" Version="2.5.192" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.3.8" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.3.8" />
<PackageVersion Include="ncrontab" Version="3.3.3" />
@@ -62,32 +62,34 @@
<PackageVersion Include="OpenIddict.AspNetCore" Version="5.7.0" />
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="5.7.0" />
<PackageVersion Include="Serilog" Version="3.1.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageVersion Include="Serilog.Enrichers.Process" Version="2.0.2" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<PackageVersion Include="Serilog.Expressions" Version="4.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="2.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact.Reader" Version="3.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.2" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.4" />
<PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageVersion Include="Serilog.Sinks.Map" Version="1.0.2" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.6" />
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.1.3" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.7.3" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.9.0" />
</ItemGroup>
<!-- Transitive pinned versions (only required because our direct dependencies have vulnerable versions of transitive dependencies) -->
<ItemGroup>
<!-- Both Microsoft.EntityFrameworkCore.SqlServer and NPoco.SqlServer bring in a vulnerable version of Azure.Identity -->
<PackageVersion Include="Azure.Identity" Version="1.12.0" />
<PackageVersion Include="Azure.Identity" Version="1.13.1" />
<!-- Dazinator.Extensions.FileProviders brings in a vulnerable version of System.Net.Http -->
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<!-- Examine brings in a vulnerable version of System.Security.Cryptography.Xml -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="8.0.1" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="8.0.2" />
<!-- Both Dazinator.Extensions.FileProviders and MiniProfiler.AspNetCore.Mvc bring in a vulnerable version of System.Text.RegularExpressions -->
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
<!-- Both OpenIddict.AspNetCore, Npoco.SqlServer and Microsoft.EntityFrameworkCore.SqlServer bring in a vulnerable version of Microsoft.IdentityModel.JsonWebTokens -->
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="7.7.1" />
<!-- Examine.Lucene bring in a vulnerable version of Lucene.Net.Replicator -->
<PackageVersion Include="Lucene.Net.Replicator" Version="4.8.0-beta00017" />
</ItemGroup>
</Project>

View File

@@ -46,7 +46,9 @@ public static class UmbracoBuilderAuthExtensions
Paths.BackOfficeApi.LogoutEndpoint.TrimStart(Constants.CharArrays.ForwardSlash))
.SetRevocationEndpointUris(
Paths.MemberApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash),
Paths.BackOfficeApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash));
Paths.BackOfficeApi.RevokeEndpoint.TrimStart(Constants.CharArrays.ForwardSlash))
.SetUserinfoEndpointUris(
Paths.MemberApi.UserinfoEndpoint.TrimStart(Constants.CharArrays.ForwardSlash));
// Enable authorization code flow with PKCE
options
@@ -58,7 +60,8 @@ public static class UmbracoBuilderAuthExtensions
options
.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableLogoutEndpointPassthrough();
.EnableLogoutEndpointPassthrough()
.EnableUserinfoEndpointPassthrough();
// Enable reference tokens
// - see https://documentation.openiddict.com/configuration/token-storage.html

View File

@@ -31,6 +31,8 @@ public static class Paths
public static readonly string RevokeEndpoint = EndpointPath($"{EndpointTemplate}/revoke");
public static readonly string UserinfoEndpoint = EndpointPath($"{EndpointTemplate}/userinfo");
// NOTE: we're NOT using /api/v1.0/ here because it will clash with the Delivery API docs
private static string EndpointPath(string relativePath) => $"/umbraco/delivery/api/v1/{relativePath}";
}

View File

@@ -15,6 +15,7 @@ namespace Umbraco.Cms.Api.Delivery.Controllers.Content;
[ApiExplorerSettings(GroupName = "Content")]
[LocalizeFromAcceptLanguageHeader]
[ValidateStartItem]
[AddVaryHeader]
[OutputCache(PolicyName = Constants.DeliveryApi.OutputCache.ContentCachePolicy)]
public abstract class ContentApiControllerBase : DeliveryApiControllerBase
{

View File

@@ -0,0 +1,28 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Server.AspNetCore;
using Umbraco.Cms.Api.Delivery.Routing;
using Umbraco.Cms.Api.Delivery.Services;
namespace Umbraco.Cms.Api.Delivery.Controllers.Security;
[ApiVersion("1.0")]
[ApiController]
[VersionedDeliveryApiRoute(Common.Security.Paths.MemberApi.EndpointTemplate)]
[ApiExplorerSettings(IgnoreApi = true)]
[Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)]
public class CurrentMemberController : DeliveryApiControllerBase
{
private readonly ICurrentMemberClaimsProvider _currentMemberClaimsProvider;
public CurrentMemberController(ICurrentMemberClaimsProvider currentMemberClaimsProvider)
=> _currentMemberClaimsProvider = currentMemberClaimsProvider;
[HttpGet("userinfo")]
public async Task<IActionResult> Userinfo()
{
Dictionary<string, object> claims = await _currentMemberClaimsProvider.GetClaimsAsync();
return Ok(claims);
}
}

View File

@@ -60,6 +60,7 @@ public static class UmbracoBuilderExtensions
builder.Services.AddSingleton<IApiMediaQueryService, ApiMediaQueryService>();
builder.Services.AddTransient<IMemberApplicationManager, MemberApplicationManager>();
builder.Services.AddTransient<IRequestMemberAccessService, RequestMemberAccessService>();
builder.Services.AddTransient<ICurrentMemberClaimsProvider, CurrentMemberClaimsProvider>();
builder.Services.ConfigureOptions<ConfigureUmbracoDeliveryApiSwaggerGenOptions>();
builder.AddUmbracoApiOpenApiUI();

View File

@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Mvc.Filters;
namespace Umbraco.Cms.Api.Delivery.Filters;
public sealed class AddVaryHeaderAttribute : ActionFilterAttribute
{
private const string Vary = "Accept-Language, Preview, Start-Item";
public override void OnResultExecuting(ResultExecutingContext context)
=> context.HttpContext.Response.Headers.Vary = context.HttpContext.Response.Headers.Vary.Count > 0
? $"{context.HttpContext.Response.Headers.Vary}, {Vary}"
: Vary;
}

View File

@@ -1,6 +1,5 @@
using Umbraco.Cms.Api.Delivery.Indexing.Filters;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Delivery.Querying.Filters;
@@ -15,15 +14,15 @@ public sealed class ContentTypeFilter : IFilterHandler
/// <inheritdoc/>
public FilterOption BuildFilterOption(string filter)
{
var alias = filter.Substring(ContentTypeSpecifier.Length);
var filterValue = filter.Substring(ContentTypeSpecifier.Length);
var negate = filterValue.StartsWith('!');
var aliases = filterValue.TrimStart('!').Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
return new FilterOption
{
FieldName = ContentTypeFilterIndexer.FieldName,
Values = alias.IsNullOrWhiteSpace() == false
? new[] { alias.TrimStart('!') }
: Array.Empty<string>(),
Operator = alias.StartsWith('!')
Values = aliases,
Operator = negate
? FilterOperation.IsNot
: FilterOperation.Is
};

View File

@@ -1,5 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Delivery.Indexing.Selectors;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Extensions;
@@ -10,10 +12,22 @@ public sealed class AncestorsSelector : QueryOptionBase, ISelectorHandler
{
private const string AncestorsSpecifier = "ancestors:";
private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor;
private readonly IRequestPreviewService _requestPreviewService;
public AncestorsSelector(IPublishedSnapshotAccessor publishedSnapshotAccessor, IRequestRoutingService requestRoutingService)
: base(publishedSnapshotAccessor, requestRoutingService) =>
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
public AncestorsSelector(
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IRequestRoutingService requestRoutingService)
: this(publishedSnapshotAccessor, requestRoutingService, StaticServiceProvider.Instance.GetRequiredService<IRequestPreviewService>())
{
}
public AncestorsSelector(IPublishedSnapshotAccessor publishedSnapshotAccessor, IRequestRoutingService requestRoutingService, IRequestPreviewService requestPreviewService)
: base(publishedSnapshotAccessor, requestRoutingService)
{
_publishedSnapshotAccessor = publishedSnapshotAccessor;
_requestPreviewService = requestPreviewService;
}
/// <inheritdoc />
public bool CanHandle(string query)
@@ -37,11 +51,21 @@ public sealed class AncestorsSelector : QueryOptionBase, ISelectorHandler
};
}
IPublishedSnapshot publishedSnapshot = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot();
IPublishedContent contentItem = publishedSnapshot.Content?.GetById((Guid)id)
IPublishedContentCache contentCache = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot()?.Content
?? throw new InvalidOperationException("Could not obtain the content cache");
IPublishedContent? contentItem = contentCache.GetById(_requestPreviewService.IsPreview(), id.Value);
if (contentItem is null)
{
// no such content item, make sure the selector does not yield any results
return new SelectorOption
{
FieldName = AncestorsSelectorIndexer.FieldName,
Values = Array.Empty<string>()
};
}
var ancestorKeys = contentItem.Ancestors().Select(a => a.Key.ToString("D")).ToArray();
return new SelectorOption

View File

@@ -0,0 +1,43 @@
using OpenIddict.Abstractions;
using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Api.Delivery.Services;
// NOTE: this is public and unsealed to allow overriding the default claims with minimal effort.
public class CurrentMemberClaimsProvider : ICurrentMemberClaimsProvider
{
private readonly IMemberManager _memberManager;
public CurrentMemberClaimsProvider(IMemberManager memberManager)
=> _memberManager = memberManager;
public virtual async Task<Dictionary<string, object>> GetClaimsAsync()
{
MemberIdentityUser? memberIdentityUser = await _memberManager.GetCurrentMemberAsync();
return memberIdentityUser is not null
? await GetClaimsForMemberIdentityAsync(memberIdentityUser)
: throw new InvalidOperationException("Could not retrieve the current member. This method should only ever be invoked when a member has been authorized.");
}
protected virtual async Task<Dictionary<string, object>> GetClaimsForMemberIdentityAsync(MemberIdentityUser memberIdentityUser)
{
var claims = new Dictionary<string, object>
{
[OpenIddictConstants.Claims.Subject] = memberIdentityUser.Key
};
if (memberIdentityUser.Name is not null)
{
claims[OpenIddictConstants.Claims.Name] = memberIdentityUser.Name;
}
if (memberIdentityUser.Email is not null)
{
claims[OpenIddictConstants.Claims.Email] = memberIdentityUser.Email;
}
claims[OpenIddictConstants.Claims.Role] = await _memberManager.GetRolesAsync(memberIdentityUser);
return claims;
}
}

View File

@@ -0,0 +1,12 @@
namespace Umbraco.Cms.Api.Delivery.Services;
public interface ICurrentMemberClaimsProvider
{
/// <summary>
/// Retrieves the claims for the currently logged in member.
/// </summary>
/// <remarks>
/// This is used by the OIDC user info endpoint to supply "current user" info.
/// </remarks>
Task<Dictionary<string, object>> GetClaimsAsync();
}

View File

@@ -32906,6 +32906,9 @@
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user do not have access to this resource"
}
},
"security": [
@@ -33107,6 +33110,9 @@
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user do not have access to this resource"
}
},
"security": [
@@ -33408,6 +33414,9 @@
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user do not have access to this resource"
}
},
"security": [

View File

@@ -170,7 +170,9 @@ internal class SqlServerEFCoreDistributedLockingMechanism<T> : IDistributedLocki
"A transaction with minimum ReadCommitted isolation level is required.");
}
var rowsAffected = await dbContext.Database.ExecuteSqlAsync(@$"SET LOCK_TIMEOUT {(int)_timeout.TotalMilliseconds};UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id={LockId}");
#pragma warning disable EF1002
var rowsAffected = await dbContext.Database.ExecuteSqlRawAsync(@$"SET LOCK_TIMEOUT {(int)_timeout.TotalMilliseconds};UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id={LockId}");
#pragma warning restore EF1002
if (rowsAffected == 0)
{

View File

@@ -140,7 +140,9 @@ public static class DistributedCacheExtensions
Id = x.Item.Id,
Key = x.Item.Key,
ChangeTypes = x.ChangeTypes,
Blueprint = x.Item.Blueprint
Blueprint = x.Item.Blueprint,
PublishedCultures = x.PublishedCultures?.ToArray(),
UnpublishedCultures = x.UnpublishedCultures?.ToArray()
});
dc.RefreshByPayload(ContentCacheRefresher.UniqueId, payloads);

View File

@@ -182,6 +182,10 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
public TreeChangeTypes ChangeTypes { get; init; }
public bool Blueprint { get; init; }
public string[]? PublishedCultures { get; init; }
public string[]? UnpublishedCultures { get; init; }
}
#endregion

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -384,7 +384,10 @@ public static class ClaimsIdentityExtensions
var firstValue = identity.FindFirstValue(ClaimTypes.NameIdentifier);
if (firstValue is not null)
{
return int.Parse(firstValue, CultureInfo.InvariantCulture);
if (int.TryParse(firstValue, CultureInfo.InvariantCulture, out var id))
{
return id;
}
}
return null;

View File

@@ -32,4 +32,14 @@ public class ContentTreeChangeNotification : TreeChangeNotification<IContent>
: base(new TreeChange<IContent>(target, changeTypes), messages)
{
}
public ContentTreeChangeNotification(
IContent target,
TreeChangeTypes changeTypes,
IEnumerable<string>? publishedCultures,
IEnumerable<string>? unpublishedCultures,
EventMessages messages)
: base(new TreeChange<IContent>(target, changeTypes, publishedCultures, unpublishedCultures), messages)
{
}
}

View File

@@ -8,10 +8,22 @@ public class TreeChange<TItem>
ChangeTypes = changeTypes;
}
public TreeChange(TItem changedItem, TreeChangeTypes changeTypes, IEnumerable<string>? publishedCultures, IEnumerable<string>? unpublishedCultures)
{
Item = changedItem;
ChangeTypes = changeTypes;
PublishedCultures = publishedCultures;
UnpublishedCultures = unpublishedCultures;
}
public TItem Item { get; }
public TreeChangeTypes ChangeTypes { get; }
public IEnumerable<string>? PublishedCultures { get; }
public IEnumerable<string>? UnpublishedCultures { get; }
public EventArgs ToEventArgs() => new EventArgs(this);
public class EventArgs : System.EventArgs

View File

@@ -1552,7 +1552,12 @@ public class ContentService : RepositoryService, IContentService
// events and audit
scope.Notifications.Publish(
new ContentUnpublishedNotification(content, eventMessages).WithState(notificationState));
scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshBranch, eventMessages));
scope.Notifications.Publish(new ContentTreeChangeNotification(
content,
TreeChangeTypes.RefreshBranch,
variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : null,
variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : ["*"],
eventMessages));
if (culturesUnpublishing != null)
{
@@ -1611,7 +1616,12 @@ public class ContentService : RepositoryService, IContentService
if (!branchOne)
{
scope.Notifications.Publish(
new ContentTreeChangeNotification(content, changeType, eventMessages));
new ContentTreeChangeNotification(
content,
changeType,
variesByCulture ? culturesPublishing.IsCollectionEmpty() ? null : culturesPublishing : ["*"],
variesByCulture ? culturesUnpublishing.IsCollectionEmpty() ? null : culturesUnpublishing : null,
eventMessages));
scope.Notifications.Publish(
new ContentPublishedNotification(content, eventMessages).WithState(notificationState));
}
@@ -2058,7 +2068,6 @@ public class ContentService : RepositoryService, IContentService
var results = new List<PublishResult>();
var publishedDocuments = new List<IContent>();
IDictionary<string, object?>? initialNotificationState = null;
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
scope.WriteLock(Constants.Locks.ContentTree);
@@ -2077,7 +2086,8 @@ public class ContentService : RepositoryService, IContentService
}
// deal with the branch root - if it fails, abort
PublishResult? result = PublishBranchItem(scope, document, shouldPublish, publishCultures, true, publishedDocuments, eventMessages, userId, allLangs, out initialNotificationState);
HashSet<string>? culturesToPublish = shouldPublish(document);
PublishResult? result = PublishBranchItem(scope, document, culturesToPublish, publishCultures, true, publishedDocuments, eventMessages, userId, allLangs, out IDictionary<string, object?>? notificationState);
if (result != null)
{
results.Add(result);
@@ -2087,6 +2097,8 @@ public class ContentService : RepositoryService, IContentService
}
}
HashSet<string> culturesPublished = culturesToPublish ?? [];
// deal with descendants
// if one fails, abort its branch
var exclude = new HashSet<int>();
@@ -2112,12 +2124,14 @@ public class ContentService : RepositoryService, IContentService
}
// no need to check path here, parent has to be published here
result = PublishBranchItem(scope, d, shouldPublish, publishCultures, false, publishedDocuments, eventMessages, userId, allLangs, out _);
culturesToPublish = shouldPublish(d);
result = PublishBranchItem(scope, d, culturesToPublish, publishCultures, false, publishedDocuments, eventMessages, userId, allLangs, out _);
if (result != null)
{
results.Add(result);
if (result.Success)
{
culturesPublished.UnionWith(culturesToPublish ?? []);
continue;
}
}
@@ -2134,9 +2148,15 @@ public class ContentService : RepositoryService, IContentService
// trigger events for the entire branch
// (SaveAndPublishBranchOne does *not* do it)
var variesByCulture = document.ContentType.VariesByCulture();
scope.Notifications.Publish(
new ContentTreeChangeNotification(document, TreeChangeTypes.RefreshBranch, eventMessages));
scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages, true).WithState(initialNotificationState));
new ContentTreeChangeNotification(
document,
TreeChangeTypes.RefreshBranch,
variesByCulture ? culturesPublished.IsCollectionEmpty() ? null : culturesPublished : ["*"],
null,
eventMessages));
scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages).WithState(notificationState));
scope.Complete();
}
@@ -2150,7 +2170,7 @@ public class ContentService : RepositoryService, IContentService
private PublishResult? PublishBranchItem(
ICoreScope scope,
IContent document,
Func<IContent, HashSet<string>?> shouldPublish,
HashSet<string>? culturesToPublish,
Func<IContent, HashSet<string>, IReadOnlyCollection<ILanguage>,
bool> publishCultures,
bool isRoot,
@@ -2160,9 +2180,7 @@ public class ContentService : RepositoryService, IContentService
IReadOnlyCollection<ILanguage> allLangs,
out IDictionary<string, object?>? initialNotificationState)
{
HashSet<string>? culturesToPublish = shouldPublish(document);
initialNotificationState = null;
initialNotificationState = new Dictionary<string, object?>();
// we need to guard against unsaved changes before proceeding; the document will be saved, but we're not firing any saved notifications
if (HasUnsavedChanges(document))

View File

@@ -13,6 +13,8 @@
<PackageReference Include="Examine" />
<!-- Take top-level depedendency on System.Security.Cryptography.Xml, because Examine depends on a vulnerable version -->
<PackageReference Include="System.Security.Cryptography.Xml" />
<!-- Take top-level depedendency on Lucene.Net.Replicator-->
<PackageReference Include="Lucene.Net.Replicator" />
</ItemGroup>
<ItemGroup>

View File

@@ -101,8 +101,9 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich
// - non-#comment nodes
// - non-#text nodes
// - non-empty #text nodes
// - empty #text between inline elements (see #17037)
HtmlNode[] childNodes = element.ChildNodes
.Where(c => c.Name != CommentNodeName && (c.Name != TextNodeName || string.IsNullOrWhiteSpace(c.InnerText) is false))
.Where(c => c.Name != CommentNodeName && (c.Name != TextNodeName || c.NextSibling is not null || string.IsNullOrWhiteSpace(c.InnerText) is false))
.ToArray();
var tag = TagName(element);

View File

@@ -187,8 +187,7 @@ public class ExamineIndexRebuilder : IIndexRebuilder
{
// If an index exists but it has zero docs we'll consider it empty and rebuild
IIndex[] indexes = (onlyEmptyIndexes
? _examineManager.Indexes.Where(x =>
!x.IndexExists() || (x is IIndexStats stats && stats.GetDocumentCount() == 0))
? _examineManager.Indexes.Where(ShouldRebuild)
: _examineManager.Indexes).ToArray();
if (indexes.Length == 0)
@@ -228,4 +227,17 @@ public class ExamineIndexRebuilder : IIndexRebuilder
}
}
}
private bool ShouldRebuild(IIndex index)
{
try
{
return !index.IndexExists() || (index is IIndexStats stats && stats.GetDocumentCount() == 0);
}
catch (Exception e)
{
_logger.LogError(e, "An error occured trying to get determine index shouldRebuild status for index {IndexName}. The index will NOT be considered for rebuilding", index.Name);
return false;
}
}
}

View File

@@ -1272,14 +1272,9 @@ public class ContentStore
// try to find the content
// if it is not there, nothing to do
_contentNodes.TryGetValue(id, out LinkedNode<ContentNode?>? link); // else null
if (link?.Value == null)
if (_contentNodes.TryGetValue(id, out LinkedNode<ContentNode?>? link) &&
link.Value is ContentNode content)
{
return false;
}
ContentNode? content = link.Value;
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Clear content ID: {ContentId}", content.Id);
@@ -1294,26 +1289,25 @@ public class ContentStore
return true;
}
return false;
}
private void ClearBranchLocked(int id)
{
_contentNodes.TryGetValue(id, out LinkedNode<ContentNode?>? link);
if (link?.Value == null)
if (_contentNodes.TryGetValue(id, out LinkedNode<ContentNode?>? link) &&
link.Value is ContentNode content)
{
return;
// clear the entire branch
ClearBranchLocked(content);
// manage the tree
RemoveTreeNodeLocked(content);
}
}
ClearBranchLocked(link.Value);
}
private void ClearBranchLocked(ContentNode? content)
private void ClearBranchLocked(ContentNode content)
{
// This should never be null, all code that calls this method is null checking but we've seen
// issues of null ref exceptions in issue reports so we'll double check here
if (content == null)
{
throw new ArgumentNullException(nameof(content));
}
// Clear content node
SetValueLocked(_contentNodes, content.Id, null);
if (_localDb != null)
{
@@ -1322,14 +1316,21 @@ public class ContentStore
_contentKeyToIdMap.TryRemove(content.Uid, out _);
var id = content.FirstChildContentId;
while (id > 0)
// Clear children
int childId = content.FirstChildContentId;
if (childId > 0)
{
// get the required link node, this ensures that both `link` and `link.Value` are not null
LinkedNode<ContentNode> link = GetRequiredLinkedNode(id, "child", null);
ContentNode? linkValue = link.Value; // capture local since clearing in recurse can clear it
ClearBranchLocked(linkValue); // recurse
id = linkValue?.NextSiblingContentId ?? 0;
ContentNode childContent = GetRequiredLinkedNode(childId, "first child", null).Value!;
ClearBranchLocked(childContent); // recurse
// Clear all siblings of child
int siblingId = childContent.NextSiblingContentId;
while (siblingId > 0)
{
ContentNode siblingContent = GetRequiredLinkedNode(siblingId, "next sibling", null).Value!;
ClearBranchLocked(siblingContent); // recurse
siblingId = siblingContent.NextSiblingContentId;
}
}
}

View File

@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.Controllers;
using Umbraco.Cms.Web.Common.Extensions;
@@ -21,6 +23,7 @@ public class UmbracoVirtualPageRoute : IUmbracoVirtualPageRoute
private readonly LinkParser _linkParser;
private readonly UriUtility _uriUtility;
private readonly IPublishedRouter _publishedRouter;
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
/// <summary>
/// Constructor.
@@ -29,16 +32,29 @@ public class UmbracoVirtualPageRoute : IUmbracoVirtualPageRoute
/// <param name="linkParser">The link parser.</param>
/// <param name="uriUtility">The Uri utility.</param>
/// <param name="publishedRouter">The published router.</param>
/// <param name="umbracoContextAccessor">The umbraco context accessor.</param>
public UmbracoVirtualPageRoute(
EndpointDataSource endpointDataSource,
LinkParser linkParser,
UriUtility uriUtility,
IPublishedRouter publishedRouter)
IPublishedRouter publishedRouter,
IUmbracoContextAccessor umbracoContextAccessor)
{
_endpointDataSource = endpointDataSource;
_linkParser = linkParser;
_uriUtility = uriUtility;
_publishedRouter = publishedRouter;
_umbracoContextAccessor = umbracoContextAccessor;
}
[Obsolete("Please use constructor that takes an IUmbracoContextAccessor instead, scheduled for removal in v17")]
public UmbracoVirtualPageRoute(
EndpointDataSource endpointDataSource,
LinkParser linkParser,
UriUtility uriUtility,
IPublishedRouter publishedRouter)
: this(endpointDataSource, linkParser, uriUtility, publishedRouter, StaticServiceProvider.Instance.GetRequiredService<IUmbracoContextAccessor>())
{
}
/// <summary>
@@ -157,7 +173,8 @@ public class UmbracoVirtualPageRoute : IUmbracoVirtualPageRoute
requestBuilder.SetPublishedContent(publishedContent);
_publishedRouter.RouteDomain(requestBuilder);
return requestBuilder.Build();
// Ensure the culture and domain is set correctly for the published request
return await _publishedRouter.RouteRequestAsync(requestBuilder, new RouteRequestOptions(Core.Routing.RouteDirection.Inbound));
}
/// <summary>
@@ -171,6 +188,12 @@ public class UmbracoVirtualPageRoute : IUmbracoVirtualPageRoute
{
IPublishedRequest publishedRequest = await CreatePublishedRequest(httpContext, publishedContent);
// Ensure the published request is set to the UmbracoContext
if (_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext))
{
umbracoContext.PublishedRequest = publishedRequest;
}
var umbracoRouteValues = new UmbracoRouteValues(
publishedRequest,
controllerActionDescriptor);

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Logging;
@@ -29,6 +30,7 @@ public class UmbLoginStatusController : SurfaceController
=> _signInManager = signInManager;
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
[ValidateUmbracoFormRouteString]
public async Task<IActionResult> HandleLogout([Bind(Prefix = "logoutModel")] PostRedirectModel model)

View File

@@ -5,12 +5,12 @@
<ItemGroup>
<!-- Microsoft packages -->
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageVersion Include="System.Data.DataSetExtensions" Version="4.5.0" />
<PackageVersion Include="System.Data.Odbc" Version="8.0.0" />
<PackageVersion Include="System.Data.OleDb" Version="8.0.0" />
<PackageVersion Include="System.Data.Odbc" Version="8.0.1" />
<PackageVersion Include="System.Data.OleDb" Version="8.0.1" />
<PackageVersion Include="System.Reflection.Emit" Version="4.7.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -84,7 +84,11 @@ public abstract class UmbracoIntegrationTest : UmbracoIntegrationTestBase
}
[TearDown]
public void TearDownAsync() => _host.StopAsync();
public void TearDownAsync()
{
_host.StopAsync();
Services.DisposeIfDisposable();
}
/// <summary>
/// Create the Generic Host and execute startup ConfigureServices/Configure calls

File diff suppressed because it is too large Load Diff

View File

@@ -54,7 +54,8 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
.AddNotificationHandler<ContentPublishingNotification, ContentNotificationHandler>()
.AddNotificationHandler<ContentPublishedNotification, ContentNotificationHandler>()
.AddNotificationHandler<ContentUnpublishingNotification, ContentNotificationHandler>()
.AddNotificationHandler<ContentUnpublishedNotification, ContentNotificationHandler>();
.AddNotificationHandler<ContentUnpublishedNotification, ContentNotificationHandler>()
.AddNotificationHandler<ContentTreeChangeNotification, ContentNotificationHandler>();
private void CreateTestData()
{
@@ -176,6 +177,69 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
}
}
[Test]
public void Publishing_Invariant()
{
IContent document = new Content("content", -1, _contentType);
ContentService.Save(document);
var treeChangeWasCalled = false;
ContentNotificationHandler.TreeChange += notification =>
{
var change = notification.Changes.FirstOrDefault();
var publishedCultures = change?.PublishedCultures?.ToArray();
Assert.IsNotNull(publishedCultures);
Assert.AreEqual(1, publishedCultures.Length);
Assert.IsTrue(publishedCultures.InvariantContains("*"));
Assert.IsNull(change.UnpublishedCultures);
treeChangeWasCalled = true;
};
try
{
ContentService.Publish(document, ["*"]);
Assert.IsTrue(treeChangeWasCalled);
}
finally
{
ContentNotificationHandler.TreeChange = null;
}
}
[Test]
public void Unpublishing_Invariant()
{
IContent document = new Content("content", -1, _contentType);
ContentService.Save(document);
ContentService.Publish(document, ["*"]);
var treeChangeWasCalled = false;
ContentNotificationHandler.TreeChange += notification =>
{
var change = notification.Changes.FirstOrDefault();
Assert.IsNull(change?.PublishedCultures);
var unpublishedCultures = change?.UnpublishedCultures?.ToArray();
Assert.IsNotNull(unpublishedCultures);
Assert.AreEqual(1, unpublishedCultures.Length);
Assert.IsTrue(unpublishedCultures.InvariantContains("*"));
treeChangeWasCalled = true;
};
try
{
ContentService.Unpublish(document);
Assert.IsTrue(treeChangeWasCalled);
}
finally
{
ContentNotificationHandler.TreeChange = null;
}
}
[Test]
public async Task Publishing_Culture()
{
@@ -202,6 +266,7 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
var publishingWasCalled = false;
var publishedWasCalled = false;
var treeChangeWasCalled = false;
ContentNotificationHandler.PublishingContent += notification =>
{
@@ -227,16 +292,30 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
publishedWasCalled = true;
};
ContentNotificationHandler.TreeChange += notification =>
{
var change = notification.Changes.FirstOrDefault();
var publishedCultures = change?.PublishedCultures?.ToArray();
Assert.IsNotNull(publishedCultures);
Assert.AreEqual(1, publishedCultures.Length);
Assert.IsTrue(publishedCultures.InvariantContains("fr-FR"));
Assert.IsNull(change.UnpublishedCultures);
treeChangeWasCalled = true;
};
try
{
ContentService.Publish(document, new[] { "fr-FR" });
Assert.IsTrue(publishingWasCalled);
Assert.IsTrue(publishedWasCalled);
Assert.IsTrue(treeChangeWasCalled);
}
finally
{
ContentNotificationHandler.PublishingContent = null;
ContentNotificationHandler.PublishedContent = null;
ContentNotificationHandler.TreeChange = null;
}
document = ContentService.GetById(document.Id);
@@ -399,6 +478,7 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
var publishingWasCalled = false;
var publishedWasCalled = false;
var treeChangeWasCalled = false;
// TODO: revisit this - it was migrated when removing static events, but the expected result seems illogic - why does this test bind to Published and not Unpublished?
@@ -432,16 +512,30 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
publishedWasCalled = true;
};
ContentNotificationHandler.TreeChange += notification =>
{
var change = notification.Changes.FirstOrDefault();
var unpublishedCultures = change?.UnpublishedCultures?.ToArray();
Assert.IsNotNull(unpublishedCultures);
Assert.AreEqual(1, unpublishedCultures.Length);
Assert.IsTrue(unpublishedCultures.InvariantContains("fr-FR"));
Assert.IsNull(change.PublishedCultures);
treeChangeWasCalled = true;
};
try
{
ContentService.CommitDocumentChanges(document);
Assert.IsTrue(publishingWasCalled);
Assert.IsTrue(publishedWasCalled);
Assert.IsTrue(treeChangeWasCalled);
}
finally
{
ContentNotificationHandler.PublishingContent = null;
ContentNotificationHandler.PublishedContent = null;
ContentNotificationHandler.TreeChange = null;
}
document = ContentService.GetById(document.Id);
@@ -456,7 +550,8 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
INotificationHandler<ContentPublishingNotification>,
INotificationHandler<ContentPublishedNotification>,
INotificationHandler<ContentUnpublishingNotification>,
INotificationHandler<ContentUnpublishedNotification>
INotificationHandler<ContentUnpublishedNotification>,
INotificationHandler<ContentTreeChangeNotification>
{
public static Action<ContentSavingNotification> SavingContent { get; set; }
@@ -470,6 +565,8 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
public static Action<ContentUnpublishedNotification> UnpublishedContent { get; set; }
public static Action<ContentTreeChangeNotification> TreeChange { get; set; }
public void Handle(ContentPublishedNotification notification) => PublishedContent?.Invoke(notification);
public void Handle(ContentPublishingNotification notification) => PublishingContent?.Invoke(notification);
@@ -480,5 +577,7 @@ public class ContentServiceNotificationTests : UmbracoIntegrationTest
public void Handle(ContentUnpublishedNotification notification) => UnpublishedContent?.Invoke(notification);
public void Handle(ContentUnpublishingNotification notification) => UnpublishingContent?.Invoke(notification);
public void Handle(ContentTreeChangeNotification notification) => TreeChange?.Invoke(notification);
}
}

View File

@@ -27,26 +27,68 @@ public class RefresherTests
Assert.AreEqual(source[0].ChangeTypes, payload[0].ChangeTypes);
}
[Test]
public void ContentCacheRefresherCanDeserializeJsonPayload()
[TestCase(TreeChangeTypes.None, false)]
[TestCase(TreeChangeTypes.RefreshAll, true)]
[TestCase(TreeChangeTypes.RefreshBranch, false)]
[TestCase(TreeChangeTypes.Remove, true)]
[TestCase(TreeChangeTypes.RefreshNode, false)]
public void ContentCacheRefresherCanDeserializeJsonPayload(TreeChangeTypes changeTypes, bool blueprint)
{
var key = Guid.NewGuid();
ContentCacheRefresher.JsonPayload[] source =
{
new ContentCacheRefresher.JsonPayload()
{
Id = 1234,
Key = Guid.NewGuid(),
ChangeTypes = TreeChangeTypes.None
Key = key,
ChangeTypes = changeTypes,
Blueprint = blueprint
}
};
var json = JsonSerializer.Serialize(source);
var payload = JsonSerializer.Deserialize<ContentCacheRefresher.JsonPayload[]>(json);
Assert.AreEqual(source[0].Id, payload[0].Id);
Assert.AreEqual(source[0].Key, payload[0].Key);
Assert.AreEqual(source[0].ChangeTypes, payload[0].ChangeTypes);
Assert.AreEqual(source[0].Blueprint, payload[0].Blueprint);
Assert.AreEqual(1234, payload[0].Id);
Assert.AreEqual(key, payload[0].Key);
Assert.AreEqual(changeTypes, payload[0].ChangeTypes);
Assert.AreEqual(blueprint, payload[0].Blueprint);
Assert.IsNull(payload[0].PublishedCultures);
Assert.IsNull(payload[0].UnpublishedCultures);
}
[Test]
public void ContentCacheRefresherCanDeserializeJsonPayloadWithCultures()
{
var key = Guid.NewGuid();
ContentCacheRefresher.JsonPayload[] source =
{
new ContentCacheRefresher.JsonPayload()
{
Id = 1234,
Key = key,
PublishedCultures = ["en-US", "da-DK"],
UnpublishedCultures = ["de-DE"]
}
};
var json = JsonSerializer.Serialize(source);
var payload = JsonSerializer.Deserialize<ContentCacheRefresher.JsonPayload[]>(json);
Assert.IsNotNull(payload[0].PublishedCultures);
Assert.Multiple(() =>
{
Assert.AreEqual(2, payload[0].PublishedCultures.Length);
Assert.AreEqual("en-US", payload[0].PublishedCultures.First());
Assert.AreEqual("da-DK", payload[0].PublishedCultures.Last());
});
Assert.IsNotNull(payload[0].UnpublishedCultures);
Assert.Multiple(() =>
{
Assert.AreEqual(1, payload[0].UnpublishedCultures.Length);
Assert.AreEqual("de-DE", payload[0].UnpublishedCultures.First());
});
}
[Test]

View File

@@ -357,6 +357,48 @@ public class RichTextParserTests : PropertyValueConverterTests
Assert.IsEmpty(blockLevelBlock.Elements);
}
[Test]
public void ParseElement_CanHandleWhitespaceAroundInlineElemements()
{
var parser = CreateRichTextElementParser();
var element = parser.Parse("<p>What follows from <strong>here</strong> <em>is</em> <a href=\"#\">just</a> a bunch of text.</p>") as RichTextRootElement;
Assert.IsNotNull(element);
var paragraphElement = element.Elements.Single() as RichTextGenericElement;
Assert.IsNotNull(paragraphElement);
var childElements = paragraphElement.Elements.ToArray();
Assert.AreEqual(7, childElements.Length);
var childElementCounter = 0;
void AssertNextChildElementIsText(string expectedText)
{
var textElement = childElements[childElementCounter++] as RichTextTextElement;
Assert.IsNotNull(textElement);
Assert.AreEqual(expectedText, textElement.Text);
}
void AssertNextChildElementIsGeneric(string expectedTag, string expectedInnerText)
{
var genericElement = childElements[childElementCounter++] as RichTextGenericElement;
Assert.IsNotNull(genericElement);
Assert.AreEqual(expectedTag, genericElement.Tag);
Assert.AreEqual(1, genericElement.Elements.Count());
var textElement = genericElement.Elements.First() as RichTextTextElement;
Assert.IsNotNull(textElement);
Assert.AreEqual(expectedInnerText, textElement.Text);
}
AssertNextChildElementIsText("What follows from ");
AssertNextChildElementIsGeneric("strong", "here");
AssertNextChildElementIsText(" ");
AssertNextChildElementIsGeneric("em", "is");
AssertNextChildElementIsText(" ");
AssertNextChildElementIsGeneric("a", "just");
AssertNextChildElementIsText(" a bunch of text.");
}
[Test]
public void ParseMarkup_CanParseContentLink()
{

View File

@@ -77,6 +77,19 @@ internal class UmbracoCmsSchema
public required MarketplaceSettings Marketplace { get; set; }
public InstallDefaultDataNamedOptions InstallDefaultData { get; set; } = null!;
public required WebhookSettings Webhook { get; set; }
}
public class InstallDefaultDataNamedOptions
{
public InstallDefaultDataSettings Languages { get; set; } = null!;
public InstallDefaultDataSettings DataTypes { get; set; } = null!;
public InstallDefaultDataSettings MediaTypes { get; set; } = null!;
public InstallDefaultDataSettings MemberTypes { get; set; } = null!;
}
}