Compare commits
28
Commits
v19/dev
...
release/18.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c087ce9fb5 | ||
|
|
8dd8820fa3 | ||
|
|
6b76230da5 | ||
|
|
2e3628dad3 | ||
|
|
cb23c84c3d | ||
|
|
1dfcd9332a | ||
|
|
d84186061c | ||
|
|
87bc6522f1 | ||
|
|
c300ebf94a | ||
|
|
e2252a8634 | ||
|
|
37e2458475 | ||
|
|
2461853b11 | ||
|
|
16c97d613f | ||
|
|
cffac2a990 | ||
|
|
6883c6fcfd | ||
|
|
5dd28e7a13 | ||
|
|
87b24912c7 | ||
|
|
f6c70e8429 | ||
|
|
1dbcf1037a | ||
|
|
27909ed18e | ||
|
|
54827c4c92 | ||
|
|
214fd03241 | ||
|
|
787f66be3d | ||
|
|
cd23fc75c5 | ||
|
|
0adb5d21f5 | ||
|
|
446a3795b7 | ||
|
|
3f0e0747fa | ||
|
|
f3471e961f |
@@ -64,4 +64,14 @@
|
||||
</_ProjectReferencesWithVersions>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!-- Workaround for https://github.com/umbraco/Umbraco-CMS/issues/23018
|
||||
Due to the amount of XML documentation in this solution, the OpenAPI XML documentation source generator produces
|
||||
too many lines of code causing a StackOverflowException when running on IIS. For that reason we disable the analyzer.
|
||||
See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/openapi-comments?view=aspnetcore-10.0#disabling-xml-documentation-support -->
|
||||
<Target Name="DisableCompileTimeOpenApiXmlGenerator" BeforeTargets="CoreCompile" Condition="'$(IsPackable)' != 'false' or '$(IsTestProject)' == 'true'">
|
||||
<ItemGroup>
|
||||
<Analyzer Remove="@(Analyzer)" Condition="'%(Filename)' == 'Microsoft.AspNetCore.OpenApi.SourceGenerators'" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<PackageVersion Include="MailKit" Version="4.16.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.1.3" />
|
||||
<PackageVersion Include="Markdown" Version="2.2.1" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.4" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.7" />
|
||||
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
|
||||
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
|
||||
<PackageVersion Include="ncrontab" Version="3.4.0" />
|
||||
|
||||
+32
-98
@@ -825,74 +825,31 @@ stages:
|
||||
publishFeedCredentials: "MyGet - Umbraco Nightly"
|
||||
${{ else }}:
|
||||
publishFeedCredentials: "MyGet - Pre-releases"
|
||||
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
|
||||
- job:
|
||||
displayName: Push to pre-release feed (npm)
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm
|
||||
- bash: |
|
||||
# Check if we are on a nightly build
|
||||
if [ $isNightly = "False" ]; then
|
||||
echo "##[debug]Prerelease build detected"
|
||||
registry="https://www.myget.org/F/umbracoprereleases/npm/"
|
||||
else
|
||||
echo "##[debug]Nightly build detected"
|
||||
registry="https://www.myget.org/F/umbraconightly/npm/"
|
||||
fi
|
||||
echo "@umbraco-cms:registry=$registry" >> .npmrc
|
||||
env:
|
||||
isNightly: ${{parameters.isNightly}}
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm (MyGet)
|
||||
inputs:
|
||||
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm
|
||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||
- bash: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push to npm (MyGet)
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Push to npm (MyGet)
|
||||
${{ if eq(parameters.isNightly, true) }}:
|
||||
registry: https://www.myget.org/F/umbraconightly/npm/
|
||||
${{ else }}:
|
||||
registry: https://www.myget.org/F/umbracoprereleases/npm/
|
||||
- job: PublishTestHelpersNpm
|
||||
displayName: Push TestHelpers to pre-release feed (npm)
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm-testhelpers
|
||||
- bash: |
|
||||
# Check if we are on a nightly build
|
||||
if [ $isNightly = "False" ]; then
|
||||
echo "##[debug]Prerelease build detected"
|
||||
registry="https://www.myget.org/F/umbracoprereleases/npm/"
|
||||
else
|
||||
echo "##[debug]Nightly build detected"
|
||||
registry="https://www.myget.org/F/umbraconightly/npm/"
|
||||
fi
|
||||
echo "@umbraco-cms:registry=$registry" >> .npmrc
|
||||
env:
|
||||
isNightly: ${{parameters.isNightly}}
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm (MyGet)
|
||||
inputs:
|
||||
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm-testhelpers
|
||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||
- bash: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push test helpers to npm (MyGet)
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Push test helpers to npm (MyGet)
|
||||
${{ if eq(parameters.isNightly, true) }}:
|
||||
registry: https://www.myget.org/F/umbraconightly/npm/
|
||||
${{ else }}:
|
||||
registry: https://www.myget.org/F/umbracoprereleases/npm/
|
||||
|
||||
- stage: Deploy_NuGet
|
||||
displayName: NuGet release
|
||||
@@ -941,53 +898,30 @@ stages:
|
||||
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
|
||||
dependsOn:
|
||||
- Deploy_NuGet
|
||||
variables:
|
||||
# `latest` for stable releases, `next` for prereleases.
|
||||
npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
|
||||
jobs:
|
||||
- job: Publish
|
||||
displayName: Push to NPM
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm
|
||||
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/npm/.npmrc
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm
|
||||
registry: https://registry.npmjs.org/
|
||||
customEndpoint: "NPM - Umbraco Backoffice"
|
||||
- script: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push to npm
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Push to npm
|
||||
npmTag: $(npmDistTag)
|
||||
- job: PublishTestHelpers
|
||||
displayName: Push Test Helpers to NPM
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm-testhelpers
|
||||
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm-testhelpers
|
||||
registry: https://registry.npmjs.org/
|
||||
customEndpoint: "NPM - Umbraco Backoffice"
|
||||
- script: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push test helpers to npm
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Push test helpers to npm
|
||||
npmTag: $(npmDistTag)
|
||||
|
||||
- stage: Upload_API_Docs
|
||||
pool:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
parameters:
|
||||
- name: artifactName # "npm" or "npm-testhelpers"
|
||||
type: string
|
||||
- name: registry # scoped-registry URL to publish to
|
||||
type: string
|
||||
- name: customEndpoint # npmAuthenticate service connection(s)
|
||||
type: string
|
||||
- name: displayName # label for the publish step
|
||||
type: string
|
||||
- name: npmTag # dist-tag to publish under
|
||||
type: string
|
||||
default: latest
|
||||
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: ${{ parameters.artifactName }}
|
||||
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
|
||||
displayName: Add scoped registry to .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
|
||||
customEndpoint: ${{ parameters.customEndpoint }}
|
||||
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
|
||||
displayName: ${{ parameters.displayName }}
|
||||
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Schema;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
@@ -341,6 +343,12 @@ public sealed class ContentTypeSchemaTransformer : IOpenApiSchemaTransformer, IO
|
||||
|
||||
var schemaId = GetSchemaId(jsonTypeInfo);
|
||||
|
||||
// Types that produce 'true' in JSON Schema (unconstrained: JsonNode, object, custom-converter types) should be inline {} rather than named components.
|
||||
if (jsonTypeInfo.Kind == JsonTypeInfoKind.None && jsonTypeInfo.GetJsonSchemaAsNode().GetValueKind() == JsonValueKind.True)
|
||||
{
|
||||
return new OpenApiSchema();
|
||||
}
|
||||
|
||||
// If this is one of the types we handle, and we already started generating it, return a placeholder
|
||||
// to avoid circular reference issues.
|
||||
// In the document transformer, these placeholders will be replaced with the actual schemas.
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ public class CreateTemporaryFileController : TemporaryFileControllerBase
|
||||
|
||||
[HttpPost("")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[EndpointSummary("Creates a temporary file.")]
|
||||
|
||||
+1
-1
@@ -33430,7 +33430,7 @@
|
||||
"operationId": "PostTemporaryFile",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/x-www-form-urlencoded": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -16,21 +16,16 @@
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
The Razor editor in VS2026 and the C# extension for VS Code uses the Razor source generator
|
||||
The Razor editor in modern Visual Studio and the C# extension for VS Code use the Razor source generator
|
||||
for IDE functionality. We need to add some things to make sure it works correctly, but we
|
||||
only do them for design time builds, so that we don't impact regular builds or CI.
|
||||
We also have an escape hatch in case it does cause issues, users can set the appropriate property
|
||||
We also have an escape hatch in case it does cause issues, users can set EnableCohostEditorCompatibility=false
|
||||
in their project file to disable this.
|
||||
|
||||
CompilerVisibleProperty is surfaced to generators via AnalyzerConfigOptionsProvider, not as a source-generator input file,
|
||||
so it doesn't enter the hintName-collision codepath that AdditionalFiles does. Keeping it at evaluation time is safe.
|
||||
-->
|
||||
<ItemGroup Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
|
||||
<!--
|
||||
We have to make sure the source generator can see the .cshtml files, so make them AdditionalFiles.
|
||||
-->
|
||||
<AdditionalFiles Include="**\*.cshtml" />
|
||||
|
||||
<!--
|
||||
Make sure the source generator knows where the project is, so it can compute target paths.
|
||||
-->
|
||||
<CompilerVisibleProperty Include="MSBuildProjectDirectory" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -49,4 +49,39 @@
|
||||
<ContentWithTargetPath Include="@(_UmbracoFolderFiles)" Exclude="@(ContentWithTargetPath)" TargetPath="%(Identity)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!--
|
||||
The Razor source generator needs .cshtml files in @(AdditionalFiles). The Razor SDK adds them
|
||||
via @(RazorGenerate), but only inside a target that runs during the build — so during cohost
|
||||
design-time builds they may not be present yet, which is what PR #21861 worked around.
|
||||
|
||||
Doing the include at evaluation time (as PR #21861 did) causes duplicates with the SDK during
|
||||
dotnet watch / hot reload design-time builds: the SDK adds the same .cshtml under a different
|
||||
item Identity (slash form / relative vs absolute) and the generator then sees two inputs that
|
||||
derive the same hintName, which crashes it with CS8785 (see issue #22773).
|
||||
|
||||
Run as a target before CoreCompile (hot-reload path) and CompileDesignTime (IDE design-time path)
|
||||
so the SDK's contribution is visible in both cases. Then add only the .cshtml files that are not already
|
||||
present. Both sides are normalized to %(FullPath) so items with different Identity forms still compare equal.
|
||||
|
||||
Set EnableCohostEditorCompatibility=false in a project to opt out entirely.
|
||||
-->
|
||||
<Target Name="_UmbracoEnsureRazorAdditionalFilesForCohostEditor"
|
||||
BeforeTargets="CoreCompile;CompileDesignTime"
|
||||
Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
|
||||
<ItemGroup>
|
||||
<_UmbracoCshtmlCandidate Include="**\*.cshtml" />
|
||||
<_UmbracoCshtmlCandidateFull Include="@(_UmbracoCshtmlCandidate->'%(FullPath)')" />
|
||||
|
||||
<_UmbracoExistingAdditionalCshtmlFull
|
||||
Include="@(AdditionalFiles->'%(FullPath)')"
|
||||
Condition="'%(Extension)' == '.cshtml'" />
|
||||
|
||||
<_UmbracoCshtmlMissingFromAdditional
|
||||
Include="@(_UmbracoCshtmlCandidateFull)"
|
||||
Exclude="@(_UmbracoExistingAdditionalCshtmlFull)" />
|
||||
|
||||
<AdditionalFiles Include="@(_UmbracoCshtmlMissingFromAdditional)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerDelete : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerMove : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerNew : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerUpdate : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementCopy : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementDelete : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementMove : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementNew : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementPublish : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementRollback : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementUpdate : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -467,6 +467,20 @@ public static class DistributedCacheExtensions
|
||||
|
||||
#endregion
|
||||
|
||||
#region ElementContainerCacheRefresher
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the id/key map for the specified deleted element containers (folders).
|
||||
/// </summary>
|
||||
/// <param name="dc">The distributed cache.</param>
|
||||
/// <param name="deletedContainers">The element containers that were deleted.</param>
|
||||
public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable<EntityContainer> deletedContainers)
|
||||
=> dc.RefreshByPayload(
|
||||
ElementContainerCacheRefresher.UniqueId,
|
||||
deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key)));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Published Snapshot
|
||||
|
||||
/// <summary>
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping
|
||||
/// is evicted from <see cref="Services.IIdKeyMap"/> on every server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an
|
||||
/// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node
|
||||
/// itself, so without this handler the container's stale id/key mapping survives until the next app
|
||||
/// restart (see #23072).
|
||||
/// </remarks>
|
||||
public sealed class ElementContainerDeletedDistributedCacheNotificationHandler
|
||||
: DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification>
|
||||
{
|
||||
private readonly DistributedCache _distributedCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="distributedCache">The distributed cache.</param>
|
||||
public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache)
|
||||
=> _distributedCache = distributedCache;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state)
|
||||
{
|
||||
EntityContainer[] elementContainers = entities
|
||||
.Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer)
|
||||
.ToArray();
|
||||
|
||||
if (elementContainers.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_distributedCache.RemoveElementContainerCache(elementContainers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Provides cache refresh functionality for element containers (folders).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A deleted container's node id is never reused, so its key→id mapping in <see cref="IIdKeyMap"/> must be
|
||||
/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and
|
||||
/// the element tree's children query returns nothing until the next app restart. This refresher only evicts
|
||||
/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader
|
||||
/// invalidation performed by <see cref="ElementCacheRefresher"/>.
|
||||
/// </remarks>
|
||||
public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase<ElementContainerCacheRefresherNotification, ElementContainerCacheRefresher.JsonPayload>
|
||||
{
|
||||
private readonly IIdKeyMap _idKeyMap;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresher"/> class.
|
||||
/// </summary>
|
||||
public ElementContainerCacheRefresher(
|
||||
AppCaches appCaches,
|
||||
IJsonSerializer serializer,
|
||||
IIdKeyMap idKeyMap,
|
||||
IEventAggregator eventAggregator,
|
||||
ICacheRefresherNotificationFactory factory)
|
||||
: base(appCaches, serializer, eventAggregator, factory)
|
||||
=> _idKeyMap = idKeyMap;
|
||||
|
||||
#region Json
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JSON-serializable payload identifying an element container that changed.
|
||||
/// </summary>
|
||||
public class JsonPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="JsonPayload"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique integer identifier for the container.</param>
|
||||
/// <param name="key">The unique GUID key associated with the container.</param>
|
||||
public JsonPayload(int id, Guid key)
|
||||
{
|
||||
Id = id;
|
||||
Key = key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique integer identifier for the container.
|
||||
/// </summary>
|
||||
public int Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique GUID key associated with the container.
|
||||
/// </summary>
|
||||
public Guid Key { get; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Define
|
||||
|
||||
/// <summary>
|
||||
/// Represents a unique identifier for the cache refresher.
|
||||
/// </summary>
|
||||
public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Guid RefresherUniqueId => UniqueId;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Name => "Element Container Cache Refresher";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Refresher
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(JsonPayload[] payloads)
|
||||
{
|
||||
foreach (JsonPayload payload in payloads)
|
||||
{
|
||||
// Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync.
|
||||
_idKeyMap.ClearCache(payload.Id);
|
||||
}
|
||||
|
||||
base.Refresh(payloads);
|
||||
}
|
||||
|
||||
// These events should never trigger. Everything should be PAYLOAD/JSON.
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void RefreshAll() => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(int id) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(Guid id) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Remove(int id) => throw new NotSupportedException();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -405,7 +405,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -454,7 +454,8 @@
|
||||
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
|
||||
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
|
||||
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
|
||||
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
|
||||
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
|
||||
|
||||
@@ -463,7 +463,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -452,7 +452,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<key alias="clickJackingCheckHeaderFound">
|
||||
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
|
||||
<key alias="clickJackingCheckHeaderNotFound">
|
||||
|
||||
@@ -403,7 +403,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
|
||||
|
||||
private HealthCheckStatus CheckUmbracoApplicationUrl()
|
||||
{
|
||||
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
|
||||
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
|
||||
var url = settings.UmbracoApplicationUrl;
|
||||
|
||||
string resultMessage;
|
||||
StatusResultType resultType;
|
||||
var success = false;
|
||||
|
||||
if (url.IsNullOrWhiteSpace())
|
||||
if (url.IsNullOrWhiteSpace() is false)
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
|
||||
resultType = StatusResultType.Success;
|
||||
}
|
||||
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
|
||||
{
|
||||
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
|
||||
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
|
||||
resultType = StatusResultType.Error;
|
||||
}
|
||||
else
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
|
||||
resultType = StatusResultType.Success;
|
||||
success = true;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
}
|
||||
|
||||
return new HealthCheckStatus(resultMessage)
|
||||
{
|
||||
ResultType = resultType,
|
||||
ReadMoreLink = success
|
||||
ReadMoreLink = resultType == StatusResultType.Success
|
||||
? null
|
||||
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
|
||||
};
|
||||
|
||||
@@ -24,4 +24,9 @@ public enum TaggableObjectTypes
|
||||
/// Represents member entities (user accounts).
|
||||
/// </summary>
|
||||
Member,
|
||||
|
||||
/// <summary>
|
||||
/// Represents element entities.
|
||||
/// </summary>
|
||||
Element,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// A notification that is used to trigger the Element Container Cache Refresher.
|
||||
/// </summary>
|
||||
public class ElementContainerCacheRefresherNotification : CacheRefresherNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresherNotification"/> class.
|
||||
/// </summary>
|
||||
/// <param name="messageObject">The refresher payload.</param>
|
||||
/// <param name="messageType">Type of the cache refresher message, <see cref="MessageType"/>.</param>
|
||||
public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType)
|
||||
: base(messageObject, messageType)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,18 @@ public interface ITagService : IService
|
||||
/// </summary>
|
||||
IEnumerable<TaggedEntity> GetTaggedMembersByTag(string tag, string? group = null, string? culture = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all elements tagged with any tag in the specified group.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all elements tagged with the specified tag.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tags.
|
||||
/// </summary>
|
||||
@@ -100,6 +112,12 @@ public interface ITagService : IService
|
||||
/// </summary>
|
||||
IEnumerable<ITag> GetAllMemberTags(string? group = null, string? culture = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all element tags.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tags attached to an entity via a property.
|
||||
/// </summary>
|
||||
|
||||
@@ -101,6 +101,24 @@ public class TagService : RepositoryService, ITagService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Element, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTaggedEntitiesByTag(TaggableObjectTypes.Element, tag, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetAllTags(string? group = null, string? culture = null)
|
||||
{
|
||||
@@ -162,6 +180,15 @@ public class TagService : RepositoryService, ITagService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTagsForEntityType(TaggableObjectTypes.Element, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetTagsForProperty(int contentId, string propertyTypeAlias, string? group = null, string? culture = null)
|
||||
{
|
||||
|
||||
+13
-2
@@ -84,8 +84,19 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(serverAddress))
|
||||
{
|
||||
_logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
|
||||
return Task.CompletedTask;
|
||||
// No application URL is known yet: either detection is off (WebRouting:ApplicationUrlDetection is
|
||||
// None with no UmbracoApplicationUrl set), or detection is on but no request has been served yet.
|
||||
// Register with the machine name as a placeholder so server-role election can still proceed (uniqueness
|
||||
// comes from the server identity, not this address). If a URL is later detected from a request, the next
|
||||
// touch overwrites the placeholder.
|
||||
serverAddress = Environment.MachineName;
|
||||
_logger.LogDebug(
|
||||
"No application URL available; registering server with placeholder address {ServerAddress}.",
|
||||
serverAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
@@ -466,6 +466,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddNotificationHandler<MemberTypeChangedNotification, MemberTypeChangedDistributedCacheNotificationHandler>()
|
||||
.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>()
|
||||
.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>()
|
||||
.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>()
|
||||
;
|
||||
|
||||
// add notification handlers for auditing
|
||||
|
||||
+24
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Infrastructure.Scoping;
|
||||
|
||||
@@ -34,4 +35,27 @@ internal sealed class ElementContainerRepository : EntityContainerRepository, IE
|
||||
cacheSyncService)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void PersistDeletedItem(EntityContainer entity)
|
||||
{
|
||||
if (entity == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entity));
|
||||
}
|
||||
|
||||
// Element containers can be referenced as start nodes on individual users (umbracoUserStartNode)
|
||||
// and on user groups (umbracoUserGroup.startElementId). Both reference umbracoNode.id via FK,
|
||||
// so we must clear those references before deleting the underlying node.
|
||||
var args = new { id = entity.Id };
|
||||
Database.Execute(
|
||||
$"DELETE FROM {QuoteTableName(Constants.DatabaseSchema.Tables.UserStartNode)} WHERE {QuoteColumnName("startNode")} = @id",
|
||||
args);
|
||||
Database.Execute(
|
||||
$@"UPDATE {QuoteTableName(Constants.DatabaseSchema.Tables.UserGroup)}
|
||||
SET {QuoteColumnName("startElementId")} = NULL
|
||||
WHERE {QuoteColumnName("startElementId")} = @id",
|
||||
args);
|
||||
|
||||
base.PersistDeletedItem(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,6 +647,8 @@ ON (tagset.tag = {cmsTags}.tag AND tagset.{group} = {cmsTags}.{group} AND COALES
|
||||
return Constants.ObjectTypes.Media;
|
||||
case TaggableObjectTypes.Member:
|
||||
return Constants.ObjectTypes.Member;
|
||||
case TaggableObjectTypes.Element:
|
||||
return Constants.ObjectTypes.Element;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
@@ -9,23 +9,41 @@ using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IDomainCacheService" />, providing an in-memory cache of the configured <see cref="Domain" />s.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The cache is lazily populated from the database on first access and kept up to date in response to domain
|
||||
/// cache refresher notifications. It is registered as a singleton, so a single instance serves all requests.
|
||||
/// </remarks>
|
||||
public class DomainCacheService : IDomainCacheService
|
||||
{
|
||||
private readonly IDomainService _domainService;
|
||||
private readonly ICoreScopeProvider _coreScopeProvider;
|
||||
private readonly ConcurrentDictionary<int, Domain> _domains;
|
||||
private bool _initialized = false;
|
||||
private readonly Lock _initializationLock = new();
|
||||
|
||||
// Both fields are written under _initializationLock but read on the hot path (request routing) without
|
||||
// it. Marking them volatile makes those lock-free reads acquire-reads, so a reader is guaranteed to see
|
||||
// the fully populated dictionary and the completed-initialization flag together, never a stale or
|
||||
// half-published value. This is required for correctness on weak memory models such as ARM; on x86/x64
|
||||
// ordinary reads already have acquire semantics, but we cannot rely on that.
|
||||
private volatile ConcurrentDictionary<int, Domain> _domains = new();
|
||||
private volatile bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DomainCacheService" /> class.
|
||||
/// </summary>
|
||||
/// <param name="domainService">The service used to load domains from the database.</param>
|
||||
/// <param name="coreScopeProvider">The provider used to create scopes for database access.</param>
|
||||
public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider)
|
||||
{
|
||||
_domainService = domainService;
|
||||
_coreScopeProvider = coreScopeProvider;
|
||||
_domains = new ConcurrentDictionary<int, Domain>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Domain> GetAll(bool includeWildcards)
|
||||
{
|
||||
InitializeIfMissing();
|
||||
@@ -34,22 +52,38 @@ public class DomainCacheService : IDomainCacheService
|
||||
: _domains.Select(x => x.Value).OrderBy(x => x.SortOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the domains on first access, ensuring the cache is populated before any caller reads from it.
|
||||
/// </summary>
|
||||
private void InitializeIfMissing()
|
||||
{
|
||||
// Lazy, on-demand initialization triggered by the first request to reach the cache.
|
||||
// The flag must only be set to true *after* the domains have been loaded and published.
|
||||
// Setting it beforehand creates a window where a concurrent caller observes _initialized == true,
|
||||
// skips loading, and reads an empty domain cache. On a multi-site setup that empties domain
|
||||
// resolution, causing every site to fall back to the first root node (see ContentFinderByUrlNew).
|
||||
// The double-checked lock ensures a single load while concurrent readers block until it completes.
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_initialized = true;
|
||||
LoadDomains();
|
||||
|
||||
lock (_initializationLock)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadDomains();
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false)
|
||||
{
|
||||
InitializeIfMissing();
|
||||
// probably this could be optimized with an index
|
||||
// but then we'd need a custom DomainStore of some sort
|
||||
IEnumerable<Domain> list = _domains.Values.Where(x => x.ContentId == documentId);
|
||||
if (includeWildcards == false)
|
||||
{
|
||||
@@ -66,6 +100,7 @@ public class DomainCacheService : IDomainCacheService
|
||||
return documentId > 0 && GetAssigned(documentId, includeWildcards).Any();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Refresh(DomainCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
foreach (DomainCacheRefresher.JsonPayload payload in payloads)
|
||||
@@ -102,20 +137,23 @@ public class DomainCacheService : IDomainCacheService
|
||||
continue; // anomaly
|
||||
}
|
||||
|
||||
var newDomain = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
|
||||
|
||||
// Feels wierd to use key and oldvalue, but we're using neither when updating.
|
||||
_domains.AddOrUpdate(
|
||||
domain.Id,
|
||||
new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder),
|
||||
(key, oldValue) => newDomain);
|
||||
_domains[domain.Id] = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the configured domains from the database into a fresh dictionary and atomically swaps it in
|
||||
/// as the current cache.
|
||||
/// </summary>
|
||||
private void LoadDomains()
|
||||
{
|
||||
// Build the replacement set in a local dictionary and publish it with a single write to the
|
||||
// (volatile) _domains field. A reader never observes a partially populated cache during a RefreshAll
|
||||
// rebuild, and the published set contains exactly the current domains (any removed since the last
|
||||
// load are absent).
|
||||
var newDomains = new ConcurrentDictionary<int, Domain>();
|
||||
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
|
||||
{
|
||||
scope.ReadLock(Constants.Locks.Domains);
|
||||
@@ -124,11 +162,11 @@ public class DomainCacheService : IDomainCacheService
|
||||
.Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false)
|
||||
.Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder)))
|
||||
{
|
||||
_domains.AddOrUpdate(domain.Id, domain, (key, oldValue) => domain);
|
||||
newDomains[domain.Id] = domain;
|
||||
}
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
|
||||
_domains = newDomains;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ Why: Normalizes to conventional semver format
|
||||
"peerDependencies": {
|
||||
"lit": "^3.3.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"@umbraco-ui/uui": "^2.0.0-alpha.1",
|
||||
"@umbraco-ui/uui": "^2.0.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"@tiptap/core": "^3.16.0",
|
||||
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
|
||||
|
||||
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@umbraco-cms/backoffice",
|
||||
"version": "18.0.0-rc1",
|
||||
"version": "18.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@umbraco-cms/backoffice",
|
||||
"version": "18.0.0-rc1",
|
||||
"version": "18.0.0",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./src/libs/*",
|
||||
@@ -3963,9 +3963,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@umbraco-ui/uui": {
|
||||
"version": "2.0.0-rc.1",
|
||||
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0-rc.1.tgz",
|
||||
"integrity": "sha512-qhKsTl11hq82GQWZEq/2f/D1UePjfN9DLet7MqastvEKFdwdgHM9cCu+2f3mlOcX/OHj5XiUwLVFoJanRgfJZg==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0.tgz",
|
||||
"integrity": "sha512-snH3u1C4gpvO/bxTfTJV6lF3HVpru5v0ssMbz0aESOt66gRyGf//fshgUHgvYa5gc3wE2Fr4uGx8mKgUC/GrXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"culori": "^4.0.2",
|
||||
@@ -16447,7 +16447,7 @@
|
||||
"src/external/uui": {
|
||||
"name": "@umbraco-backoffice/uui",
|
||||
"dependencies": {
|
||||
"@umbraco-ui/uui": "^2.0.0-rc.1"
|
||||
"@umbraco-ui/uui": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"src/libs/class-api": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@umbraco-cms/backoffice",
|
||||
"license": "MIT",
|
||||
"version": "18.0.0-rc1",
|
||||
"version": "18.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": null,
|
||||
|
||||
@@ -262,7 +262,7 @@ export class UmbAppElement extends UmbLitElement {
|
||||
|
||||
// Register public extensions (login extensions)
|
||||
await new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerPublicExtensions();
|
||||
new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
|
||||
const entryPointInitializer = new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
|
||||
|
||||
// Try to initialise the auth flow and get the runtime status
|
||||
try {
|
||||
@@ -278,6 +278,12 @@ export class UmbAppElement extends UmbLitElement {
|
||||
await this.#setAuthStatus();
|
||||
}
|
||||
|
||||
// The login screen decides which auth provider to use from the registered
|
||||
// `authProvider` extensions. App-entry-points may register or unregister those during
|
||||
// their async onInit, so wait for them to settle before routing — otherwise on a slow
|
||||
// connection the decision races and falls back to the local login.
|
||||
await this.observe(entryPointInitializer.loaded).asPromise();
|
||||
|
||||
// Initialise the router
|
||||
this.#redirect();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1258,6 +1258,7 @@ export default {
|
||||
},
|
||||
sections: {
|
||||
content: 'المحتوى',
|
||||
library: 'المكتبة',
|
||||
media: 'الوسائط',
|
||||
member: 'الأعضاء',
|
||||
packages: 'الحزم',
|
||||
|
||||
@@ -1184,20 +1184,13 @@ export default {
|
||||
editscript: 'Uredite datoteku skripte',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Portirnica',
|
||||
content: 'Sadržaj',
|
||||
courier: 'Kurir',
|
||||
developer: 'Developer',
|
||||
forms: 'Forme',
|
||||
help: 'Pomoć',
|
||||
installer: 'Umbraco Konfiguracijski Čarobnjak',
|
||||
library: 'Biblioteka',
|
||||
media: 'Mediji',
|
||||
member: 'Članovi',
|
||||
newsletters: 'Bilteni',
|
||||
packages: 'Paketi',
|
||||
marketplace: 'Marketplace',
|
||||
settings: 'Postavke',
|
||||
statistics: 'Statistika',
|
||||
translation: 'Prevodi',
|
||||
users: 'Korisnici',
|
||||
},
|
||||
|
||||
@@ -1091,20 +1091,13 @@ export default {
|
||||
},
|
||||
sections: {
|
||||
content: 'Obsah',
|
||||
forms: 'Formuláře',
|
||||
library: 'Knihovna',
|
||||
media: 'Média',
|
||||
member: 'Členové',
|
||||
packages: 'Balíčky',
|
||||
settings: 'Nastavení',
|
||||
translation: 'Překlad',
|
||||
users: 'Uživatelé',
|
||||
concierge: 'Domovník',
|
||||
courier: 'Kurýr',
|
||||
developer: 'Vývojář',
|
||||
installer: 'Průvodce nastavením Umbraca',
|
||||
newsletters: 'Zpravodaje',
|
||||
statistics: 'Statistiky',
|
||||
help: 'Nápověda',
|
||||
},
|
||||
settings: {
|
||||
defaulttemplate: 'Výchozí šablona',
|
||||
|
||||
@@ -1288,14 +1288,14 @@ export default {
|
||||
},
|
||||
sections: {
|
||||
content: 'Cynnwys',
|
||||
forms: 'Ffurflenni',
|
||||
library: 'Llyfrgell',
|
||||
media: 'Cyfrwng',
|
||||
member: 'Aelodau',
|
||||
packages: 'Pecynnau',
|
||||
marketplace: 'Marchnad',
|
||||
settings: 'Gosodiadau',
|
||||
translation: 'Cyfieithiad',
|
||||
users: 'Defnyddwyr',
|
||||
marketplace: 'Marchnad',
|
||||
},
|
||||
settings: {
|
||||
defaulttemplate: 'Templed diofyn',
|
||||
|
||||
@@ -1474,6 +1474,7 @@ export default {
|
||||
},
|
||||
sections: {
|
||||
content: 'Indhold',
|
||||
library: 'Bibliotek',
|
||||
media: 'Mediearkiv',
|
||||
member: 'Medlemmer',
|
||||
packages: 'Pakker',
|
||||
|
||||
@@ -1056,7 +1056,7 @@ export default {
|
||||
greeting5: 'Willkommen',
|
||||
greeting6: 'Willkommen',
|
||||
instruction: 'Hier anmelden:',
|
||||
signInWith: 'Anmelden mit',
|
||||
signInWith: 'Anmelden mit {0}',
|
||||
timeout: 'Sitzung abgelaufen',
|
||||
forgottenPassword: 'Kennwort vergessen?',
|
||||
forgottenPasswordInstruction:
|
||||
@@ -1309,7 +1309,7 @@ export default {
|
||||
},
|
||||
sections: {
|
||||
content: 'Inhalte',
|
||||
forms: 'Formulare',
|
||||
library: 'Bibliothek',
|
||||
media: 'Medien',
|
||||
member: 'Mitglieder',
|
||||
packages: 'Pakete',
|
||||
|
||||
@@ -885,20 +885,14 @@ export default {
|
||||
editscript: 'Editar fichero de script',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Conserje',
|
||||
content: 'Contenido',
|
||||
courier: 'Mensajero',
|
||||
developer: 'Desarrollador',
|
||||
installer: 'Asistente de configuración de Umbraco',
|
||||
library: 'Biblioteca',
|
||||
media: 'Media',
|
||||
member: 'Miembros',
|
||||
newsletters: 'Boletín informativo',
|
||||
packages: 'Paquetes',
|
||||
settings: 'Ajustes',
|
||||
statistics: 'Estadísticas',
|
||||
translation: 'Traducción',
|
||||
users: 'Usuarios',
|
||||
help: 'Ayuda',
|
||||
packages: 'Paquetes',
|
||||
},
|
||||
settings: {
|
||||
defaulttemplate: 'Plantilla por defecto',
|
||||
|
||||
@@ -923,7 +923,7 @@ export default {
|
||||
greeting5: 'Bienvenue',
|
||||
greeting6: 'Bienvenue',
|
||||
instruction: 'Connectez-vous ci-dessous',
|
||||
signInWith: 'Identifiez-vous avec',
|
||||
signInWith: 'Identifiez-vous avec {0}',
|
||||
timeout: 'La session a expiré',
|
||||
forgottenPassword: 'Mot de passe oublié?',
|
||||
forgottenPasswordInstruction:
|
||||
@@ -1117,7 +1117,7 @@ export default {
|
||||
},
|
||||
sections: {
|
||||
content: 'Contenu',
|
||||
forms: 'Formulaires',
|
||||
library: 'Bibliothèque',
|
||||
media: 'Medias',
|
||||
member: 'Membres',
|
||||
packages: 'Packages',
|
||||
|
||||
@@ -548,16 +548,11 @@ export default {
|
||||
editscript: 'ערוך קובץ סקריפט',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Concierge',
|
||||
content: 'תוכן',
|
||||
courier: 'Courier',
|
||||
developer: 'מפתח',
|
||||
installer: 'אשף הגדרת אומברקו',
|
||||
library: 'ספרייה',
|
||||
media: 'מדיה',
|
||||
member: 'חברים',
|
||||
newsletters: 'עיתון',
|
||||
settings: 'הגדרות',
|
||||
statistics: 'סטטיסטיקות',
|
||||
translation: 'תירגום',
|
||||
users: 'משתמשים',
|
||||
},
|
||||
|
||||
@@ -1228,20 +1228,13 @@ export default {
|
||||
editscript: 'Uredite datoteku skripte',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Portirnica',
|
||||
content: 'Sadržaj',
|
||||
courier: 'Kurir',
|
||||
developer: 'Developer',
|
||||
forms: 'Forme',
|
||||
help: 'Pomoć',
|
||||
installer: 'Umbraco Konfiguracijski Čarobnjak',
|
||||
library: 'Knjižnica',
|
||||
media: 'Mediji',
|
||||
member: 'Članovi',
|
||||
newsletters: 'Newsletteri',
|
||||
packages: 'Paketi',
|
||||
marketplace: 'Marketplace',
|
||||
settings: 'Postavke',
|
||||
statistics: 'Statistika',
|
||||
translation: 'Prijevodi',
|
||||
users: 'Korisnici',
|
||||
},
|
||||
|
||||
@@ -1232,7 +1232,7 @@ export default {
|
||||
},
|
||||
sections: {
|
||||
content: 'Contenuto',
|
||||
forms: 'Forms',
|
||||
library: 'Biblioteca',
|
||||
media: 'Media',
|
||||
member: 'Membri',
|
||||
packages: 'Pacchetti',
|
||||
|
||||
@@ -719,20 +719,13 @@ export default {
|
||||
editscript: 'スクリプトファイルの編集',
|
||||
},
|
||||
sections: {
|
||||
concierge: '管理人',
|
||||
content: 'コンテンツ',
|
||||
courier: 'Courier',
|
||||
developer: '開発',
|
||||
installer: 'Umbraco 設定ウィザード',
|
||||
library: 'ライブラリ',
|
||||
media: 'メディア',
|
||||
member: 'メンバー',
|
||||
newsletters: 'ニュースレター',
|
||||
settings: '設定',
|
||||
statistics: '統計',
|
||||
translation: '翻訳',
|
||||
users: 'ユーザー',
|
||||
help: 'ヘルプ',
|
||||
forms: 'フォーム',
|
||||
},
|
||||
settings: {
|
||||
defaulttemplate: '既定のテンプレート',
|
||||
|
||||
@@ -546,16 +546,11 @@ export default {
|
||||
editscript: '스크립트 파일 편집',
|
||||
},
|
||||
sections: {
|
||||
concierge: '안내',
|
||||
content: '컨텐츠',
|
||||
courier: '가이드',
|
||||
developer: '개발도구',
|
||||
installer: 'Umbraco 설치마법사',
|
||||
library: '라이브러리',
|
||||
media: '미디어',
|
||||
member: '구성원',
|
||||
newsletters: '뉴스레터',
|
||||
settings: '세팅',
|
||||
statistics: '통계',
|
||||
translation: '변환',
|
||||
users: '사용자',
|
||||
},
|
||||
|
||||
@@ -712,7 +712,7 @@ export default {
|
||||
greeting5: 'Velkommen',
|
||||
greeting6: 'Velkommen',
|
||||
instruction: 'Logg på nedenfor',
|
||||
signInWith: 'Logg på med',
|
||||
signInWith: 'Logg på med {0}',
|
||||
timeout: 'Din sesjon er utløpt',
|
||||
continue: 'Fortsett',
|
||||
validate: 'Valider',
|
||||
@@ -871,20 +871,13 @@ export default {
|
||||
editscript: 'Rediger scriptfilen',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Concierge',
|
||||
content: 'Innhold',
|
||||
courier: 'Courier',
|
||||
developer: 'Utvikler',
|
||||
installer: 'Umbraco konfigurasjonsveiviser',
|
||||
library: 'Bibliotek',
|
||||
media: 'Mediaarkiv',
|
||||
member: 'Medlemmer',
|
||||
newsletters: 'Nyhetsbrev',
|
||||
settings: 'Innstillinger',
|
||||
statistics: 'Statistikk',
|
||||
translation: 'Oversettelse',
|
||||
users: 'Brukere',
|
||||
help: 'Hjelp',
|
||||
forms: 'Skjemaer',
|
||||
},
|
||||
settings: {
|
||||
defaulttemplate: 'Standardmal',
|
||||
|
||||
@@ -943,7 +943,7 @@ export default {
|
||||
greeting5: 'Welkom',
|
||||
greeting6: 'Welkom',
|
||||
instruction: 'log hieronder in',
|
||||
signInWith: 'Inloggen met',
|
||||
signInWith: 'Inloggen met {0}',
|
||||
timeout: 'Sessie is verlopen',
|
||||
forgottenPassword: 'Wachtwoord vergeten?',
|
||||
forgottenPasswordInstruction:
|
||||
@@ -1187,7 +1187,7 @@ export default {
|
||||
},
|
||||
sections: {
|
||||
content: 'Inhoud',
|
||||
forms: 'Formulieren',
|
||||
library: 'Bibliotheek',
|
||||
media: 'Media',
|
||||
member: 'Leden',
|
||||
packages: 'Packages',
|
||||
|
||||
@@ -845,20 +845,13 @@ export default {
|
||||
editscript: 'Edytuj skrypt',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Concierge',
|
||||
content: 'Treść',
|
||||
courier: 'Kurier',
|
||||
developer: 'Deweloper',
|
||||
installer: 'Konfigurator Umbraco',
|
||||
library: 'Biblioteka',
|
||||
media: 'Media',
|
||||
member: 'Członkowie',
|
||||
newsletters: 'Biuletyny',
|
||||
settings: 'Ustawienia',
|
||||
statistics: 'Statystyki',
|
||||
translation: 'Tłumaczenie',
|
||||
users: 'Użytkownicy',
|
||||
help: 'Pomoc',
|
||||
forms: 'Formularze',
|
||||
},
|
||||
settings: {
|
||||
defaulttemplate: 'Domyślny szablon',
|
||||
|
||||
@@ -375,14 +375,8 @@ export default {
|
||||
editscript: 'Editar arquivo de script',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Porteiro',
|
||||
courier: 'Mensageiro',
|
||||
developer: 'Desenvolvedor',
|
||||
installer: 'Assistente de Configuração Umbraco',
|
||||
media: 'Mídia',
|
||||
newsletters: 'Boletins Informativos',
|
||||
settings: 'Configurações',
|
||||
statistics: 'Estatísticas',
|
||||
users: 'Usuários',
|
||||
},
|
||||
settings: {
|
||||
|
||||
@@ -1403,6 +1403,7 @@ export default {
|
||||
},
|
||||
sections: {
|
||||
content: 'Conteúdo',
|
||||
library: 'Biblioteca',
|
||||
media: 'Multimédia',
|
||||
member: 'Membros',
|
||||
packages: 'Pacotes',
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { UmbLocalizationDictionary } from '@umbraco-cms/backoffice/localiza
|
||||
export default {
|
||||
sections: {
|
||||
content: 'Conţinut',
|
||||
forms: 'Formulare',
|
||||
library: 'Bibliotecă',
|
||||
media: 'Media',
|
||||
member: 'Membrii',
|
||||
packages: 'Pachete',
|
||||
|
||||
@@ -1023,18 +1023,11 @@ export default {
|
||||
editscript: 'Править файл скрипта',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Смотритель',
|
||||
content: 'Содержимое',
|
||||
courier: 'Курьер',
|
||||
developer: 'Разработка',
|
||||
forms: 'Формы',
|
||||
help: 'Помощь',
|
||||
installer: 'Мастер конфигурирования Umbraco',
|
||||
library: 'Библиотека',
|
||||
media: 'Медиа-материалы',
|
||||
member: 'Участники',
|
||||
newsletters: 'Рассылки',
|
||||
settings: 'Установки',
|
||||
statistics: 'Статистика',
|
||||
translation: 'Перевод',
|
||||
users: 'Пользователи',
|
||||
},
|
||||
|
||||
@@ -694,7 +694,7 @@ export default {
|
||||
greeting5: 'Välkommen',
|
||||
greeting6: 'Välkommen',
|
||||
instruction: 'Logga in nedan',
|
||||
signInWith: 'Logga in med',
|
||||
signInWith: 'Logga in med {0}',
|
||||
timeout: 'Sessionen har nått sin maxgräns',
|
||||
continue: 'Fortsätt',
|
||||
validate: 'Validera',
|
||||
@@ -883,19 +883,12 @@ export default {
|
||||
editscript: 'Redigera script',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Concierge',
|
||||
content: 'Innehåll',
|
||||
courier: 'Courier',
|
||||
developer: 'Utvecklare',
|
||||
forms: 'Formulär',
|
||||
help: 'Hjälp',
|
||||
installer: 'Umbraco konfigurationsguide',
|
||||
library: 'Bibliotek',
|
||||
media: 'Media',
|
||||
member: 'Medlemmar',
|
||||
newsletters: 'Nyhetsbrev',
|
||||
packages: 'Paket',
|
||||
settings: 'Inställningar',
|
||||
statistics: 'Statistik',
|
||||
translation: 'Översättning',
|
||||
users: 'Användare',
|
||||
},
|
||||
|
||||
@@ -1094,19 +1094,12 @@ export default {
|
||||
editscript: 'Komut dosyasını düzenle',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Konsiyerj',
|
||||
content: 'İçerik',
|
||||
courier: 'Kurye',
|
||||
developer: 'Geliştirici',
|
||||
forms: 'Formlar',
|
||||
help: 'Yardım',
|
||||
installer: 'Umbraco Yapılandırma Sihirbazı',
|
||||
library: 'Kitaplık',
|
||||
media: 'Medya',
|
||||
member: 'Üyeler',
|
||||
newsletters: 'Bültenler',
|
||||
packages: 'Paketler',
|
||||
settings: 'Ayarlar',
|
||||
statistics: 'İstatistikler',
|
||||
translation: 'Çeviri',
|
||||
users: 'Kullanıcılar',
|
||||
},
|
||||
|
||||
@@ -1021,18 +1021,11 @@ export default {
|
||||
editscript: 'Редагувати файл скрипта',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Консьєрж',
|
||||
content: 'Вміст',
|
||||
courier: "Кур'єр",
|
||||
developer: 'Розробка',
|
||||
forms: 'Форми',
|
||||
help: 'Допомога',
|
||||
installer: 'Майстер конфігурування Umbraco',
|
||||
library: 'Бібліотека',
|
||||
media: 'Медіа-матеріали',
|
||||
member: 'Учасники',
|
||||
newsletters: 'Розсилки',
|
||||
settings: 'Налаштування',
|
||||
statistics: 'Статистика',
|
||||
translation: 'Переклад',
|
||||
users: 'Користувачі',
|
||||
},
|
||||
|
||||
@@ -1413,6 +1413,7 @@ export default {
|
||||
},
|
||||
sections: {
|
||||
content: 'Nội dung',
|
||||
library: 'Thư viện',
|
||||
media: 'Phương tiện',
|
||||
member: 'Thành viên',
|
||||
packages: 'Gói mở rộng',
|
||||
|
||||
@@ -691,20 +691,13 @@ export default {
|
||||
editscript: '編輯腳本',
|
||||
},
|
||||
sections: {
|
||||
concierge: 'Concierge',
|
||||
content: '內容',
|
||||
courier: 'Courier',
|
||||
developer: '開發',
|
||||
installer: '設定精靈',
|
||||
library: '資源庫',
|
||||
media: '媒體',
|
||||
member: '會員',
|
||||
newsletters: '消息',
|
||||
settings: '設置',
|
||||
statistics: '統計',
|
||||
translation: '翻譯',
|
||||
users: '用戶',
|
||||
help: '說明',
|
||||
forms: '表單',
|
||||
},
|
||||
settings: {
|
||||
defaulttemplate: '預設範本',
|
||||
|
||||
@@ -689,20 +689,13 @@ export default {
|
||||
editscript: '编辑脚本',
|
||||
},
|
||||
sections: {
|
||||
concierge: '礼宾',
|
||||
content: '内容',
|
||||
courier: '导游',
|
||||
developer: '开发',
|
||||
installer: 'Umbraco配置向导',
|
||||
library: '资源库',
|
||||
media: '媒体',
|
||||
member: '会员',
|
||||
newsletters: '消息',
|
||||
settings: '设置',
|
||||
statistics: '统计',
|
||||
translation: '翻译',
|
||||
users: '用户',
|
||||
help: '帮助',
|
||||
forms: '窗体',
|
||||
},
|
||||
settings: {
|
||||
defaulttemplate: '默认模板',
|
||||
|
||||
+2
-2
@@ -6,6 +6,6 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@umbraco-ui/uui": "^2.0.0-rc.1"
|
||||
"@umbraco-ui/uui": "^2.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import type { ManifestBase } from '../types/index.js';
|
||||
import { UmbExtensionRegistry } from '../registry/extension.registry.js';
|
||||
import { loadManifestPlainJs } from '../functions/load-manifest-plain-js.function.js';
|
||||
import { UmbExtensionInitializerBase } from './extension-initializer-base.js';
|
||||
import { UmbObserver } from '../../observable-api/observer.js';
|
||||
import { expect, fixture } from '@open-wc/testing';
|
||||
import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
|
||||
import type { UmbControllerHostElement } from '@umbraco-cms/backoffice/controller-api';
|
||||
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
|
||||
|
||||
@customElement('umb-test-initializer-base-host')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
class UmbTestInitializerBaseHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
|
||||
|
||||
async function wait(ms: number) {
|
||||
await new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// Factory for a concrete initializer over the 'test' manifest type. The base constructor's
|
||||
// `observe` callback fires synchronously during `super()` — before any subclass field would
|
||||
// initialise — so the record of instantiated aliases is a closed-over array created up front
|
||||
// rather than instance state.
|
||||
function createTestInitializer(host: UmbControllerHostElement, registry: UmbExtensionRegistry<ManifestBase>) {
|
||||
const instantiated: string[] = [];
|
||||
class UmbTestInitializer extends UmbExtensionInitializerBase<'test'> {
|
||||
constructor() {
|
||||
super(host, registry as never, 'test');
|
||||
}
|
||||
async instantiateExtension(manifest: ManifestBase & { js?: unknown }): Promise<void> {
|
||||
if (manifest.js) {
|
||||
await loadManifestPlainJs(manifest.js as never);
|
||||
}
|
||||
instantiated.push(manifest.alias);
|
||||
}
|
||||
unloadExtension(manifest: ManifestBase): void {
|
||||
const index = instantiated.indexOf(manifest.alias);
|
||||
if (index !== -1) instantiated.splice(index, 1);
|
||||
}
|
||||
}
|
||||
return { initializer: new UmbTestInitializer(), instantiated };
|
||||
}
|
||||
|
||||
describe('UmbExtensionInitializerBase — loaded signal', () => {
|
||||
let hostElement: UmbControllerHostElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
hostElement = await fixture(html`<umb-test-initializer-base-host></umb-test-initializer-base-host>`);
|
||||
});
|
||||
|
||||
// Regression for the v17.4+ external-login race (introduced in #22522).
|
||||
//
|
||||
// A default Umbraco install registers ZERO app-entry-point extensions. The boot sequence
|
||||
// awaits the app-entry-point initializer's `loaded` before deciding which login provider
|
||||
// to use. If `loaded` never resolves when there are no matching extensions, that await
|
||||
// hangs forever — which is precisely why the await was removed, leaving externally
|
||||
// registered auth providers un-awaited and the login flow racing on slow connections.
|
||||
//
|
||||
// So: an initializer for a type with zero matching extensions MUST still resolve `loaded`.
|
||||
it('resolves `loaded` even when no extensions of the type are registered', async () => {
|
||||
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
|
||||
const { initializer } = createTestInitializer(hostElement, extensionRegistry);
|
||||
|
||||
const outcome = await Promise.race([
|
||||
new UmbObserver(initializer.loaded).asPromise().then(() => 'resolved'),
|
||||
wait(1000).then(() => 'timeout'),
|
||||
]);
|
||||
|
||||
expect(outcome, '`loaded` must resolve for an initializer with zero matching extensions').to.equal('resolved');
|
||||
});
|
||||
|
||||
// Regression for the late-loading race that the external-login bug is built on.
|
||||
//
|
||||
// This simulates an extension that registers AFTER the initial load and whose
|
||||
// instantiation is slow (the app-entry-point case: its onInit registers an auth provider
|
||||
// after an async module load). A consumer that awaits `loaded` must not be told "loaded"
|
||||
// until that late, slow extension has actually finished instantiating — otherwise it makes
|
||||
// its decision (e.g. which login provider to redirect to) against a stale registry.
|
||||
it('does not report `loaded` until a late-registered, slow extension has finished instantiating', async () => {
|
||||
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
|
||||
|
||||
// Initial, fast extension — load settles to `true`.
|
||||
extensionRegistry.register({ type: 'test', name: 'a', alias: 'Umb.Test.A' } as never);
|
||||
const { initializer, instantiated } = createTestInitializer(hostElement, extensionRegistry);
|
||||
await new UmbObserver(initializer.loaded).asPromise();
|
||||
expect(instantiated, 'initial extension instantiated').to.eql(['Umb.Test.A']);
|
||||
|
||||
// A late, slow extension registers (mirrors an app-entry-point's onInit registering an
|
||||
// auth provider after an async delay).
|
||||
extensionRegistry.register({
|
||||
type: 'test',
|
||||
name: 'b-late',
|
||||
alias: 'Umb.Test.B.Late',
|
||||
js: () => new Promise((r) => setTimeout(() => r({}), 100)),
|
||||
} as never);
|
||||
|
||||
// Awaiting `loaded` now must wait for the late extension to finish instantiating.
|
||||
const lateExtInstantiatedWhenLoaded = await new UmbObserver(initializer.loaded)
|
||||
.asPromise()
|
||||
.then(() => instantiated.includes('Umb.Test.B.Late'));
|
||||
|
||||
expect(
|
||||
lateExtInstantiatedWhenLoaded,
|
||||
'`loaded` resolved before the late, slow extension finished instantiating',
|
||||
).to.be.true;
|
||||
});
|
||||
|
||||
// Permission-timing guard (re: the #22522 "user permissions resolved too late" concern).
|
||||
//
|
||||
// The backoffice route is gated by `#loadedGuard`, which awaits `bundleInitializer.loaded`
|
||||
// via `.asPromise()`; the private extensions and user-permission data that load behind that
|
||||
// gate must not be raced. So the gate must NOT open until the extensions registered before it
|
||||
// was awaited have actually finished instantiating. This guards against a naive "resolve
|
||||
// unconditionally" that sets `loaded` before instantiation completes.
|
||||
//
|
||||
// Note: user-permission *condition* resolution itself lives in UmbBaseExtensionInitializer
|
||||
// (see base-extension-initializer.race.test.ts) — a different class this change does not touch.
|
||||
it('does not open the `loaded` gate until the initially-registered extensions have instantiated', async () => {
|
||||
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
|
||||
extensionRegistry.register({
|
||||
type: 'test',
|
||||
name: 'slow-boot',
|
||||
alias: 'Umb.Test.SlowBoot',
|
||||
js: () => new Promise((r) => setTimeout(() => r({}), 100)),
|
||||
} as never);
|
||||
|
||||
const {initializer, instantiated} = createTestInitializer(hostElement, extensionRegistry);
|
||||
|
||||
const instantiatedWhenGateOpened = await new UmbObserver(initializer.loaded)
|
||||
.asPromise()
|
||||
.then(() => instantiated.includes('Umb.Test.SlowBoot'));
|
||||
|
||||
expect(instantiatedWhenGateOpened, '`loaded` opened the gate before the extension instantiated').to.be.true;
|
||||
});
|
||||
});
|
||||
+26
-2
@@ -20,11 +20,23 @@ export abstract class UmbExtensionInitializerBase<
|
||||
#loaded = new UmbBooleanState(undefined);
|
||||
loaded = this.#loaded.asObservable();
|
||||
|
||||
// Identifies the current processing pass. The observer callback is async, so passes can
|
||||
// overlap; only the latest pass is allowed to settle `loaded`, so a slower earlier pass
|
||||
// cannot unblock waiters before the newest set of extensions has finished instantiating.
|
||||
#loadPass = 0;
|
||||
|
||||
constructor(host: UmbElement, extensionRegistry: UmbExtensionRegistry<T>, manifestType: Key) {
|
||||
super(host);
|
||||
this.host = host;
|
||||
this.extensionRegistry = extensionRegistry;
|
||||
this.observe(extensionRegistry.byType<Key, T>(manifestType), async (extensions) => {
|
||||
const pass = ++this.#loadPass;
|
||||
|
||||
// Re-arm while this pass is in flight so a consumer awaiting `loaded` waits for it to
|
||||
// finish instead of resolving on a stale `true` from a previous pass. `undefined`
|
||||
// rather than `false` because `asPromise()` resolves on the first non-undefined value.
|
||||
this.#loaded.setValue(undefined);
|
||||
|
||||
this.#extensionMap.forEach((existingExt) => {
|
||||
if (!extensions.find((b) => b.alias === existingExt.alias)) {
|
||||
this.unloadExtension(existingExt);
|
||||
@@ -32,7 +44,10 @@ export abstract class UmbExtensionInitializerBase<
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
// `allSettled` so a throwing/rejecting `instantiateExtension` cannot leave `loaded`
|
||||
// stuck at `undefined` and hang a waiter (e.g. the app boot gate). Failures are
|
||||
// surfaced rather than swallowed.
|
||||
const results = await Promise.allSettled(
|
||||
extensions.map((extension) => {
|
||||
if (this.#extensionMap.has(extension.alias)) return;
|
||||
this.#extensionMap.set(extension.alias, extension);
|
||||
@@ -40,7 +55,16 @@ export abstract class UmbExtensionInitializerBase<
|
||||
}),
|
||||
);
|
||||
|
||||
if (extensions.length > 0) {
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
console.error('[UmbExtensionInitializer] Failed to instantiate extension', result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Only the latest pass settles `loaded`. Resolving unconditionally — including for
|
||||
// zero extensions — so a consumer awaiting `loaded` (the app-entry-point boot gate,
|
||||
// the bundle guard) never hangs on a default install that registers none of this type.
|
||||
if (pass === this.#loadPass) {
|
||||
this.#loaded.setValue(true);
|
||||
}
|
||||
});
|
||||
|
||||
+1
@@ -317,6 +317,7 @@ export class UmbContentTypeDesignEditorTabElement extends UmbLitElement {
|
||||
uui-box.opaque {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
--uui-box-default-padding: 0;
|
||||
}
|
||||
|
||||
.container-list {
|
||||
|
||||
+1
-2
@@ -98,8 +98,7 @@ export class UmbCollectionPaginationElement extends UmbLitElement {
|
||||
}
|
||||
|
||||
uui-pagination {
|
||||
display: block;
|
||||
margin-top: var(--uui-size-layout-1);
|
||||
margin-top: var(--uui-size-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
+18
-1
@@ -107,7 +107,6 @@ export class UmbInputMultipleTextStringItemElement extends UUIFormControlWithBas
|
||||
<uui-button
|
||||
compact
|
||||
label="${this.localize.term('general_remove')} ${this.value}"
|
||||
look="outline"
|
||||
?disabled=${this.disabled}
|
||||
@click=${this.#onDelete}>
|
||||
<uui-icon name="icon-trash"></uui-icon>
|
||||
@@ -128,6 +127,7 @@ export class UmbInputMultipleTextStringItemElement extends UUIFormControlWithBas
|
||||
|
||||
#validation-message {
|
||||
flex: 1;
|
||||
margin-bottom: calc(var(--uui-size-1) * -1);
|
||||
}
|
||||
|
||||
#input {
|
||||
@@ -136,11 +136,28 @@ export class UmbInputMultipleTextStringItemElement extends UUIFormControlWithBas
|
||||
|
||||
.handle {
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
transition: opacity 120ms;
|
||||
}
|
||||
|
||||
.handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
uui-button {
|
||||
opacity: 0;
|
||||
transition: opacity 120ms;
|
||||
}
|
||||
|
||||
:host(:hover),
|
||||
:host(:focus-within) {
|
||||
uui-button {
|
||||
opacity: 1;
|
||||
}
|
||||
.handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
+1
-2
@@ -172,8 +172,7 @@ export class UmbPickerSearchResultElement extends UmbLitElement {
|
||||
}
|
||||
|
||||
uui-pagination {
|
||||
display: block;
|
||||
margin-top: var(--uui-size-layout-1);
|
||||
margin-top: var(--uui-size-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
-4
@@ -307,10 +307,6 @@ export class UmbDashboardRedirectManagementElement extends UmbLitElement {
|
||||
}
|
||||
}
|
||||
|
||||
uui-pagination {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
-3
@@ -199,9 +199,6 @@ export class UmbDocumentHistoryWorkspaceInfoAppElement extends UmbLitElement {
|
||||
}
|
||||
|
||||
uui-pagination {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: var(--uui-size-layout-1);
|
||||
}
|
||||
`,
|
||||
|
||||
-1
@@ -148,7 +148,6 @@ export class UmbLogViewerMessagesListElement extends UmbLitElement {
|
||||
static override styles = [
|
||||
css`
|
||||
uui-pagination {
|
||||
display: block;
|
||||
margin-bottom: var(--uui-size-layout-1);
|
||||
}
|
||||
uui-box {
|
||||
|
||||
-3
@@ -183,9 +183,6 @@ export class UmbMediaHistoryWorkspaceInfoAppElement extends UmbLitElement {
|
||||
}
|
||||
|
||||
uui-pagination {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: var(--uui-size-layout-1);
|
||||
}
|
||||
`,
|
||||
|
||||
-1
@@ -738,7 +738,6 @@ export class UmbMediaPickerModalElement extends UmbPickerModalBaseElement<
|
||||
}
|
||||
|
||||
uui-pagination {
|
||||
display: block;
|
||||
margin-top: var(--uui-size-layout-1);
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -119,7 +119,6 @@ export class UmbMemberGroupPickerModalElement extends UmbModalBaseElement<
|
||||
static override styles = [
|
||||
css`
|
||||
uui-pagination {
|
||||
display: block;
|
||||
margin-top: var(--uui-size-layout-1);
|
||||
}
|
||||
`,
|
||||
|
||||
-3
@@ -147,9 +147,6 @@ export class UmbPackagesCreatedOverviewElement extends UmbLitElement {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
uui-pagination {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
|
||||
-1
@@ -224,7 +224,6 @@ export class UmbRelationTypeDetailWorkspaceViewElement extends UmbLitElement imp
|
||||
|
||||
uui-pagination {
|
||||
margin-top: var(--uui-size-layout-1);
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
-5
@@ -167,11 +167,6 @@ export class UmbEntityReferencesWorkspaceInfoAppElement extends UmbLitElement {
|
||||
justify-content: center;
|
||||
margin-top: var(--uui-size-space-4);
|
||||
}
|
||||
|
||||
uui-pagination {
|
||||
flex: 1;
|
||||
display: inline-block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
-2
@@ -116,8 +116,6 @@ export class UmbCurrentUserHistoryUserProfileAppElement extends UmbLitElement {
|
||||
}
|
||||
|
||||
uui-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: var(--uui-size-layout-1);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"cases": [
|
||||
{
|
||||
"condition": "(StarterKit == 'Umbraco.TheStarterKit' && (UmbracoRelease == 'Latest' || UmbracoRelease == 'Custom'))",
|
||||
"value": "17.0.0"
|
||||
"value": "18.0.0"
|
||||
},
|
||||
{
|
||||
"condition": "(StarterKit == 'Umbraco.TheStarterKit' && UmbracoRelease == 'LTS')",
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@umbraco-cms/acceptance-test-helpers",
|
||||
"version": "18.0.0-rc1",
|
||||
"version": "18.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@umbraco-cms/acceptance-test-helpers",
|
||||
"version": "18.0.0-rc1",
|
||||
"version": "18.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@umbraco-cms/acceptance-test-helpers",
|
||||
"version": "18.0.0-rc1",
|
||||
"version": "18.0.0",
|
||||
"description": "Test helpers and builders for making Playwright tests for Umbraco solutions",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -95,6 +95,15 @@ export default defineConfig({
|
||||
ignoreHTTPSErrors: true,
|
||||
}
|
||||
},
|
||||
// Unauthenticated: this exercises the login screen itself (a late-registered auth provider).
|
||||
{
|
||||
name: 'authProviderLateRegistration',
|
||||
testMatch: 'AuthProviderLateRegistration/**/*.spec.ts',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
ignoreHTTPSErrors: true,
|
||||
}
|
||||
},
|
||||
// This project is used to test the install steps, for that we do not need to authenticate.
|
||||
{
|
||||
name: 'unattendedInstallConfig',
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Test fixture: an appEntryPoint that registers an external auth provider LATE — i.e. after
|
||||
// an async delay inside onInit — mirroring a real provider (e.g. Umbraco ID) whose onInit
|
||||
// registers its authProvider after fetching/initialising on a slow connection.
|
||||
//
|
||||
// The backoffice boot must wait for app-entry-points to settle before deciding which login
|
||||
// provider to use. If it doesn't, the login screen renders before this provider is registered
|
||||
// and the late provider never appears (the v17.4+ regression). The delay makes that race
|
||||
// deterministic.
|
||||
|
||||
const LATE_REGISTRATION_DELAY_MS = 1500;
|
||||
|
||||
export const onInit = async (_host, extensionRegistry) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, LATE_REGISTRATION_DELAY_MS));
|
||||
|
||||
extensionRegistry.register({
|
||||
type: 'authProvider',
|
||||
alias: 'Test.LateAuthProvider',
|
||||
name: 'Late External Login',
|
||||
forProviderName: 'Umbraco.LateTest',
|
||||
meta: {
|
||||
label: 'Late External Login',
|
||||
defaultView: {
|
||||
icon: 'icon-cloud',
|
||||
},
|
||||
behavior: {
|
||||
autoRedirect: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Late Auth Provider (test)",
|
||||
"allowPublicAccess": true,
|
||||
"extensions": [
|
||||
{
|
||||
"type": "appEntryPoint",
|
||||
"alias": "Test.LateAuthProvider.EntryPoint",
|
||||
"name": "Late Auth Provider Entry Point",
|
||||
"js": "/App_Plugins/LateAuthProvider/entry-point.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import {test} from '@umbraco/acceptance-test-helpers';
|
||||
import {expect} from '@playwright/test';
|
||||
|
||||
// Regression guard for the v17.4+ external-login race (introduced in #22522).
|
||||
//
|
||||
// AdditionalSetup/App_Plugins/LateAuthProvider deploys an appEntryPoint whose onInit, after a
|
||||
// 1.5s delay, registers an external auth provider ("Late External Login"). This mirrors a real
|
||||
// provider (e.g. Umbraco ID) that registers its authProvider during an async onInit on a slow
|
||||
// connection.
|
||||
//
|
||||
// The backoffice boot must wait for app-entry-points to settle before rendering the login
|
||||
// screen. If it does not, the login decision is made before the provider is registered, the
|
||||
// late provider never appears, and the user is dropped on the local login instead. So: the
|
||||
// late-registered provider MUST be offered on the login screen.
|
||||
//
|
||||
// This test is intentionally brittle (it depends on boot timing) but must remain working — it
|
||||
// is the only end-to-end guard for the boot-gate behaviour.
|
||||
test('a late-registered external auth provider is offered on the login screen', async ({umbracoUi}) => {
|
||||
test.slow();
|
||||
|
||||
// Act - navigate to the backoffice unauthenticated (the login screen).
|
||||
await umbracoUi.goToBackOffice();
|
||||
|
||||
// Assert - the provider registered late by the appEntryPoint is still offered. On the
|
||||
// buggy boot the login screen renders before this provider exists, so it never appears.
|
||||
const lateProviderButton = umbracoUi.page
|
||||
.locator('umb-auth-provider-default')
|
||||
.getByText('Sign in with Late External Login');
|
||||
await expect(lateProviderButton).toBeVisible({timeout: 15000});
|
||||
});
|
||||
@@ -9,8 +9,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoFixture.AutoMoq" />
|
||||
<PackageReference Include="AutoFixture.NUnit3" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="NUnit" />
|
||||
</ItemGroup>
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ public class ChildrenElementTreeControllerTests : ManagementApiUserGroupTestBase
|
||||
.WithAlias(Guid.NewGuid().ToString("N"))
|
||||
.WithName("Test Group With Element Start Node")
|
||||
.WithAllowedSections(["library"])
|
||||
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter })
|
||||
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter, ActionElementContainerBrowse.ActionLetter })
|
||||
.WithStartElementId(startNodeFolder.Id)
|
||||
.Build();
|
||||
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ public class RootElementTreeControllerTests : ManagementApiUserGroupTestBase<Roo
|
||||
.WithAlias(Guid.NewGuid().ToString("N"))
|
||||
.WithName("Test Group With Element Start Node")
|
||||
.WithAllowedSections(["library"])
|
||||
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter })
|
||||
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter, ActionElementContainerBrowse.ActionLetter })
|
||||
.WithStartElementId(folder1.Id)
|
||||
.Build();
|
||||
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ public class SiblingsElementTreeControllerTests : ManagementApiUserGroupTestBase
|
||||
.WithAlias(Guid.NewGuid().ToString("N"))
|
||||
.WithName("Test Group With Element Start Node")
|
||||
.WithAllowedSections(["library"])
|
||||
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter })
|
||||
.WithPermissions(new HashSet<string> { ActionElementBrowse.ActionLetter, ActionElementContainerBrowse.ActionLetter })
|
||||
.WithStartElementId(_folder1Id)
|
||||
.Build();
|
||||
|
||||
|
||||
+5
-4
@@ -1,8 +1,6 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Umbraco.Cms.Api.Management.Controllers.TemporaryFile;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.TemporaryFile;
|
||||
|
||||
namespace Umbraco.Cms.Tests.Integration.ManagementApi.TemporaryFile;
|
||||
|
||||
@@ -42,8 +40,11 @@ public class CreateTemporaryFileControllerTests : ManagementApiUserGroupTestBase
|
||||
|
||||
protected override async Task<HttpResponseMessage> ClientRequest()
|
||||
{
|
||||
CreateTemporaryFileRequestModel createTemporaryFileRequest = new() { Id = Guid.NewGuid(), File = null! };
|
||||
// The endpoint only consumes multipart/form-data, so the request must be sent as such to reach the action.
|
||||
// The required file is intentionally omitted, yielding the expected BadRequest for authenticated users.
|
||||
using var content = new MultipartFormDataContent();
|
||||
content.Add(new StringContent(Guid.NewGuid().ToString()), "Id");
|
||||
|
||||
return await Client.PostAsync(Url, JsonContent.Create(createTemporaryFileRequest));
|
||||
return await Client.PostAsync(Url, content);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1278,7 +1278,8 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"plainJson": { }
|
||||
}
|
||||
},
|
||||
"ArticlePageContentResponseModel": {
|
||||
|
||||
+26
@@ -170,6 +170,9 @@ internal abstract class OpenApiContractTestBase : OpenApiTestBase
|
||||
// discriminator mapping refs that match the registered schema names.
|
||||
var polymorphicDataType = await CreatePolymorphicTestDataTypeAsync();
|
||||
|
||||
// Create a Plain JSON data type to exercise the unconstrained-type schema path (#23034).
|
||||
var plainJsonDataType = await CreatePlainJsonDataTypeAsync();
|
||||
|
||||
// Create a composition type that exposes shared SEO metadata properties
|
||||
var seoMetadataComposition = new ContentTypeBuilder()
|
||||
.WithAlias("seoMetadata")
|
||||
@@ -223,6 +226,11 @@ internal abstract class OpenApiContractTestBase : OpenApiTestBase
|
||||
.WithName("Polymorphic Test")
|
||||
.WithDataTypeId(polymorphicDataType.Id)
|
||||
.Done()
|
||||
.AddPropertyType()
|
||||
.WithAlias("plainJson")
|
||||
.WithName("Plain JSON")
|
||||
.WithDataTypeId(plainJsonDataType.Id)
|
||||
.Done()
|
||||
.Done()
|
||||
.Build();
|
||||
articlePage.AddContentType(seoMetadataComposition);
|
||||
@@ -343,4 +351,22 @@ internal abstract class OpenApiContractTestBase : OpenApiTestBase
|
||||
await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey);
|
||||
return dataType;
|
||||
}
|
||||
|
||||
private async Task<IDataType> CreatePlainJsonDataTypeAsync()
|
||||
{
|
||||
var editor = PropertyEditorCollection[Constants.PropertyEditors.Aliases.PlainJson]
|
||||
?? throw new InvalidOperationException(
|
||||
$"Property editor '{Constants.PropertyEditors.Aliases.PlainJson}' was not registered.");
|
||||
|
||||
var dataType = new DataType(editor, ConfigurationEditorJsonSerializer)
|
||||
{
|
||||
Name = "Plain JSON",
|
||||
DatabaseType = ValueStorageType.Ntext,
|
||||
ParentId = Constants.System.Root,
|
||||
CreateDate = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await DataTypeService.CreateAsync(dataType, Constants.Security.SuperUserKey);
|
||||
return dataType;
|
||||
}
|
||||
}
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Tests.Common.Builders;
|
||||
using Umbraco.Cms.Tests.Common.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
|
||||
|
||||
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/>.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, WithApplication = true)]
|
||||
internal sealed class ElementContainerDeletedDistributedCacheNotificationHandlerTests : UmbracoIntegrationTest
|
||||
{
|
||||
private IElementContainerService ElementContainerService => GetRequiredService<IElementContainerService>();
|
||||
|
||||
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
|
||||
|
||||
private IElementService ElementService => GetRequiredService<IElementService>();
|
||||
|
||||
private IEntityService EntityService => GetRequiredService<IEntityService>();
|
||||
|
||||
private static readonly UmbracoObjectTypes[] _treeObjectTypes =
|
||||
[UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element];
|
||||
|
||||
protected override void CustomTestSetup(IUmbracoBuilder builder)
|
||||
{
|
||||
// Integration tests use a no-op server messenger and do not register the distributed cache
|
||||
// notification handlers by default, so opt in to the element handlers under test and a messenger
|
||||
// that delivers cache refreshes locally.
|
||||
builder.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>();
|
||||
builder.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>();
|
||||
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for https://github.com/umbraco/Umbraco-CMS/issues/23072: the element tree's children
|
||||
/// query resolves the container key to an id via <see cref="IIdKeyMap"/>. When a container is deleted the
|
||||
/// handler must evict its mapping, otherwise a container recreated under the same key resolves to the old
|
||||
/// (now non-existent) id and nested elements stay invisible in the tree until the application is restarted.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task Can_Resolve_Children_After_Container_Recreated_Under_Same_Key()
|
||||
{
|
||||
IContentType elementType = await CreateElementTypeAsync();
|
||||
var containerKey = Guid.NewGuid();
|
||||
|
||||
// Create the container and resolve its children once, so its key->id mapping is cached in IdKeyMap.
|
||||
EntityContainer firstContainer = await CreateContainerAsync(containerKey, "Container v1");
|
||||
Attempt<int> warmResolve = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer);
|
||||
Assert.IsTrue(warmResolve.Success, "Expected IdKeyMap to resolve the newly created container key.");
|
||||
Assert.AreEqual(firstContainer.Id, warmResolve.Result);
|
||||
|
||||
// Delete and recreate under the same key - the recreated container gets a new id.
|
||||
Attempt<EntityContainer?, EntityContainerOperationStatus> deleteResult =
|
||||
await ElementContainerService.DeleteAsync(containerKey, Constants.Security.SuperUserKey);
|
||||
Assert.IsTrue(deleteResult.Success, $"Failed to delete container: {deleteResult.Status}");
|
||||
|
||||
EntityContainer secondContainer = await CreateContainerAsync(containerKey, "Container v2");
|
||||
Assert.AreNotEqual(firstContainer.Id, secondContainer.Id, "Recreated container should have a new id.");
|
||||
|
||||
IElement element = CreateElementUnder(secondContainer.Id, elementType);
|
||||
|
||||
// Without the fix, the stale containerKey->firstContainer.Id mapping survives and the children query
|
||||
// resolves to the old (now non-existent) parent id, returning nothing.
|
||||
Attempt<int> resolvedAfter = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer);
|
||||
Assert.IsTrue(resolvedAfter.Success, "Expected IdKeyMap to resolve the recreated container key.");
|
||||
Assert.AreEqual(secondContainer.Id, resolvedAfter.Result, "Container key should resolve to the recreated container id.");
|
||||
|
||||
AssertChildrenContains(containerKey, element.Key);
|
||||
}
|
||||
|
||||
private void AssertChildrenContains(Guid containerKey, Guid expectedElementKey)
|
||||
{
|
||||
IEntitySlim[] children = EntityService
|
||||
.GetPagedChildren(containerKey, _treeObjectTypes, _treeObjectTypes, 0, 100, false, out var total)
|
||||
.ToArray();
|
||||
|
||||
Assert.AreEqual(1, total, "Expected the element tree children query to return the nested element.");
|
||||
Assert.IsTrue(children.Any(child => child.Key == expectedElementKey), "Nested element was not returned by the children query.");
|
||||
}
|
||||
|
||||
private async Task<IContentType> CreateElementTypeAsync()
|
||||
{
|
||||
IContentType elementType = ContentTypeBuilder.CreateSimpleElementType();
|
||||
await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey);
|
||||
return elementType;
|
||||
}
|
||||
|
||||
private async Task<EntityContainer> CreateContainerAsync(Guid key, string name)
|
||||
{
|
||||
Attempt<EntityContainer?, EntityContainerOperationStatus> result =
|
||||
await ElementContainerService.CreateAsync(key, name, null, Constants.Security.SuperUserKey);
|
||||
Assert.IsTrue(result.Success, $"Failed to create container: {result.Status}");
|
||||
return result.Result!;
|
||||
}
|
||||
|
||||
private IElement CreateElementUnder(int parentId, IContentType elementType)
|
||||
{
|
||||
var element = new Element($"Element {Guid.NewGuid():N}", parentId, elementType);
|
||||
OperationResult saveResult = ElementService.Save(element);
|
||||
Assert.IsTrue(saveResult.Success, "Failed to save element.");
|
||||
return element;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user