Compare commits

..
Author SHA1 Message Date
Laura Neto 09acba310f Merge branch 'v18/feature/delivery-api-document-type-schema-generation-clean' into v18/task/delivery-api-openapi-sample-content 2026-05-06 11:16:44 +02:00
Laura Neto 93a13648b7 Qualify properties model schema IDs by item type
Document, element, and media types share the same alias namespace
across content/media (a doc-type and a media-type can use the same
alias), so a "{Schema}PropertiesModel" naming scheme could collide.

Properties model schemas now follow the same Content/Element/Media
suffix as their parent *Model schema:

- Document type: ArticlePageContentPropertiesModel
- Element type:  TestElementElementPropertiesModel
- Media type:    VideoMediaPropertiesModel

Composition references look up each composition's own IsElement so
that a doc-type composing an element-type (allowed in the UI) still
references the correct ElementPropertiesModel schema.
2026-05-06 11:15:46 +02:00
Laura Neto 417eae32a7 Preserve casing of content type aliases in OpenAPI schema IDs
Replaces the legacy ModelsBuilder-style ToCleanString tokenizer with
ToFirstUpperInvariant. The tokenizer split aliases on case boundaries
and mangled capital-letter runs (e.g. "xMLSitemap" -> "XMlsitemap"),
making the typed schema names harder to read for OpenAPI consumers.
Since content type aliases are already valid identifiers, only the
first character needs uppercasing.

Also adds an "xMLSitemap" sample type to the integration tests to
cover the casing-preservation behavior.
2026-05-06 10:48:10 +02:00
Laura Neto c45813bebd Drop additionalProperties: false from typed schemas
JSON Schema 2020-12 (mandated by OpenAPI 3.1) does not let additionalProperties look through allOf, so a strict validator rejects every inherited field on the composed *ResponseModel/*Model/*PropertiesModel schemas. Most code generators silently ignore it, but the document is technically invalid and the constraint would be a lie anyway since Umbraco can grow new properties in non-major releases.

Removed from all four schema construction sites (response, content type, properties, and the interface-based fallback) and regenerated the affected snapshots.
2026-05-06 10:22:22 +02:00
Laura Neto 074d2ea9f0 Updated expected contracts following code adjustments 2026-05-06 09:49:04 +02:00
Andy Butland c45a79bcd7 Allows nulls at property reference sites without mutating any shared component schema.
Avoid unnecessary re-get of the JsonTypeInfo for the default case.
2026-05-06 08:04:35 +02:00
Laura Neto c6140ed807 Merge remote-tracking branch 'origin/v18/dev' into v18/feature/delivery-api-document-type-schema-generation-clean 2026-05-05 16:42:32 +02:00
Laura Neto 1eb0ec9755 Merge branch 'v18/dev' into v18/feature/delivery-api-document-type-schema-generation-clean
# Conflicts:
#	tests/Umbraco.Tests.Integration/Umbraco.Api.Delivery/OpenApi/ExpectedContracts/default.json
2026-05-05 16:41:33 +02:00
Laura Neto 61ce9e70e9 Merge remote-tracking branch 'origin/v18/dev' into v18/feature/delivery-api-document-type-schema-generation-clean 2026-05-04 23:10:54 +02:00
Laura Neto ecce15c39b Drop default JsonDerivedType registrations from Delivery API interfaces
Removes the [JsonDerivedType] attributes from IApiContent,
IApiContentResponse, and IApiElement. Without them System.Text.Json
configures no polymorphism by default, so wire payloads stop carrying
$type fields and the OpenAPI spec stops emitting a discriminator on
the generic schemas - matching v17 Delivery API behaviour. Consumers
that need polymorphic serialization can still register derived types
via ContentJsonTypeResolverBase.

Snapshots regenerated.
2026-05-04 23:10:02 +02:00
Laura Neto 085fcda2ba Stop registering media derived types in the JSON resolver
ContentJsonTypeResolverBase.GetDerivedTypes goes back to returning
empty. Previously it registered ApiMediaWithCrops and
ApiMediaWithCropsResponse as derived types of their interfaces, which
made every consumer of the resolver (the Delivery API and webhooks)
emit a $type discriminator on media payloads, even when the typed
schema feature was disabled.

The Delivery API still needs a base schema for the typed media
schemas to extend via allOf. Since the concrete media classes are
internal to Umbraco.Infrastructure and cannot be referenced from
[JsonDerivedType] in Core, ContentTypeSchemaTransformer now builds
that base from the interface's own properties when the interface has
no [JsonDerivedType] entries. Content/element interfaces are
unaffected and keep using their declared concrete derived types.

Snapshots regenerated.
2026-05-04 20:54:40 +02:00
Laura Neto e0abfcb4b0 Honour Delivery API allow/deny list in typed OpenAPI schemas
ContentTypeSchemaTransformer now filters DocumentTypes through
DeliveryApiSettings.IsAllowedContentType so document types blocked
by AllowedContentTypeAliases / DisallowedContentTypeAliases no longer
leak into the polymorphic union or discriminator mapping.
2026-05-04 18:42:34 +02:00
Laura NetoandGitHub 5bd22410a6 Merge branch 'v18/dev' into v18/feature/delivery-api-document-type-schema-generation 2026-05-04 15:20:30 +02:00
Laura Neto 16f6e3a4b2 Add Delivery API client test projects 2026-05-04 15:06:00 +02:00
Laura NetoandGitHub 308369452d Merge branch 'v18/dev' into v18/feature/delivery-api-document-type-schema-generation 2026-05-04 11:42:38 +02:00
Laura Neto f97db75db0 Delivery API: Generate typed OpenAPI schemas per content type 2026-05-01 10:48:27 +02:00
1504 changed files with 37241 additions and 40033 deletions
-1
View File
@@ -59,5 +59,4 @@
# Generated files - hidden by default in GitHub diffs
src/Umbraco.Web.UI.Client/src/packages/core/backend-api/** linguist-generated
src/Umbraco.Web.UI.Login/src/api/** linguist-generated
templates/UmbracoExtension/Client/src/api/** linguist-generated
src/Umbraco.Cms.Api.Management/OpenApi.json linguist-generated
+1 -1
View File
@@ -7,7 +7,7 @@ body:
id: "version"
attributes:
label: "Which Umbraco version are you using?"
description: "Please write the *exact* version, example: `10.1.0`. Click the Umbraco logo in the top left corner of the backoffice to find the version you're using."
description: "Please write the *exact* version, example: `10.1.0`. Use the help icon in the Umbraco backoffice to find the version you're using"
validations:
required: true
- type: textarea
+2 -2
View File
@@ -80,7 +80,8 @@ tools/docfx/
/src/Umbraco.Web.UI/wwwroot/[Mm]edia/
/src/Umbraco.Web.UI/App_Code/
/src/Umbraco.Web.UI/App_Plugins/
/src/Umbraco.Web.UI/[Uu]mbraco/[Dd]ata/
/src/Umbraco.Web.UI/[Uu]mbraco/[Dd]ata/*
!/src/Umbraco.Web.UI/[Uu]mbraco/[Dd]ata/Umbraco.Sample.sqlite.db
/src/Umbraco.Web.UI/[Uu]mbraco/[Ll]ogs/
/src/Umbraco.Web.UI/[Uu]mbraco/[Mm]odels/
/src/Umbraco.Web.UI/Views/
@@ -120,4 +121,3 @@ trace.zip
/tests/Umbraco.Tests.Integration/appsettings-schema.*.json
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
/src/Umbraco.Cms/appsettings-schema.json
.playwright-mcp/
-16
View File
@@ -448,14 +448,6 @@ When a PR changes Management API controllers or models, the `OpenApi.json` file
The backoffice is published to npm as `@umbraco-cms/backoffice`. Runtime dependencies are provided via importmap; npm peerDependencies provide types only. For full details on dependency hoisting, version range logic, and plugin development, see `/src/Umbraco.Web.UI.Client/CLAUDE.md` → "npm Package Publishing".
### SQL Server 2100-parameter limit
Any `WHERE IN (@0, @1, ...)` built from a runtime-sized collection risks hitting SQL Server's 2100-parameter ceiling and throwing `SqlException` 8003 in production.
Batch with `IEnumerable<T>.InGroupsOf(Constants.Sql.MaxParameterCount)` or `Database.FetchByGroups(...)` whenever the collection size is driven by user data — not just when it currently fits. Watch for products of two scaling dimensions (documents × languages, properties × versions) and config-tunable batch sizes whose defaults are safe but ceilings aren't.
Full guidance, safe patterns and decision rule: see `/src/Umbraco.Infrastructure/CLAUDE.md` → "Avoiding the SQL Server 2100-parameter limit".
### Known Limitations
1. **Circular Dependencies**: Avoided via `Lazy<T>` or event notifications
@@ -552,14 +544,6 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
---
## 9. Testing Practices
### Tests for a bug fix must fail before the fix
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
---
## Quick Reference
### Essential Commands
+11 -11
View File
@@ -45,7 +45,7 @@ parameters:
- name: integrationNonReleaseTestFilter
displayName: TestFilter used for non-release type builds
type: string
default: "TestCategory!=LongRunning&TestCategory!=NonCritical"
default: "--filter TestCategory!=LongRunning&TestCategory!=NonCritical"
- name: integrationReleaseTestFilter
displayName: TestFilter used for release type builds
type: string
@@ -53,7 +53,7 @@ parameters:
- name: nonWindowsIntegrationNonReleaseTestFilter
displayName: TestFilter used for non-release type builds on non Windows agents
type: string
default: "TestCategory!=LongRunning&TestCategory!=NonCritical"
default: "--filter TestCategory!=LongRunning&TestCategory!=NonCritical"
- name: nonWindowsIntegrationReleaseTestFilter
displayName: TestFilter used for release type builds on non Windows agents
type: string
@@ -455,13 +455,13 @@ stages:
projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj"
testRunTitle: Integration Tests SQLite - $(Agent.OS)
${{ if and(eq(variables['Agent.OS'],'Windows_NT'), or(variables.releaseTestFilter, parameters.forceReleaseTestFilter)) }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationReleaseTestFilter}}'
${{ elseif eq(variables['Agent.OS'],'Windows_NT') }}:
arguments: '--filter "$(testFilter) & ${{parameters.integrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationNonReleaseTestFilter}}'
${{ elseif or(variables.releaseTestFilter, parameters.forceReleaseTestFilter) }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationReleaseTestFilter}}'
${{ else }}:
arguments: '--filter "$(testFilter) & ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}'
# Integration Tests (SQL Server)
- job:
timeoutInMinutes: 180
@@ -569,13 +569,13 @@ stages:
projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj"
testRunTitle: Integration Tests SQL Server - $(Agent.OS)
${{ if and(eq(variables['Agent.OS'],'Windows_NT'), or(variables.releaseTestFilter, parameters.forceReleaseTestFilter)) }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationReleaseTestFilter}}'
${{ elseif eq(variables['Agent.OS'],'Windows_NT') }}:
arguments: '--filter "$(testFilter) & ${{parameters.integrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationNonReleaseTestFilter}}'
${{ elseif or(variables.releaseTestFilter, parameters.forceReleaseTestFilter) }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationReleaseTestFilter}}'
${{ else }}:
arguments: '--filter "$(testFilter) & ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}'
# Stop SQL Server
- pwsh: docker stop mssql
@@ -905,10 +905,10 @@ stages:
- job: WaitForApproval
displayName: Wait for manual approval
pool: server
timeoutInMinutes: 4320 # 3 days
steps:
- task: ManualValidation@0
displayName: Manual approval to push to NuGet
timeoutInMinutes: 4320 # 3 days
inputs:
notifyUsers: ''
instructions: 'Approve to push the NuGet release.'
+3 -3
View File
@@ -4,11 +4,11 @@ pr: none
trigger: none
schedules:
- cron: '0 0 * * *'
displayName: Daily 0AM build (main)
- cron: '0 6 * * *'
displayName: Daily 6AM build (v18/dev)
branches:
include:
- main
- v18/dev
parameters:
- name: skipIntegrationTests
@@ -1,3 +1,4 @@
using System.Text.Json.Serialization.Metadata;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
@@ -68,7 +69,7 @@ internal abstract class ConfigureUmbracoOpenApiOptionsBase : IConfigureNamedOpti
});
options.ShouldInclude = ShouldInclude;
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
options.CreateSchemaReferenceId = CreateSchemaReferenceId;
options.AddOperationTransformer<UmbracoOperationIdTransformer>();
@@ -79,6 +80,33 @@ internal abstract class ConfigureUmbracoOpenApiOptionsBase : IConfigureNamedOpti
.AddDocumentTransformer<SortTagsAndPathsTransformer>();
}
/// <summary>
/// Creates a schema reference ID for the given JSON type info.
/// Returns null for types that should be inlined, the default schema ID for non-Umbraco types,
/// or a generated schema ID for Umbraco types.
/// </summary>
/// <param name="jsonTypeInfo">The JSON type info to create a schema reference ID for.</param>
/// <returns>The schema reference ID, or null if the type should be inlined.</returns>
internal static string? CreateSchemaReferenceId(JsonTypeInfo jsonTypeInfo)
{
// Ensure that only types that would normally be included in the schema generation are given a schema reference ID.
// Otherwise, we should return null to inline them.
var defaultSchemaReferenceId = OpenApiOptions.CreateDefaultSchemaReferenceId(jsonTypeInfo);
if (defaultSchemaReferenceId is null)
{
return null;
}
Type targetType = Nullable.GetUnderlyingType(jsonTypeInfo.Type) ?? jsonTypeInfo.Type;
if (targetType.Namespace?.StartsWith("Umbraco.Cms") is not true)
{
return defaultSchemaReferenceId;
}
return UmbracoSchemaIdGenerator.Generate(targetType);
}
/// <summary>
/// Determines whether the specified API description should be included in this OpenAPI document.
/// </summary>
@@ -30,27 +30,6 @@ internal static class OpenApiSchemaServiceExtensions
this IServiceCollection services,
string documentName,
string jsonOptionsName)
=> services.ReplaceOpenApiSchemaService(
documentName,
sp => sp.GetRequiredService<IOptionsMonitor<JsonOptions>>().Get(jsonOptionsName));
/// <summary>
/// Replaces the internal Microsoft <c>OpenApiSchemaService</c> registration for the specified document so that schema
/// generation uses the <see cref="JsonOptions"/> instance produced by the supplied factory. Use this overload when
/// the options need to be resolved from the service provider, computed at the last moment, or built in a way that
/// doesn't fit the named-options lookup.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="documentName">The OpenAPI document key.</param>
/// <param name="jsonOptionsFactory">Factory invoked when the schema service is first resolved. Receives the resolving <see cref="IServiceProvider"/> and returns the <see cref="JsonOptions"/> to use.</param>
/// <returns>The same <see cref="IServiceCollection"/> for chaining.</returns>
/// <remarks>
/// Workaround for <see href="https://github.com/dotnet/aspnetcore/issues/66340">dotnet/aspnetcore#66340</see>.
/// </remarks>
public static IServiceCollection ReplaceOpenApiSchemaService(
this IServiceCollection services,
string documentName,
Func<IServiceProvider, JsonOptions> jsonOptionsFactory)
{
ServiceDescriptor descriptor = services.FirstOrDefault(sd =>
sd.ServiceType.FullName == OpenApiSchemaServiceFullName
@@ -68,7 +47,7 @@ internal static class OpenApiSchemaServiceExtensions
sp,
descriptor.ServiceType,
key,
Options.Create(jsonOptionsFactory(sp))));
Options.Create(sp.GetRequiredService<IOptionsMonitor<JsonOptions>>().Get(jsonOptionsName))));
return services;
}
@@ -23,26 +23,12 @@ public static class OpenApiServiceCollectionExtensions
this IServiceCollection services,
string documentName,
string? documentTitle = null)
=> services.AddOpenApiDocumentToUi(documentName, () => documentTitle);
/// <summary>
/// Adds an OpenAPI document to the OpenAPI UI document selector dropdown, resolving the title lazily so
/// callers (such as builder-pattern helpers) can defer it until SwaggerUI options are resolved.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> instance.</param>
/// <param name="documentName">The name/identifier of the OpenAPI document.</param>
/// <param name="documentTitleFactory">Factory invoked when SwaggerUI options are resolved. Returning <c>null</c> falls back to <paramref name="documentName"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> instance.</returns>
internal static IServiceCollection AddOpenApiDocumentToUi(
this IServiceCollection services,
string documentName,
Func<string?> documentTitleFactory)
{
services.AddOptions<SwaggerUIOptions>()
.Configure<IOptions<UmbracoOpenApiOptions>>((swaggerUiOptions, openApiOptions) =>
{
var openApiRoute = openApiOptions.Value.RouteTemplate.Replace("{documentName}", documentName).EnsureStartsWith("/");
swaggerUiOptions.SwaggerEndpoint(openApiRoute, documentTitleFactory() ?? documentName);
swaggerUiOptions.SwaggerEndpoint(openApiRoute, documentTitle ?? documentName);
swaggerUiOptions.ConfigObject.Urls = swaggerUiOptions.ConfigObject.Urls.OrderBy(x => x.Name);
});
@@ -1,169 +0,0 @@
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Fluent builder for configuring a custom OpenAPI document.
/// </summary>
public sealed class BackOfficeOpenApiDocumentBuilder
{
private readonly List<Action<OpenApiOptions>> _configurations = [];
private string? _title;
private string? _uiTitle;
private bool _includedInUi = true;
private Func<IServiceProvider, JsonOptions>? _httpJsonOptionsFactory;
/// <summary>
/// Initializes a new instance of the <see cref="BackOfficeOpenApiDocumentBuilder"/> class.
/// </summary>
/// <param name="documentName">The name of the OpenAPI document being configured.</param>
internal BackOfficeOpenApiDocumentBuilder(string documentName)
=> DocumentName = documentName;
/// <summary>
/// Gets the name of the OpenAPI document being configured.
/// </summary>
public string DocumentName { get; }
/// <summary>
/// Sets the document's <c>Info.Title</c>. Also used as the UI dropdown label unless overridden via
/// <see cref="WithUiTitle"/>.
/// </summary>
/// <param name="title">The title to display.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder WithTitle(string title)
{
_title = title;
return this;
}
/// <summary>
/// Overrides the UI dropdown label for this document.
/// </summary>
/// <param name="uiTitle">The label to display.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder WithUiTitle(string uiTitle)
{
_uiTitle = uiTitle;
return this;
}
/// <summary>
/// Excludes this document from the UI dropdown.
/// </summary>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder ExcludeFromUi()
{
_includedInUi = false;
return this;
}
/// <summary>
/// Adds an <see cref="OpenApiOptions"/> configuration callback. Multiple calls compose.
/// </summary>
/// <param name="configure">Callback to configure the options.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder ConfigureOpenApiOptions(Action<OpenApiOptions> configure)
{
_configurations.Add(configure);
return this;
}
/// <summary>
/// Sets the named <see cref="JsonOptions">Microsoft.AspNetCore.Http.Json.JsonOptions</see> used when
/// generating this document's schema. Use this to match the serialization conventions of the API
/// endpoints the document describes.
/// </summary>
/// <param name="jsonOptionsName">The name of the registered HTTP <see cref="JsonOptions"/> to apply.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder WithJsonOptions(string jsonOptionsName)
=> WithJsonOptions(sp => sp.GetRequiredService<IOptionsMonitor<JsonOptions>>().Get(jsonOptionsName));
/// <summary>
/// Sets the <see cref="JsonOptions">Microsoft.AspNetCore.Http.Json.JsonOptions</see> used when
/// generating this document's schema. Use this to match the serialization conventions of the API
/// endpoints the document describes.
/// </summary>
/// <param name="jsonOptions">The HTTP JSON options to apply.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder WithJsonOptions(JsonOptions jsonOptions)
=> WithJsonOptions(_ => jsonOptions);
/// <summary>
/// Sets a factory that produces the <see cref="JsonOptions">Microsoft.AspNetCore.Http.Json.JsonOptions</see>
/// used when generating this document's schema. Use this to match the serialization conventions of the
/// API endpoints the document describes.
/// </summary>
/// <param name="jsonOptionsFactory">Factory invoked when the schema service is first resolved.</param>
/// <returns>The same builder for chaining.</returns>
public BackOfficeOpenApiDocumentBuilder WithJsonOptions(Func<IServiceProvider, JsonOptions> jsonOptionsFactory)
{
_httpJsonOptionsFactory = jsonOptionsFactory;
return this;
}
/// <summary>
/// Applies the accumulated configuration to the supplied <see cref="IUmbracoBuilder"/>'s service
/// collection. Called by <c>AddBackOfficeOpenApiDocument</c> once the user-supplied callback returns.
/// </summary>
/// <param name="builder">The Umbraco builder to register services against.</param>
internal void Build(IUmbracoBuilder builder)
{
builder.Services.AddOpenApi(
DocumentName,
options =>
{
options.ShouldInclude = apiDescription =>
apiDescription.ActionDescriptor.HasMapToApiAttribute(DocumentName);
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
if (_title is not null)
{
options.AddDocumentTransformer((document, _, _) =>
{
document.Info.Title = _title;
return Task.CompletedTask;
});
}
// Generate operation IDs using Umbraco's naming conventions.
options.AddOperationTransformer<UmbracoOperationIdTransformer>();
// Trim redundant JSON-equivalent MIME types (e.g. text/json, application/*+json, text/plain)
// that ASP.NET Core adds alongside application/json.
options.AddOperationTransformer<MimeTypesTransformer>();
// Mark non-nullable properties as required so generated SDKs reflect the C# nullability.
options.AddSchemaTransformer<RequireNonNullablePropertiesSchemaTransformer>();
// Tag actions by group name and cleanup unused tags (caused by the tag changes).
options
.AddOperationTransformer<TagActionsByGroupNameTransformer>()
.AddDocumentTransformer<TagActionsByGroupNameTransformer>()
.AddDocumentTransformer<SortTagsAndPathsTransformer>();
foreach (Action<OpenApiOptions> configure in _configurations)
{
configure(options);
}
});
if (_includedInUi)
{
builder.Services.AddOpenApiDocumentToUi(DocumentName, _uiTitle ?? _title);
}
if (_httpJsonOptionsFactory is not null)
{
builder.Services.ReplaceOpenApiSchemaService(DocumentName, _httpJsonOptionsFactory);
}
}
}
@@ -1,5 +1,5 @@
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using Umbraco.Extensions;
@@ -7,31 +7,11 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Trims redundant JSON-equivalent media types from OpenAPI operations.
/// Removes unwanted MIME types from OpenAPI operations, keeping only the content types
/// declared by <c>[Consumes]</c> for request bodies or <c>application/json</c> as the default.
/// </summary>
/// <remarks>
/// <para>
/// ASP.NET Core's content negotiation populates operations with several media types that all serialize to JSON
/// (<c>text/json</c>, <c>application/*+json</c>, and <c>text/plain</c> alongside <c>application/json</c>).
/// When <c>application/json</c> is present on a response or request body, this transformer strips those
/// equivalents so OpenAPI consumers and generated SDKs aren't burdened with variants that produce identical
/// payloads. Non-JSON media types (e.g. <c>application/xml</c>, <c>application/octet-stream</c>) are preserved.
/// </para>
/// <para>
/// Request bodies additionally honour <c>[Consumes]</c>: when the attribute is present, the request content is
/// replaced entirely with the declared content types, taking precedence over the
/// JSON-equivalent stripping above.
/// </para>
/// </remarks>
internal class MimeTypesTransformer : IOpenApiOperationTransformer
{
private static readonly string[] _jsonEquivalentMimeTypes =
[
MediaTypeNames.Text.Plain,
"application/*+json",
"text/json"
];
/// <inheritdoc/>
public Task TransformAsync(
OpenApiOperation operation,
@@ -60,29 +40,29 @@ internal class MimeTypesTransformer : IOpenApiOperationTransformer
}
else
{
RemoveJsonEquivalentMimeTypes(requestContent);
RemoveNonJsonMimeTypes(requestContent);
}
}
// For responses, drop JSON-equivalent media types when application/json is present.
// For responses, always keep only application/json.
foreach (IOpenApiResponse response in (operation.Responses ?? []).Values)
{
if (response is OpenApiResponse openApiResponse)
{
RemoveJsonEquivalentMimeTypes(openApiResponse.Content);
RemoveNonJsonMimeTypes(openApiResponse.Content);
}
}
return Task.CompletedTask;
}
private static void RemoveJsonEquivalentMimeTypes(IDictionary<string, OpenApiMediaType>? content)
private static void RemoveNonJsonMimeTypes(IDictionary<string, OpenApiMediaType>? content)
{
if (content?.ContainsKey(MediaTypeNames.Application.Json) != true)
if (content?.ContainsKey("application/json") != true)
{
return;
}
content.RemoveAll(r => _jsonEquivalentMimeTypes.Contains(r.Key, StringComparer.OrdinalIgnoreCase));
content.RemoveAll(r => r.Key != "application/json");
}
}
@@ -1,81 +0,0 @@
using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Extension methods for <see cref="IUmbracoBuilder"/> to register custom OpenAPI documents.
/// </summary>
public static class UmbracoBuilderOpenApiExtensions
{
/// <summary>
/// Registers a custom OpenAPI document with Umbraco's defaults applied.
/// </summary>
/// <param name="builder">The Umbraco builder.</param>
/// <param name="documentName">The document name. Matches the <c>[MapToApi]</c> value on controllers to include.</param>
/// <param name="configure">Optional callback to customize the document.</param>
/// <returns>The same <see cref="IUmbracoBuilder"/> for chaining.</returns>
/// <remarks>
/// <para>
/// The following defaults are applied to the document and can be customized or overridden via the
/// <paramref name="configure"/> callback:
/// </para>
/// <list type="bullet">
/// <item>
/// <description>
/// Endpoints are filtered by <c>[MapToApi(documentName)]</c>; only matching endpoints appear in the document.
/// </description>
/// </item>
/// <item>
/// <description>
/// Schema reference IDs are generated by <see cref="UmbracoSchemaIdGenerator.CreateSchemaReferenceId"/>, applying
/// Umbraco naming conventions to types under the <c>Umbraco.Cms</c> namespace and falling back to the framework
/// default for everything else. Register your own <c>CreateSchemaReferenceId</c> delegate via
/// <see cref="BackOfficeOpenApiDocumentBuilder.ConfigureOpenApiOptions"/> to override.
/// </description>
/// </item>
/// <item>
/// <description>
/// Operation IDs are generated by <see cref="UmbracoOperationIdTransformer"/>. Register your own
/// <see cref="Microsoft.AspNetCore.OpenApi.IOpenApiOperationTransformer"/> via
/// <see cref="BackOfficeOpenApiDocumentBuilder.ConfigureOpenApiOptions"/> to override.
/// </description>
/// </item>
/// <item>
/// <description>
/// Operations are tagged by their controller's API group name, and the resulting tags and paths are sorted
/// for stable, diffable document output.
/// </description>
/// </item>
/// <item>
/// <description>
/// Redundant JSON-equivalent media types (such as <c>text/json</c>, <c>application/*+json</c>, and
/// <c>text/plain</c>) are stripped from request and response content when <c>application/json</c> is present,
/// so the document doesn't list spurious media types that ASP.NET Core adds by default.
/// </description>
/// </item>
/// <item>
/// <description>
/// Non-nullable properties are marked as <c>required</c> in the schema so generated client SDKs reflect
/// C# nullability. Override via <see cref="BackOfficeOpenApiDocumentBuilder.ConfigureOpenApiOptions"/>
/// if your types don't follow this convention.
/// </description>
/// </item>
/// <item>
/// <description>
/// The document is registered in the OpenAPI UI document selector dropdown. Call
/// <see cref="BackOfficeOpenApiDocumentBuilder.ExcludeFromUi"/> to opt out.
/// </description>
/// </item>
/// </list>
/// </remarks>
public static IUmbracoBuilder AddBackOfficeOpenApiDocument(
this IUmbracoBuilder builder,
string documentName,
Action<BackOfficeOpenApiDocumentBuilder>? configure = null)
{
var documentBuilder = new BackOfficeOpenApiDocumentBuilder(documentName);
configure?.Invoke(documentBuilder);
documentBuilder.Build(builder);
return builder;
}
}
@@ -29,23 +29,16 @@ public class UmbracoOperationIdTransformer : IOpenApiOperationTransformer
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
var operationId = GenerateOperationId(context);
if (operationId is not null)
{
operation.OperationId = operationId;
}
operation.OperationId = GenerateOperationId(context);
return Task.CompletedTask;
}
private static string? GenerateOperationId(OpenApiOperationTransformerContext context)
private static string GenerateOperationId(OpenApiOperationTransformerContext context)
{
ApiDescription apiDescription = context.Description;
if (apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
{
// Minimal APIs and other non-MVC endpoints don't carry a ControllerActionDescriptor; leave their
// operation ID untouched so the framework's default applies.
return null;
throw new ArgumentException($"This handler operates only on {nameof(ControllerActionDescriptor)}.");
}
ApiVersion defaultVersion = context.ApplicationServices.GetRequiredService<IOptions<ApiVersioningOptions>>().Value.DefaultApiVersion;
@@ -1,6 +1,4 @@
using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.OpenApi;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
@@ -31,32 +29,6 @@ public static class UmbracoSchemaIdGenerator
return Regex.Replace(name, @"[^\w]", string.Empty);
}
/// <summary>
/// Creates a schema reference ID for the given JSON type info, applying Umbraco's naming conventions to
/// types in the <c>Umbraco.Cms</c> namespace and falling back to the framework default for other types.
/// </summary>
/// <param name="jsonTypeInfo">The JSON type info to create a schema reference ID for.</param>
/// <returns>The schema reference ID, or <c>null</c> if the type should be inlined.</returns>
internal static string? CreateSchemaReferenceId(JsonTypeInfo jsonTypeInfo)
{
// Ensure that only types that would normally be included in the schema generation are given a schema reference ID.
// Otherwise, we should return null to inline them.
var defaultSchemaReferenceId = OpenApiOptions.CreateDefaultSchemaReferenceId(jsonTypeInfo);
if (defaultSchemaReferenceId is null)
{
return null;
}
Type targetType = Nullable.GetUnderlyingType(jsonTypeInfo.Type) ?? jsonTypeInfo.Type;
if (targetType.Namespace?.StartsWith("Umbraco.Cms") is not true)
{
return defaultSchemaReferenceId;
}
return Generate(targetType);
}
private static string SanitizedTypeName(Type t) => t.Name
// first grab the "non-generic" part of any generic type name (i.e. "PagedViewModel`1" becomes "PagedViewModel")
.Split('`').First()
@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Umbraco.Cms.Api.Common.DependencyInjection;
@@ -172,12 +171,6 @@ public static class UmbracoBuilderExtensions
builder.Services.AddUnique<IDeliveryApiOutputCacheRequestFilter, DefaultDeliveryApiOutputCacheRequestFilter>();
builder.Services.AddUnique<IDeliveryApiOutputCacheManager, DeliveryApiOutputCacheManager>();
// Signal that Umbraco has enabled output caching so the application builder registers
// the output cache middleware. Gated via a marker rather than IOutputCacheStore so that
// applications calling services.AddOutputCache(...) for their own purposes are not
// affected by Umbraco's automatic middleware registration.
builder.Services.TryAddSingleton<IUmbracoManagedOutputCacheMarker, UmbracoManagedOutputCacheMarker>();
return builder;
}
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Cms.Api.Common.Configuration;
using Umbraco.Cms.Api.Delivery.OpenApi.Extensions;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
@@ -122,52 +122,14 @@ public sealed class ContentTypeSchemaTransformer : IOpenApiSchemaTransformer, IO
return Task.CompletedTask;
}
foreach ((var schemaId, IOpenApiSchema componentsSchema) in document.Components.Schemas)
foreach (IOpenApiSchema componentsSchema in document.Components.Schemas.Values)
{
ResolveSchemaReferences(document, componentsSchema);
FixAutoBuiltDiscriminatorMapping(document, schemaId, componentsSchema);
}
return Task.CompletedTask;
}
/// <summary>
/// Repairs broken discriminator mapping refs auto-built by the framework for polymorphic types.
/// </summary>
/// <remarks>
/// The framework prefixes each ref with the base schema id, but the derived schemas are
/// registered without that prefix. Stripping the prefix recovers the correct ref.
/// </remarks>
private static void FixAutoBuiltDiscriminatorMapping(OpenApiDocument document, string parentSchemaId, IOpenApiSchema schema)
{
if (schema is not OpenApiSchema concrete
|| concrete.Discriminator?.Mapping is not { } mapping
|| document.Components?.Schemas is not { } schemas)
{
return;
}
foreach ((var key, OpenApiSchemaReference currentRef) in mapping.ToList())
{
var targetId = currentRef.Reference.Id;
if (string.IsNullOrEmpty(targetId) || schemas.ContainsKey(targetId))
{
continue;
}
if (targetId.StartsWith(parentSchemaId, StringComparison.Ordinal) is false)
{
continue;
}
var stripped = targetId[parentSchemaId.Length..];
if (schemas.ContainsKey(stripped))
{
mapping[key] = new OpenApiSchemaReference(stripped, document);
}
}
}
/// <inheritdoc />
public async Task TransformAsync(
OpenApiSchema schema,
@@ -533,7 +495,7 @@ public sealed class ContentTypeSchemaTransformer : IOpenApiSchemaTransformer, IO
}
private static string? GetSchemaId(JsonTypeInfo type)
=> UmbracoSchemaIdGenerator.CreateSchemaReferenceId(type);
=> ConfigureUmbracoOpenApiOptionsBase.CreateSchemaReferenceId(type);
/// <summary>
/// Creates a temporary placeholder schema to break circular reference chains during schema generation.
@@ -0,0 +1,42 @@
using Microsoft.AspNetCore.OpenApi;
using Umbraco.Cms.Api.Common.Configuration;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Cms.Api.Management.DependencyInjection;
using Umbraco.Cms.Api.Management.OpenApi;
using Umbraco.Cms.Api.Management.OpenApi.Transformers;
namespace Umbraco.Cms.Api.Management.Configuration;
/// <summary>
/// Configures the OpenAPI options for the Umbraco Management API.
/// </summary>
internal class ConfigureUmbracoManagementApiOpenApiOptions : ConfigureUmbracoOpenApiOptionsBase
{
/// <inheritdoc />
protected override string ApiName => ManagementApiConfiguration.ApiName;
/// <inheritdoc />
protected override string ApiTitle => ManagementApiConfiguration.ApiTitle;
/// <inheritdoc />
protected override string ApiVersion => "Latest";
/// <inheritdoc />
protected override string ApiDescription =>
"This shows all APIs available in this version of Umbraco - including all the legacy apis that are available for backward compatibility";
/// <inheritdoc />
protected override void ConfigureOpenApi(OpenApiOptions options)
{
base.ConfigureOpenApi(options);
// Sets Security requirement on backoffice apis
options.AddBackofficeSecurityRequirements();
options.AddSchemaTransformer<RequireNonNullablePropertiesSchemaTransformer>();
options.AddSchemaTransformer<FixFileReturnTypesTransformer>();
options.AddOperationTransformer<MimeTypesTransformer>();
options.AddOperationTransformer<ResponseHeaderTransformer>();
options.AddOperationTransformer<NotificationHeaderTransformer>();
}
}
@@ -53,14 +53,11 @@ public class SearchDataTypeItemController : DatatypeItemControllerBase
return Ok(new PagedModel<DataTypeItemResponseModel> { Total = searchResult.Total });
}
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(keys);
IEnumerable<IDataType> orderedDataTypes = OrderByRequestedIds(dataTypes, keys);
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
var result = new PagedModel<DataTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(orderedDataTypes),
Total = searchResult.Total,
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(dataTypes),
Total = searchResult.Total
};
return Ok(result);
@@ -49,8 +49,8 @@ public class ByKeyElementFolderController : ElementFolderControllerBase
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementContainerPermissionResource.WithKeys(ActionElementContainerBrowse.ActionLetter, id),
AuthorizationPolicies.ElementFolderPermissionByResource);
ElementPermissionResource.WithKeys(ActionElementBrowse.ActionLetter, id),
AuthorizationPolicies.ElementPermissionByResource);
if (!authorizationResult.Succeeded)
{
@@ -50,8 +50,8 @@ public class CreateElementFolderController : ElementFolderControllerBase
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementContainerPermissionResource.WithKeys(ActionElementContainerNew.ActionLetter, createFolderRequestModel.Parent?.Id),
AuthorizationPolicies.ElementFolderPermissionByResource);
ElementPermissionResource.WithKeys(ActionElementNew.ActionLetter, createFolderRequestModel.Parent?.Id),
AuthorizationPolicies.ElementPermissionByResource);
if (!authorizationResult.Succeeded)
{
@@ -49,18 +49,7 @@ public class DeleteElementFolderController : ElementFolderControllerBase
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementContainerPermissionResource.WithKeys(ActionElementContainerDelete.ActionLetter, id),
AuthorizationPolicies.ElementFolderPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
// Also authorize deletion of all descendant elements.
authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementPermissionResource.Branch(ActionElementDelete.ActionLetter, id),
ElementPermissionResource.WithKeys(ActionElementDelete.ActionLetter, id),
AuthorizationPolicies.ElementPermissionByResource);
if (!authorizationResult.Succeeded)
@@ -60,8 +60,8 @@ public class MoveElementFolderController : ElementFolderControllerBase
// Check Move permission on source folder
AuthorizationResult sourceAuthorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementContainerPermissionResource.WithKeys(ActionElementContainerMove.ActionLetter, id),
AuthorizationPolicies.ElementFolderPermissionByResource);
ElementPermissionResource.WithKeys(ActionElementMove.ActionLetter, id),
AuthorizationPolicies.ElementPermissionByResource);
if (!sourceAuthorizationResult.Succeeded)
{
@@ -71,8 +71,8 @@ public class MoveElementFolderController : ElementFolderControllerBase
// Check Create permission on target (where we're moving to)
AuthorizationResult targetAuthorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementContainerPermissionResource.WithKeys(ActionElementContainerNew.ActionLetter, moveFolderRequestModel.Target?.Id),
AuthorizationPolicies.ElementFolderPermissionByResource);
ElementPermissionResource.WithKeys(ActionElementNew.ActionLetter, moveFolderRequestModel.Target?.Id),
AuthorizationPolicies.ElementPermissionByResource);
if (!targetAuthorizationResult.Succeeded)
{
@@ -57,8 +57,8 @@ public class MoveToRecycleBinElementFolderController : ElementFolderControllerBa
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementContainerPermissionResource.WithKeys(ActionElementContainerDelete.ActionLetter, id),
AuthorizationPolicies.ElementFolderPermissionByResource);
ElementPermissionResource.WithKeys(ActionElementDelete.ActionLetter, id),
AuthorizationPolicies.ElementPermissionByResource);
if (!authorizationResult.Succeeded)
{
@@ -51,8 +51,8 @@ public class UpdateElementFolderController : ElementFolderControllerBase
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementContainerPermissionResource.WithKeys(ActionElementContainerUpdate.ActionLetter, id),
AuthorizationPolicies.ElementFolderPermissionByResource);
ElementPermissionResource.WithKeys(ActionElementUpdate.ActionLetter, id),
AuthorizationPolicies.ElementPermissionByResource);
if (!authorizationResult.Succeeded)
{
@@ -61,9 +61,8 @@ public class SearchElementItemController : ElementItemControllerBase
.GetAll(UmbracoObjectTypes.Element, keys)
.OfType<IElementEntitySlim>()
.ToArray();
List<IElementEntitySlim> orderedElements = OrderByRequestedIds(elements, keys);
ElementItemResponseModel[] items = await Task.WhenAll(orderedElements.Select(_elementPresentationFactory.CreateItemResponseModelAsync));
ElementItemResponseModel[] items = await Task.WhenAll(elements.Select(_elementPresentationFactory.CreateItemResponseModelAsync));
return Ok(
new PagedModel<ElementItemResponseModel>
@@ -63,18 +63,7 @@ public class DeleteElementFolderRecycleBinController : ElementRecycleBinControll
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementContainerPermissionResource.RecycleBin(ActionElementContainerDelete.ActionLetter),
AuthorizationPolicies.ElementFolderPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
// Also authorize deletion of all descendant elements.
authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementPermissionResource.Branch(ActionElementDelete.ActionLetter, id),
ElementPermissionResource.RecycleBin(ActionElementDelete.ActionLetter),
AuthorizationPolicies.ElementPermissionByResource);
if (!authorizationResult.Succeeded)
@@ -59,8 +59,8 @@ public class OriginalParentElementFolderRecycleBinController : ElementRecycleBin
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementContainerPermissionResource.RecycleBin(ActionElementContainerBrowse.ActionLetter),
AuthorizationPolicies.ElementFolderPermissionByResource);
ElementPermissionResource.RecycleBin(ActionElementBrowse.ActionLetter),
AuthorizationPolicies.ElementPermissionByResource);
if (!authorizationResult.Succeeded)
{
@@ -64,8 +64,8 @@ public class RestoreElementFolderRecycleBinController : ElementRecycleBinControl
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ElementContainerPermissionResource.RecycleBin(ActionElementContainerMove.ActionLetter),
AuthorizationPolicies.ElementFolderPermissionByResource);
ElementPermissionResource.RecycleBin(ActionElementMove.ActionLetter),
AuthorizationPolicies.ElementPermissionByResource);
if (!authorizationResult.Succeeded)
{
@@ -54,14 +54,11 @@ public class SearchMediaTypeItemController : MediaTypeItemControllerBase
return Task.FromResult<IActionResult>(Ok(new PagedModel<MediaTypeItemResponseModel> { Total = searchResult.Total }));
}
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(keys.EmptyNull());
IEnumerable<IMediaType> orderedMediaTypes = OrderByRequestedIds(mediaTypes, keys);
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray().EmptyNull());
var result = new PagedModel<MediaTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(orderedMediaTypes),
Total = searchResult.Total,
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(mediaTypes),
Total = searchResult.Total
};
return Task.FromResult<IActionResult>(Ok(result));
@@ -32,14 +32,6 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
_mapper = mapper;
}
/// <summary>
/// Searches for member type items matching the specified query, with support for pagination.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="query">The search query used to filter member type items.</param>
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{MemberTypeItemResponseModel}"/> containing the search results.</returns>
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<MemberTypeItemResponseModel>), StatusCodes.Status200OK)]
@@ -53,14 +45,11 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
return Task.FromResult<IActionResult>(Ok(new PagedModel<MemberTypeItemResponseModel> { Total = searchResult.Total }));
}
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(keys);
IEnumerable<IMemberType> orderedMemberTypes = OrderByRequestedIds(memberTypes, keys);
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray());
var result = new PagedModel<MemberTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(orderedMemberTypes),
Total = searchResult.Total,
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(memberTypes),
Total = searchResult.Total
};
return Task.FromResult<IActionResult>(Ok(result));
@@ -8,38 +8,67 @@ using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
/// <summary>
/// Controller for setting the redirect URL tracking status. Retained for backwards compatibility only;
/// the endpoint no longer modifies any configuration.
/// Controller for setting the redirect URL tracking status.
/// </summary>
[ApiVersion("1.0")]
[Obsolete("This controller is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public class SetStatusRedirectUrlManagementController : RedirectUrlManagementControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IConfigManipulator _configManipulator;
/// <summary>
/// Initializes a new instance of the <see cref="SetStatusRedirectUrlManagementController"/> class.
/// </summary>
/// <param name="backOfficeSecurityAccessor">Ignored. Retained for binary compatibility.</param>
/// <param name="configManipulator">Ignored. Retained for binary compatibility.</param>
/// <param name="backOfficeSecurityAccessor">The back office security accessor.</param>
/// <param name="configManipulator">The configuration manipulator.</param>
public SetStatusRedirectUrlManagementController(
#pragma warning disable IDE0060 // Remove unused parameter
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IConfigManipulator configManipulator)
#pragma warning restore IDE0060 // Remove unused parameter
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_configManipulator = configManipulator;
}
// TODO: Consider if we should even allow this, or only allow using the appsettings
// We generally don't want to edit the appsettings from our code.
// But maybe there is a valid use case for doing it on the fly.
/// <summary>
/// Deprecated. Returns an OK response without modifying any configuration. To toggle redirect URL tracking,
/// set the <c>Umbraco:CMS:WebRouting:DisableRedirectUrlTracking</c> configuration key instead.
/// Sets the redirect URL tracking status.
/// </summary>
/// <param name="cancellationToken">The cancellation token for the HTTP request.</param>
/// <param name="status">The redirect status (ignored).</param>
/// <returns>An OK result.</returns>
/// <param name="status">The redirect status to set.</param>
/// <returns>An OK result if successful.</returns>
[HttpPost("status")]
[EndpointSummary("Deprecated. No longer changes the redirect URL tracking status.")]
[EndpointDescription("This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.")]
[EndpointSummary("Sets the redirect URL tracking status.")]
[EndpointDescription("Updates the redirect URL tracking configuration according to the provided status.")]
[MapToApiVersion("1.0")]
[Obsolete("This endpoint is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
=> Task.FromResult<IActionResult>(Ok());
public async Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
{
// TODO: uncomment this when auth is implemented.
// var userIsAdmin = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin();
// if (userIsAdmin is null or false)
// {
// return Unauthorized();
// }
var enable = status switch
{
RedirectStatus.Enabled => true,
RedirectStatus.Disabled => false,
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown redirect status")
};
// For now I'm not gonna change this to limit breaking, but it's weird to have a "disabled" switch,
// since you're essentially negating the boolean from the get go,
// it's much easier to reason with enabled = false == disabled.
await _configManipulator.SaveDisableRedirectUrlTrackingAsync(!enable);
// Taken from the existing implementation in RedirectUrlManagementController
// TODO this is ridiculous, but we need to ensure the configuration is reloaded, before this request is ended.
// otherwise we can read the old value in GetEnableState.
// The value is equal to JsonConfigurationSource.ReloadDelay
Thread.Sleep(250);
return Ok();
}
}
@@ -28,7 +28,6 @@ public class ConfigurationServerController : ServerControllerBase
private readonly GlobalSettings _globalSettings;
private readonly IBackOfficeExternalLoginProviders _externalLoginProviders;
private readonly IHostingEnvironment _hostingEnvironment;
private readonly SignalRSettings _signalRSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationServerController"/> class.
@@ -37,38 +36,13 @@ public class ConfigurationServerController : ServerControllerBase
/// <param name="globalSettings">The global settings options.</param>
/// <param name="externalLoginProviders">The external login providers for back office.</param>
/// <param name="hostingEnvironment">The hosting environment.</param>
/// <param name="signalRSettings">The SignalR settings options.</param>
[ActivatorUtilitiesConstructor]
public ConfigurationServerController(
IOptions<SecuritySettings> securitySettings,
IOptions<GlobalSettings> globalSettings,
IBackOfficeExternalLoginProviders externalLoginProviders,
IHostingEnvironment hostingEnvironment,
IOptions<SignalRSettings> signalRSettings)
public ConfigurationServerController(IOptions<SecuritySettings> securitySettings, IOptions<GlobalSettings> globalSettings, IBackOfficeExternalLoginProviders externalLoginProviders, IHostingEnvironment hostingEnvironment)
{
_securitySettings = securitySettings.Value;
_globalSettings = globalSettings.Value;
_externalLoginProviders = externalLoginProviders;
_hostingEnvironment = hostingEnvironment;
_signalRSettings = signalRSettings.Value;
}
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Server.ConfigurationServerController"/> class.
/// </summary>
/// <param name="securitySettings">The <see cref="SecuritySettings"/> options.</param>
/// <param name="globalSettings">The <see cref="GlobalSettings"/> options.</param>
/// <param name="externalLoginProviders">The external login providers used for back office authentication.</param>
/// <param name="hostingEnvironment">The hosting environment.</param>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public ConfigurationServerController(IOptions<SecuritySettings> securitySettings, IOptions<GlobalSettings> globalSettings, IBackOfficeExternalLoginProviders externalLoginProviders, IHostingEnvironment hostingEnvironment)
: this(
securitySettings,
globalSettings,
externalLoginProviders,
hostingEnvironment,
StaticServiceProvider.Instance.GetRequiredService<IOptions<SignalRSettings>>())
{
}
/// <summary>
@@ -104,10 +78,6 @@ public class ConfigurationServerController : ServerControllerBase
VersionCheckPeriod = _globalSettings.VersionCheckPeriod,
AllowLocalLogin = _externalLoginProviders.HasDenyLocalLogin() is false,
UmbracoCssPath = _hostingEnvironment.ToAbsolute(_globalSettings.UmbracoCssPath),
SignalR = new SignalRClientSettingsResponseModel
{
SkipNegotiation = _signalRSettings.ClientShouldSkipNegotiation,
},
};
return Task.FromResult<IActionResult>(Ok(responseModel));
@@ -32,14 +32,6 @@ public class SearchTemplateItemController : TemplateItemControllerBase
_mapper = mapper;
}
/// <summary>
/// Searches for template items matching the specified query, with support for pagination.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="query">The search query used to filter template items.</param>
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{TemplateItemResponseModel}"/> containing the search results.</returns>
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<TemplateItemResponseModel>), StatusCodes.Status200OK)]
@@ -53,14 +45,11 @@ public class SearchTemplateItemController : TemplateItemControllerBase
return Ok(new PagedModel<TemplateItemResponseModel> { Total = searchResult.Total });
}
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(keys);
IEnumerable<ITemplate> orderedTemplates = OrderByRequestedIds(templates, keys);
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
var result = new PagedModel<TemplateItemResponseModel>
{
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(orderedTemplates),
Total = searchResult.Total,
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(templates),
Total = searchResult.Total
};
return Ok(result);
@@ -26,7 +26,6 @@ internal static class BackOfficeAuthPolicyBuilderExtensions
builder.Services.AddSingleton<IAuthorizationHandler, DenyLocalLoginHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, DictionaryPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, ElementPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, ElementContainerPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, FeatureAuthorizeHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, MediaPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, UserGroupPermissionHandler>();
@@ -151,12 +150,6 @@ internal static class BackOfficeAuthPolicyBuilderExtensions
policy.Requirements.Add(new ElementPermissionRequirement());
});
options.AddPolicy(AuthorizationPolicies.ElementFolderPermissionByResource, policy =>
{
policy.AuthenticationSchemes.Add(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
policy.Requirements.Add(new ElementContainerPermissionRequirement());
});
options.AddPolicy(AuthorizationPolicies.MediaPermissionByResource, policy =>
{
policy.AuthenticationSchemes.Add(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
@@ -5,7 +5,6 @@ using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Web.Common.Hosting;
using Umbraco.Cms.Web.Common.Middleware;
namespace Umbraco.Extensions;
@@ -69,10 +68,6 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IBackOfficeEnabledMarker, BackOfficeEnabledMarker>();
builder.Services.AddUnique<IBackOfficePathGenerator, UmbracoBackOfficePathGenerator>();
// Registered here rather than in AddWebComponents because the middleware depends on
// IBackOfficePathGenerator (registered just above). DI scope validation would otherwise
// fail in Delivery-only/Website-only bootstraps that never call AddBackOffice().
builder.Services.AddSingleton<UmbracoBackOfficeCacheHeadersMiddleware>();
builder.Services.AddUnique<IPhysicalFileSystem>(factory =>
{
var path = "~/";
@@ -1,11 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Common.Configuration;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Cms.Api.Management.Configuration;
using Umbraco.Cms.Api.Management.DependencyInjection;
using Umbraco.Cms.Api.Management.Middleware;
using Umbraco.Cms.Api.Management.OpenApi;
using Umbraco.Cms.Api.Management.OpenApi.Transformers;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.Serialization;
using Umbraco.Cms.Api.Management.Services;
@@ -102,25 +100,10 @@ public static partial class UmbracoBuilderExtensions
// Configures the JSON options for the Open API schema generation (based on the back-office MVC JSON options)
builder.Services.ConfigureOptions<ConfigureUmbracoBackofficeHttpJsonOptions>();
builder.AddBackOfficeOpenApiDocument(
builder.AddUmbracoOpenApiDocument<ConfigureUmbracoManagementApiOpenApiOptions>(
ManagementApiConfiguration.ApiName,
document => document
.WithTitle(ManagementApiConfiguration.ApiTitle)
.WithBackOfficeAuthentication()
.WithJsonOptions(Constants.JsonOptionsNames.BackOffice)
.ConfigureOpenApiOptions(options =>
{
options.AddDocumentTransformer((doc, _, _) =>
{
doc.Info.Version = "Latest";
doc.Info.Description = "This shows all APIs available in this version of Umbraco - including all the legacy apis that are available for backward compatibility";
doc.Servers?.Clear();
return Task.CompletedTask;
});
options.AddSchemaTransformer<FixFileReturnTypesTransformer>();
options.AddOperationTransformer<ResponseHeaderTransformer>();
options.AddOperationTransformer<NotificationHeaderTransformer>();
}));
ManagementApiConfiguration.ApiTitle,
Constants.JsonOptionsNames.BackOffice);
services.Configure<UmbracoPipelineOptions>(options =>
{
File diff suppressed because it is too large Load Diff
@@ -1,18 +0,0 @@
using Umbraco.Cms.Api.Common.OpenApi;
namespace Umbraco.Cms.Api.Management.OpenApi;
/// <summary>
/// Management-specific extension methods for <see cref="BackOfficeOpenApiDocumentBuilder"/>.
/// </summary>
public static class BackOfficeOpenApiDocumentBuilderExtensions
{
/// <summary>
/// Adds backoffice authentication requirements to the document.
/// </summary>
/// <param name="documentBuilder">The document builder.</param>
/// <returns>The same builder for chaining.</returns>
public static BackOfficeOpenApiDocumentBuilder WithBackOfficeAuthentication(
this BackOfficeOpenApiDocumentBuilder documentBuilder)
=> documentBuilder.ConfigureOpenApiOptions(options => options.AddBackofficeSecurityRequirements());
}
@@ -26,20 +26,11 @@ internal class BackOfficeSecurityRequirementsTransformer : IOpenApiOperationTran
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
if (context.Description.ActionDescriptor is not ControllerActionDescriptor description)
{
return Task.CompletedTask;
}
if (description.MethodInfo.GetCustomAttributes(true).Any(x => x is AllowAnonymousAttribute) ||
if (context.Description.ActionDescriptor is not ControllerActionDescriptor description ||
description.MethodInfo.GetCustomAttributes(true).Any(x => x is AllowAnonymousAttribute) ||
description.MethodInfo.DeclaringType?.GetCustomAttributes(true).Any(x => x is AllowAnonymousAttribute) ==
true)
{
// Explicitly clear security on anonymous operations so they override the document-level
// security requirement added below. Without this, OpenAPI consumers (including the
// generated backoffice SDK) treat these endpoints as authenticated and attach a Bearer
// token, which triggers a /token refresh before the user has logged in.
operation.Security = [];
return Task.CompletedTask;
}
@@ -1,12 +1,8 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Management.Controllers.Security;
using Umbraco.Cms.Api.Management.ServerEvents;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Routing;
using Umbraco.Extensions;
@@ -16,36 +12,26 @@ namespace Umbraco.Cms.Api.Management.Routing;
/// <summary>
/// Creates routes for the back office area.
/// </summary>
public sealed class BackOfficeAreaRoutes : SignalRRoutesBase, IAreaRoutes
public sealed class BackOfficeAreaRoutes : IAreaRoutes
{
/// <summary>
/// Initializes a new instance of the <see cref="BackOfficeAreaRoutes" /> class.
/// </summary>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public BackOfficeAreaRoutes(IRuntimeState runtimeState)
: this(
runtimeState,
StaticServiceProvider.Instance.GetRequiredService<IOptions<SignalRSettings>>())
{
}
private readonly IRuntimeState _runtimeState;
/// <summary>
/// Initializes a new instance of the <see cref="BackOfficeAreaRoutes" /> class.
/// </summary>
public BackOfficeAreaRoutes(IRuntimeState runtimeState, IOptions<SignalRSettings> signalRSettings)
: base(runtimeState, signalRSettings)
{
}
public BackOfficeAreaRoutes(IRuntimeState runtimeState)
=> _runtimeState = runtimeState;
/// <inheritdoc />
public void CreateRoutes(IEndpointRouteBuilder endpoints)
{
if (RuntimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run)
if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run)
{
MapMinimalBackOffice(endpoints);
endpoints.MapHub<BackofficeHub>(Constants.System.UmbracoPathSegment + Constants.Web.BackofficeSignalRHub, ConfigureHubEndpoint);
endpoints.MapHub<ServerEventHub>(Constants.System.UmbracoPathSegment + Constants.Web.ServerEventSignalRHub, ConfigureHubEndpoint);
endpoints.MapHub<BackofficeHub>(Constants.System.UmbracoPathSegment + Constants.Web.BackofficeSignalRHub);
endpoints.MapHub<ServerEventHub>(Constants.System.UmbracoPathSegment + Constants.Web.ServerEventSignalRHub);
}
}
@@ -1,11 +1,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Management.Preview;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Routing;
@@ -14,29 +10,16 @@ namespace Umbraco.Cms.Api.Management.Routing;
/// <summary>
/// Creates routes for the preview hub
/// </summary>
public sealed class PreviewRoutes : SignalRRoutesBase, IAreaRoutes
public sealed class PreviewRoutes : IAreaRoutes
{
/// <summary>
/// Initializes a new instance of the <see cref="PreviewRoutes"/> class, configuring preview routing based on the application's runtime state.
/// </summary>
/// <param name="runtimeState">An instance representing the current runtime state of the Umbraco application.</param>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public PreviewRoutes(IRuntimeState runtimeState)
: this(
runtimeState,
StaticServiceProvider.Instance.GetRequiredService<IOptions<SignalRSettings>>())
{
}
private readonly IRuntimeState _runtimeState;
/// <summary>
/// Initializes a new instance of the <see cref="PreviewRoutes"/> class, configuring preview routing based on the application's runtime state.
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Routing.PreviewRoutes"/> class, configuring preview routing based on the application's runtime state.
/// </summary>
/// <param name="runtimeState">An instance representing the current runtime state of the Umbraco application.</param>
/// <param name="signalRSettings">The SignalR settings options.</param>
public PreviewRoutes(IRuntimeState runtimeState, IOptions<SignalRSettings> signalRSettings)
: base(runtimeState, signalRSettings)
{
}
public PreviewRoutes(IRuntimeState runtimeState)
=> _runtimeState = runtimeState;
/// <summary>
/// Creates the preview routes on the specified endpoint route builder.
@@ -44,9 +27,9 @@ public sealed class PreviewRoutes : SignalRRoutesBase, IAreaRoutes
/// <param name="endpoints">The endpoint route builder to add routes to.</param>
public void CreateRoutes(IEndpointRouteBuilder endpoints)
{
if (RuntimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run)
if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run)
{
endpoints.MapHub<PreviewHub>(GetPreviewHubRoute(), ConfigureHubEndpoint);
endpoints.MapHub<PreviewHub>(GetPreviewHubRoute());
}
}
@@ -58,4 +41,3 @@ public sealed class PreviewRoutes : SignalRRoutesBase, IAreaRoutes
/// </returns>
public string GetPreviewHubRoute() => $"/{Constants.System.UmbracoPathSegment}/{nameof(PreviewHub)}";
}
@@ -1,45 +0,0 @@
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Routing;
/// <summary>
/// Base class for route definitions that map SignalR hub endpoints,
/// applying shared transport configuration from <see cref="SignalRSettings"/>.
/// </summary>
public abstract class SignalRRoutesBase
{
private readonly SignalRSettings _signalRSettings;
/// <summary>
/// Initializes a new instance of the <see cref="SignalRRoutesBase"/> class.
/// </summary>
/// <param name="runtimeState">The current runtime state of the Umbraco application.</param>
/// <param name="signalRSettings">The SignalR settings options.</param>
protected SignalRRoutesBase(IRuntimeState runtimeState, IOptions<SignalRSettings> signalRSettings)
{
RuntimeState = runtimeState;
_signalRSettings = signalRSettings.Value;
}
/// <summary>
/// Gets the current runtime state of the Umbraco application.
/// </summary>
protected IRuntimeState RuntimeState { get; }
/// <summary>
/// Configures the transport options for a SignalR hub endpoint.
/// When <see cref="SignalRSettings.ClientShouldSkipNegotiation"/> is enabled,
/// restricts the endpoint to WebSocket transport only so clients can skip the negotiate round-trip.
/// </summary>
/// <param name="options">The hub endpoint dispatcher options to configure.</param>
protected void ConfigureHubEndpoint(HttpConnectionDispatcherOptions options)
{
if (_signalRSettings.ClientShouldSkipNegotiation)
{
options.Transports = HttpTransportType.WebSockets;
}
}
}
@@ -1,52 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security.Authorization;
namespace Umbraco.Cms.Api.Management.Security.Authorization.Element;
/// <summary>
/// Authorizes that the current user has the correct permission access to the element container item(s) specified in the request.
/// </summary>
public class ElementContainerPermissionHandler : MustSatisfyRequirementAuthorizationHandler<ElementContainerPermissionRequirement, ElementContainerPermissionResource>
{
private readonly IElementContainerPermissionAuthorizer _elementContainerPermissionAuthorizer;
private readonly IAuthorizationHelper _authorizationHelper;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerPermissionHandler" /> class.
/// </summary>
/// <param name="elementContainerPermissionAuthorizer">Authorizer for element container access.</param>
/// <param name="authorizationHelper">The authorization helper.</param>
public ElementContainerPermissionHandler(IElementContainerPermissionAuthorizer elementContainerPermissionAuthorizer, IAuthorizationHelper authorizationHelper)
{
_elementContainerPermissionAuthorizer = elementContainerPermissionAuthorizer;
_authorizationHelper = authorizationHelper;
}
/// <inheritdoc />
protected override async Task<bool> IsAuthorized(
AuthorizationHandlerContext context,
ElementContainerPermissionRequirement requirement,
ElementContainerPermissionResource resource)
{
var result = true;
IUser user = _authorizationHelper.GetUmbracoUser(context.User);
if (resource.CheckRoot)
{
result &= await _elementContainerPermissionAuthorizer.IsDeniedAtRootLevelAsync(user, resource.PermissionsToCheck) is false;
}
if (resource.CheckRecycleBin)
{
result &= await _elementContainerPermissionAuthorizer.IsDeniedAtRecycleBinLevelAsync(user, resource.PermissionsToCheck) is false;
}
if (resource.ContainerKeys.Any())
{
result &= await _elementContainerPermissionAuthorizer.IsDeniedAsync(user, resource.ContainerKeys, resource.PermissionsToCheck) is false;
}
return result;
}
}
@@ -1,10 +0,0 @@
using Microsoft.AspNetCore.Authorization;
namespace Umbraco.Cms.Api.Management.Security.Authorization.Element;
/// <summary>
/// Authorization requirement for the <see cref="ElementContainerPermissionHandler" />.
/// </summary>
public class ElementContainerPermissionRequirement : IAuthorizationRequirement
{
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,5 @@
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
@@ -26,7 +25,7 @@ internal sealed class DocumentPermissionFilterService : PermissionFilterServiceB
=> _contentPermissionService = contentPermissionService;
/// <inheritdoc/>
protected override string BrowseActionLetter(IEntitySlim entity) => ActionBrowse.ActionLetter;
protected override string BrowseActionLetter => ActionBrowse.ActionLetter;
/// <inheritdoc/>
protected override Task<IEnumerable<NodePermissions>> GetPermissionsAsync(IUser user, IEnumerable<Guid> entityKeys)
@@ -1,7 +1,5 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
@@ -27,10 +25,7 @@ internal sealed class ElementPermissionFilterService : PermissionFilterServiceBa
=> _elementPermissionService = elementPermissionService;
/// <inheritdoc/>
protected override string BrowseActionLetter(IEntitySlim entity)
=> entity.NodeObjectType == Constants.ObjectTypes.Element
? ActionElementBrowse.ActionLetter
: ActionElementContainerBrowse.ActionLetter;
protected override string BrowseActionLetter => ActionElementBrowse.ActionLetter;
/// <inheritdoc/>
protected override Task<IEnumerable<NodePermissions>> GetPermissionsAsync(IUser user, IEnumerable<Guid> entityKeys)
@@ -22,7 +22,7 @@ internal abstract class PermissionFilterServiceBase
/// <summary>
/// Gets the browse action letter used to check permissions.
/// </summary>
protected abstract string BrowseActionLetter(IEntitySlim entity);
protected abstract string BrowseActionLetter { get; }
/// <summary>
/// Filters entities based on the current user's browse permissions.
@@ -106,5 +106,5 @@ internal abstract class PermissionFilterServiceBase
private bool HasBrowsePermission(IEntitySlim entity, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
=> permissionsByNodeKey.TryGetValue(entity.Key, out NodePermissions? nodePermissions)
&& nodePermissions.Permissions.Contains(BrowseActionLetter(entity));
&& nodePermissions.Permissions.Contains(BrowseActionLetter);
}
@@ -21,9 +21,4 @@ public class ServerConfigurationResponseModel
/// Gets or sets the relative or absolute path to the Umbraco CSS file used by the application.
/// </summary>
public string UmbracoCssPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the client-side SignalR settings.
/// </summary>
public SignalRClientSettingsResponseModel SignalR { get; set; } = new();
}
@@ -1,10 +0,0 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Server;
/// <summary>
/// Represents client-side SignalR settings returned by the server configuration endpoint.
/// </summary>
public class SignalRClientSettingsResponseModel
{
/// <summary>Gets or sets a value indicating whether the client should skip the SignalR negotiate round-trip.</summary>
public bool SkipNegotiation { get; set; }
}
+9 -13
View File
@@ -119,7 +119,8 @@ BackofficeProjectDirectory = ../Umbraco.Web.UI.Client/
BackofficeAssetsPath = wwwroot/umbraco/backoffice
```
**Login Build** (lines 102-148):
**Login Build** (lines 94-148):
```
LoginProjectDirectory = ../Umbraco.Web.UI.Login/
LoginAssetsPath = wwwroot/umbraco/login
@@ -127,18 +128,13 @@ LoginAssetsPath = wwwroot/umbraco/login
### Build Targets
| Target | Purpose |
|--------|---------|
| `BuildBackofficeStaticAssetsPreconditions` | Checks if Backoffice build needed (Visual Studio only) |
| `RestoreBackoffice` | Runs `npm i` if package-lock changed |
| `BuildBackoffice` | Runs `npm run build:for:cms` |
| `DefineBackofficeAssets` | Registers Backoffice assets with StaticWebAssets system |
| `CleanBackoffice` | Removes built Backoffice assets on `dotnet clean` |
| `BuildLoginStaticAssetsPreconditions` | Checks if Login build needed (Visual Studio only) |
| `RestoreLogin` | Runs `npm i` if Login's package-lock changed |
| `BuildLogin` | Runs `npm run build` in Login. Depends on `RestoreBackoffice` because Login's `tsc` walks Client's `src/` via tsconfig path aliases and needs Client's `node_modules` populated for transitive `lit`/`rxjs`/UUI resolution |
| `DefineLoginAssets` | Registers Login assets with StaticWebAssets system |
| `CleanLogin` | Removes built Login assets on `dotnet clean` |
| Target | Purpose |
| -------------------------------- | -------------------------------------------- |
| `BuildStaticAssetsPreconditions` | Checks if build needed (Visual Studio only) |
| `RestoreBackoffice` | Runs `npm i` if package-lock changed |
| `BuildBackoffice` | Runs `npm run build:for:cms` |
| `DefineBackofficeAssets` | Registers assets with StaticWebAssets system |
| `CleanBackoffice` | Removes built assets on `dotnet clean` |
### Build Conditions
@@ -113,7 +113,7 @@
<Exec Command="npm i --no-fund --no-audit" WorkingDirectory="$(LoginProjectDirectory)" />
</Target>
<Target Name="BuildLogin" DependsOnTargets="RestoreLogin;RestoreBackoffice">
<Target Name="BuildLogin" DependsOnTargets="RestoreLogin">
<Message Importance="high" Text="Executing Login NPM build script..." />
<Exec Command="npm run build" WorkingDirectory="$(LoginProjectDirectory)" />
<ItemGroup>
@@ -21,7 +21,7 @@
var backOfficeAssetsPath = BackOfficePathGenerator.BackOfficeAssetsPath;
var loginLogoImageAlternative = Url.RouteUrl(BackOfficeGraphicsController.LoginLogoAlternativeRouteName, new {Version= "1"});
}<!doctype html>
<html lang="en">
<html lang="@GlobalSettings.Value.DefaultUILanguage">
<head>
<meta charset="UTF-8" />
@@ -61,7 +61,7 @@
<p>Here are the <a href="https://www.enable-javascript.com/" target="_blank" rel="noopener" style="text-decoration: underline;">instructions how to enable JavaScript in your web browser</a>.</p>
</div>
</noscript>
<umb-app lang="@GlobalSettings.Value.DefaultUILanguage" @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
<umb-app @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
@if (isDebug)
{
@@ -35,7 +35,7 @@
}
<!DOCTYPE html>
<html lang="en">
<html lang="@GlobalSettings.Value.DefaultUILanguage">
<head>
<meta charset="UTF-8"/>
<base href="@backOfficePath.EnsureEndsWith('/')" />
@@ -83,7 +83,6 @@
</noscript>
<umb-auth
lang="@GlobalSettings.Value.DefaultUILanguage"
return-url="@backOfficePath"
logo-image="@loginLogoImage"
logo-image-alternative="@loginLogoImageAlternative"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 216 KiB

@@ -16,16 +16,21 @@
</ItemGroup>
<!--
The Razor editor in modern Visual Studio and the C# extension for VS Code use the Razor source generator
The Razor editor in VS2026 and the C# extension for VS Code uses 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 EnableCohostEditorCompatibility=false
We also have an escape hatch in case it does cause issues, users can set the appropriate property
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,39 +49,4 @@
<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>
@@ -1,33 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.Actions;
/// <summary>
/// This action is used as a security constraint that grants a user the ability to view element containers in a tree
/// that has permissions applied to it.
/// </summary>
/// <remarks>
/// This action should not be invoked. It is used as the minimum required permission to view element containers in the element tree.
/// By granting a user this permission, the user is able to see the element container in the tree but not edit it.
/// </remarks>
public class ActionElementContainerBrowse : IAction
{
/// <inheritdoc cref="IAction.ActionLetter" />
public const string ActionLetter = "Umb.ElementContainer.Read";
/// <inheritdoc cref="IAction.ActionAlias" />
public const string ActionAlias = "elementcontainerbrowse";
/// <inheritdoc/>
public string Letter => ActionLetter;
/// <inheritdoc/>
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
}
@@ -1,28 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.Actions;
/// <summary>
/// This action is used as a security constraint that grants a user the ability to delete element containers.
/// </summary>
public class ActionElementContainerDelete : IAction
{
/// <inheritdoc cref="IAction.ActionLetter" />
public const string ActionLetter = "Umb.ElementContainer.Delete";
/// <inheritdoc cref="IAction.ActionAlias" />
public const string ActionAlias = "elementcontainerdelete";
/// <inheritdoc/>
public string Letter => ActionLetter;
/// <inheritdoc/>
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
}
@@ -1,28 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.Actions;
/// <summary>
/// This action is used as a security constraint that grants a user the ability to move element containers.
/// </summary>
public class ActionElementContainerMove : IAction
{
/// <inheritdoc cref="IAction.ActionLetter" />
public const string ActionLetter = "Umb.ElementContainer.Move";
/// <inheritdoc cref="IAction.ActionAlias" />
public const string ActionAlias = "elementcontainermove";
/// <inheritdoc/>
public string Letter => ActionLetter;
/// <inheritdoc/>
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
}
@@ -1,28 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.Actions;
/// <summary>
/// This action is used as a security constraint that grants a user the ability to create new element containers.
/// </summary>
public class ActionElementContainerNew : IAction
{
/// <inheritdoc cref="IAction.ActionLetter" />
public const string ActionLetter = "Umb.ElementContainer.Create";
/// <inheritdoc cref="IAction.ActionAlias" />
public const string ActionAlias = "elementcontainercreate";
/// <inheritdoc/>
public string Letter => ActionLetter;
/// <inheritdoc/>
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
}
@@ -1,28 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.Actions;
/// <summary>
/// This action is used as a security constraint that grants a user the ability to update element containers.
/// </summary>
public class ActionElementContainerUpdate : IAction
{
/// <inheritdoc cref="IAction.ActionLetter" />
public const string ActionLetter = "Umb.ElementContainer.Update";
/// <inheritdoc cref="IAction.ActionAlias" />
public const string ActionAlias = "elementcontainerupdate";
/// <inheritdoc/>
public string Letter => ActionLetter;
/// <inheritdoc/>
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
}
-2
View File
@@ -306,8 +306,6 @@ public class MyEntityCacheRefresher : CacheRefresherBase<MyEntityCacheRefresher>
- `Attempt.Succeed(value)` / `Attempt.Fail<T>()`
- `Attempt<Content, ContentEditingOperationStatus>` - typed result with status
> Writing or reviewing a query with a `WHERE IN` on a runtime-sized collection? See "Avoiding the SQL Server 2100-parameter limit" in `/src/Umbraco.Infrastructure/CLAUDE.md` — that's where the full helper list (`Constants.Sql.MaxParameterCount`, `InGroupsOf`, NPoco's `FetchByGroups`) and the decision rules live.
### Configuration
Configuration models in `/Configuration/Models`:
@@ -26,21 +26,9 @@ public interface IRepositoryCacheVersionAccessor
/// Notifies of a version change on a given cache key.
/// </summary>
/// <param name="cacheKey">Key of the changed version.</param>
[Obsolete("Use version that takes newVersion, scheduled for removal in V19")]
void VersionChanged(string cacheKey)
{ }
/// <summary>
/// Notifies of a version change on a given cache key, providing the new version so internal caches
/// can be updated in-place without a database round-trip.
/// </summary>
/// <param name="cacheKey">Key of the changed version.</param>
/// <param name="newVersion">The new version GUID that was just written to the database.</param>
void VersionChanged(string cacheKey, Guid newVersion)
{
VersionChanged(cacheKey);
}
/// <summary>
/// Notifies the accessor that caches have been synchronized.
/// </summary>
@@ -1,12 +0,0 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Defines an asynchronous handler for a <typeparamref name="TNotification" /> that should be invoked when notifications are dispatched in a distributed cache scope (e.g. to trigger a distributed cache refresher).
/// </summary>
/// <typeparam name="TNotification">The type of the notification.</typeparam>
public interface IDistributedCacheAsyncNotificationHandler<in TNotification> : INotificationAsyncHandler<TNotification>, IDistributedCacheNotificationHandler
where TNotification : INotification
{ }
@@ -428,15 +428,15 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
{
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
_documentUrlService.UpdateUrlSegmentCacheAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.UpdateAliasCacheAsync(key).GetAwaiter().GetResult();
_documentUrlService.CreateOrUpdateUrlSegmentsAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.CreateOrUpdateAliasesAsync(key).GetAwaiter().GetResult();
}
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
{
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
_documentUrlService.UpdateUrlSegmentCacheWithDescendantsAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.UpdateAliasCacheWithDescendantsAsync(key).GetAwaiter().GetResult();
_documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(key).GetAwaiter().GetResult();
_documentUrlAliasService.CreateOrUpdateAliasesWithDescendantsAsync(key).GetAwaiter().GetResult();
}
}
@@ -131,8 +131,8 @@ public sealed class ElementCacheRefresher : PayloadCacheRefresherBase<ElementCac
// By INT Id
isolatedCache.Clear(RepositoryCacheKeys.GetKey<IElement, int>(payload.Id));
// By GUID Key (GUID-keyed read repository uses a separate "uRepoGuid_" prefix)
isolatedCache.Clear(RepositoryCacheKeys.GetGuidKey<IElement>(payload.Key));
// By GUID Key
isolatedCache.Clear(RepositoryCacheKeys.GetKey<IElement, Guid?>(payload.Key));
HandleMemoryCache(payload);
HandlePublishStatusAsync(payload, CancellationToken.None).GetAwaiter().GetResult();
@@ -78,10 +78,8 @@ public sealed class UserCacheRefresher : PayloadCacheRefresherBase<UserCacheRefr
userCache.Result?.Clear(RepositoryCacheKeys.GetKey<IUser, int>(p.Id));
userCache.Result?.ClearByKey(CacheKeys.UserContentStartNodePathsPrefix + p.Key);
userCache.Result?.ClearByKey(CacheKeys.UserMediaStartNodePathsPrefix + p.Key);
userCache.Result?.ClearByKey(CacheKeys.UserElementStartNodePathsPrefix + p.Key);
userCache.Result?.ClearByKey(CacheKeys.UserAllContentStartNodesPrefix + p.Key);
userCache.Result?.ClearByKey(CacheKeys.UserAllMediaStartNodesPrefix + p.Key);
userCache.Result?.ClearByKey(CacheKeys.UserAllElementStartNodesPrefix + p.Key);
}
}
}
@@ -1,6 +1,5 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Collections;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
@@ -15,7 +14,6 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
private readonly ILogger<RepositoryCacheVersionService> _logger;
private readonly IRepositoryCacheVersionAccessor _repositoryCacheVersionAccessor;
private readonly ConcurrentDictionary<string, Guid> _cacheVersions = new();
private readonly ConcurrentDictionary<Guid, ConcurrentHashSet<string>> _writtenKeysByScope = new();
/// <summary>
/// Initializes a new instance of the <see cref="RepositoryCacheVersionService" /> class.
@@ -46,6 +44,7 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
var cacheKey = GetCacheKey<TEntity>();
// The cache version accessor will take a read lock if the version is not in request cache, so we don't need to take one here.
RepositoryCacheVersion? databaseVersion = await _repositoryCacheVersionAccessor.GetAsync(cacheKey);
if (databaseVersion?.Version is null)
@@ -85,23 +84,18 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
public async Task SetCacheUpdatedAsync<TEntity>()
where TEntity : class
{
string cacheKey = GetCacheKey<TEntity>();
ConcurrentHashSet<string>? writtenKeys = GetOrRegisterScopeWrittenKeys();
if (writtenKeys?.TryAdd(cacheKey) is false)
{
_logger.LogDebug("Cache version for {EntityType} already written in this scope, skipping", typeof(TEntity).Name);
return;
}
using ICoreScope scope = _scopeProvider.CreateCoreScope();
// We have to take a write lock to ensure the cache is not being read while we update the version.
scope.WriteLock(Constants.Locks.CacheVersion);
var cacheKey = GetCacheKey<TEntity>();
var newVersion = Guid.NewGuid();
_logger.LogDebug("Setting cache for {EntityType} to version {Version}", typeof(TEntity).Name, newVersion);
await _repositoryCacheVersionRepository.SaveAsync(new RepositoryCacheVersion { Identifier = cacheKey, Version = newVersion.ToString() });
_cacheVersions[cacheKey] = newVersion;
_repositoryCacheVersionAccessor.VersionChanged(cacheKey, newVersion);
_repositoryCacheVersionAccessor.VersionChanged(cacheKey);
scope.Complete();
}
@@ -110,6 +104,7 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
public async Task SetCachesSyncedAsync()
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
scope.ReadLock(Constants.Locks.CacheVersion);
// We always sync all caches versions, so it's safe to assume all caches are synced at this point.
IEnumerable<RepositoryCacheVersion> cacheVersions = await _repositoryCacheVersionRepository.GetAllAsync();
@@ -136,22 +131,4 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
internal string GetCacheKey<TEntity>()
where TEntity : class =>
typeof(TEntity).FullName ?? typeof(TEntity).Name;
private ConcurrentHashSet<string>? GetOrRegisterScopeWrittenKeys()
{
IScopeContext? context = _scopeProvider.Context;
if (context is null)
{
return null;
}
Guid contextId = context.InstanceId;
ConcurrentHashSet<string> writtenKeys = _writtenKeysByScope.GetOrAdd(contextId, _ => new ConcurrentHashSet<string>());
context.Enlist(
$"RepositoryCacheVersionService_{contextId}",
completed => _writtenKeysByScope.TryRemove(contextId, out _));
return writtenKeys;
}
}
+9 -5
View File
@@ -49,7 +49,7 @@ public sealed class TypeLoader
public ITypeFinder TypeFinder { get; }
/// <summary>
/// Gets the set of assemblies to scan.
/// Gets or sets the set of assemblies to scan.
/// </summary>
/// <remarks>
/// <para>
@@ -58,15 +58,19 @@ public sealed class TypeLoader
/// assemblies
/// for example.
/// </para>
/// <para>Marked as internal as used only for unit tests.</para>
/// <para>This is for unit tests.</para>
/// </remarks>
internal IEnumerable<Assembly> AssembliesToScan => _assemblies ??= TypeFinder.AssembliesToScan;
// internal for tests
[Obsolete("Scheduled for removal in Umbraco 18.")]
public IEnumerable<Assembly> AssembliesToScan => _assemblies ??= TypeFinder.AssembliesToScan;
/// <summary>
/// Gets the type lists.
/// </summary>
/// <remarks>Marked as internal as used only for unit tests.</remarks>
internal IEnumerable<TypeList> TypeLists => _types.Values;
/// <remarks>For unit tests.</remarks>
// internal for tests
[Obsolete("Scheduled for removal in Umbraco 18.")]
public IEnumerable<TypeList> TypeLists => _types.Values;
#region Get Assembly Attributes
@@ -36,7 +36,6 @@ public interface IConfigManipulator
/// </summary>
/// <param name="disable">The value to save.</param>
/// <returns></returns>
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
Task SaveDisableRedirectUrlTrackingAsync(bool disable);
/// <summary>
@@ -6,7 +6,7 @@ namespace Umbraco.Cms.Core.Configuration.Models;
/// Typed configuration options for back-office token cookie settings.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigBackOfficeTokenCookie)]
[Obsolete("This will be replaced with a different authentication scheme when the BFF project is complete. Scheduled for removal in Umbraco 19.")]
[Obsolete("This will be replaced with a different authentication scheme. Scheduled for removal in Umbraco 18.")]
public class BackOfficeTokenCookieSettings
{
private const string StaticSameSite = "Strict";
@@ -1,38 +0,0 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Typed configuration options for SignalR settings.
/// </summary>
/// <remarks>
/// <para>
/// When <see cref="ClientShouldSkipNegotiation"/> is enabled, all hub endpoints are restricted
/// to WebSocket transport and the client skips the negotiate round-trip. The setting is forwarded
/// to the client via the <c>/umbraco/management/api/v1/server/configuration</c> endpoint.
/// </para>
/// <para>
/// Downstream packages (e.g. Umbraco Cloud) can configure these settings via
/// <c>IConfigureOptions&lt;SignalRSettings&gt;</c> or <c>appsettings.json</c>
/// under <c>Umbraco:CMS:SignalR</c>.
/// </para>
/// </remarks>
[UmbracoOptions(Constants.Configuration.ConfigSignalR)]
public class SignalRSettings
{
internal const bool StaticClientShouldSkipNegotiation = false;
/// <summary>
/// Gets or sets a value indicating whether the client should skip the SignalR negotiate
/// round-trip and connect directly via WebSockets.
/// </summary>
/// <remarks>
/// When <c>true</c>, the server restricts all hub endpoints to the WebSocket transport only
/// (via <c>HttpConnectionDispatcherOptions.Transports</c>) and the client is instructed to
/// set <c>skipNegotiation = true</c> with <c>transport = WebSockets</c>. This eliminates the
/// negotiate HTTP request that causes failures in load-balanced deployments without sticky sessions.
/// This is safe for self-hosted SignalR but must <b>not</b> be used with Azure SignalR Service.
/// </remarks>
[DefaultValue(StaticClientShouldSkipNegotiation)]
public bool ClientShouldSkipNegotiation { get; set; } = StaticClientShouldSkipNegotiation;
}
@@ -16,7 +16,6 @@ public class UnattendedSettings
private const bool StaticInstallUnattended = false;
private const bool StaticUpgradeUnattended = false;
private const TelemetryLevel StaticTelemetryLevel = TelemetryLevel.Detailed;
private const string StaticMigrationClaimTimeout = "02:00:00";
/// <summary>
/// Gets or sets a value indicating whether unattended installs are enabled.
@@ -46,17 +45,6 @@ public class UnattendedSettings
/// </remarks>
public bool PackageMigrationsUnattended { get; set; } = true;
/// <summary>
/// Gets or sets the maximum time a migration leadership claim is considered valid before
/// another server may take over. Protects against a leader crashing mid-migration.
/// </summary>
/// <remarks>
/// Only relevant in load-balanced deployments with <see cref="UpgradeUnattended"/> enabled.
/// Default is 2 hours, which should exceed the longest reasonable migration run time.
/// </remarks>
[DefaultValue(StaticMigrationClaimTimeout)]
public TimeSpan MigrationClaimTimeout { get; set; } = TimeSpan.Parse(StaticMigrationClaimTimeout);
/// <summary>
/// Gets or sets a value to use for creating a user with a name for Unattended Installs
/// </summary>
@@ -302,11 +302,6 @@ public static partial class Constants
/// </summary>
public const string ConfigWebsite = ConfigPrefix + "Website";
/// <summary>
/// The configuration key for SignalR settings.
/// </summary>
public const string ConfigSignalR = ConfigPrefix + "SignalR";
/// <summary>
/// Contains constants for named options used in configuration.
/// </summary>
@@ -36,14 +36,6 @@ public static partial class Constants
/// The key used to store the Umbraco pre-migrations upgrade plan state.
/// </summary>
public const string UmbracoUpgradePlanPremigrationsKey = KeyValuePrefix + UmbracoUpgradePlanPremigrationsName;
/// <summary>
/// The key used to coordinate migration leadership across servers in a load-balanced
/// environment. The value is either empty (no active leader) or
/// <c>"{machineIdentifier}|{claimedAtUtc:O}"</c> when a server holds the claim,
/// where <c>machineIdentifier</c> is the value returned by <see cref="Umbraco.Cms.Core.Factories.IMachineInfoFactory.GetMachineIdentifier"/>.
/// </summary>
public const string UpgradeLockKey = "Umbraco.Core.Upgrader.Lock";
}
/// <summary>
@@ -1,15 +0,0 @@
namespace Umbraco.Cms.Core.DependencyInjection;
/// <summary>
/// Marker interface indicating that Umbraco itself has enabled ASP.NET Core output caching
/// (via Website template caching or Delivery API caching configuration).
/// Used to gate Umbraco's automatic registration of the output cache middleware so that
/// applications calling <c>services.AddOutputCache(...)</c> for their own purposes do not
/// inadvertently trigger a duplicate <c>UseOutputCache()</c> registration.
/// </summary>
public interface IUmbracoManagedOutputCacheMarker { }
/// <summary>
/// Marker class implementation for <see cref="IUmbracoManagedOutputCacheMarker"/>.
/// </summary>
public sealed class UmbracoManagedOutputCacheMarker : IUmbracoManagedOutputCacheMarker { }
@@ -101,8 +101,7 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<SystemDateMigrationSettings>()
.AddUmbracoOptions<DistributedJobSettings>()
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
.AddUmbracoOptions<WebsiteSettings>()
.AddUmbracoOptions<SignalRSettings>();
.AddUmbracoOptions<WebsiteSettings>();
// Configure connection string and ensure it's updated when the configuration changes
builder.Services.AddSingleton<IConfigureOptions<ConnectionStrings>, ConfigureConnectionStrings>();
@@ -312,7 +312,6 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IContentPermissionService, ContentPermissionService>();
Services.AddUnique<IDictionaryPermissionService, DictionaryPermissionService>();
Services.AddUnique<IElementPermissionService, ElementPermissionService>();
Services.AddUnique<IElementContainerPermissionService, ElementContainerPermissionService>();
Services.AddUnique<IContentService, ContentService>();
Services.AddUnique<IElementService, ElementService>();
Services.AddUnique<IElementVersionService, ElementVersionService>();
@@ -337,6 +336,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IMediaTypeService, MediaTypeService>();
Services.AddUnique<IContentTypeEditingService, ContentTypeEditingService>();
Services.AddUnique<IMediaTypeEditingService, MediaTypeEditingService>();
Services.AddUnique<IFileService, FileService>();
Services.AddUnique<ITemplateService, TemplateService>();
Services.AddUnique<IScriptService, ScriptService>();
Services.AddUnique<IStylesheetService, StylesheetService>();
@@ -452,7 +452,6 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddSingleton<IContentPermissionAuthorizer, ContentPermissionAuthorizer>();
Services.AddSingleton<IDictionaryPermissionAuthorizer, DictionaryPermissionAuthorizer>();
Services.AddSingleton<IElementPermissionAuthorizer, ElementPermissionAuthorizer>();
Services.AddSingleton<IElementContainerPermissionAuthorizer, ElementContainerPermissionAuthorizer>();
Services.AddSingleton<IFeatureAuthorizer, FeatureAuthorizer>();
Services.AddSingleton<IMediaPermissionAuthorizer, MediaPermissionAuthorizer>();
Services.AddSingleton<IUserGroupPermissionAuthorizer, UserGroupPermissionAuthorizer>();
@@ -476,7 +475,6 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IDocumentUrlAliasService, DocumentUrlAliasService>();
Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlAliasServiceInitializerNotificationHandler>();
Services.AddNotificationAsyncHandler<ContentTypeChangedNotification, DocumentUrlServiceContentTypeChangedNotificationHandler>();
Services.AddNotificationAsyncHandler<ContentTreeChangeNotification, DocumentUrlServiceContentTreeChangeNotificationHandler>();
}
}
}
+9 -16
View File
@@ -13,10 +13,11 @@ public class MoveEventInfo<TEntity> : MoveEventInfoBase<TEntity>
/// <param name="originalPath">The original path of the entity.</param>
/// <param name="newParentId">The identifier of the new parent.</param>
/// <param name="newParentKey">The unique identifier of the new parent.</param>
[Obsolete("Use the overload without the newParentId parameter instead. Scheduled for removal in v19.")]
public MoveEventInfo(TEntity entity, string originalPath, int newParentId, Guid? newParentKey)
: this(entity, originalPath, newParentKey)
: base(entity, originalPath)
{
NewParentId = newParentId;
NewParentKey = newParentKey;
}
/// <summary>
@@ -25,23 +26,15 @@ public class MoveEventInfo<TEntity> : MoveEventInfoBase<TEntity>
/// <param name="entity">The entity being moved.</param>
/// <param name="originalPath">The original path of the entity.</param>
/// <param name="newParentId">The identifier of the new parent.</param>
[Obsolete("Use the overload with the newParentKey parameter instead. Scheduled for removal in v19.")]
public MoveEventInfo(TEntity entity, string originalPath, int newParentId)
: this(entity, originalPath, null)
public MoveEventInfo(TEntity entity, string originalPath, int newParentId) : this(entity, originalPath, newParentId, null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="MoveEventInfo{TEntity}" /> class.
/// Gets or sets the identifier of the new parent.
/// </summary>
/// <param name="entity">The entity being moved.</param>
/// <param name="originalPath">The original path of the entity.</param>
/// <param name="newParentKey">The unique identifier of the new parent.</param>
public MoveEventInfo(TEntity entity, string originalPath, Guid? newParentKey)
: base(entity, originalPath)
{
NewParentKey = newParentKey;
}
[Obsolete("Please use NewParentKey instead. Scheduled for removal in Umbraco 18.")]
public int NewParentId { get; set; }
/// <summary>
/// Gets the unique identifier of the new parent.
@@ -64,7 +57,7 @@ public class MoveEventInfo<TEntity> : MoveEventInfoBase<TEntity>
/// </summary>
/// <param name="other">The other instance to compare.</param>
/// <returns><c>true</c> if the instances are equal; otherwise, <c>false</c>.</returns>
public bool Equals(MoveEventInfo<TEntity>? other) => NewParentKey == other?.NewParentKey && base.Equals(other);
public bool Equals(MoveEventInfo<TEntity>? other) => NewParentId == other?.NewParentId && NewParentKey == other.NewParentKey && base.Equals(other);
/// <inheritdoc />
public override int GetHashCode()
@@ -74,7 +67,7 @@ public class MoveEventInfo<TEntity> : MoveEventInfoBase<TEntity>
var hashCode = Entity is not null
? EqualityComparer<TEntity>.Default.GetHashCode(Entity)
: base.GetHashCode();
hashCode = (hashCode * 397) ^ NewParentKey.GetHashCode();
hashCode = (hashCode * 397) ^ NewParentId;
hashCode = (hashCode * 397) ^ OriginalPath.GetHashCode();
return hashCode;
}
@@ -196,14 +196,14 @@ public static class PublishedContentExtensions
/// Returns the current template Alias
/// </summary>
/// <returns>Empty string if none is set.</returns>
public static string GetTemplateAlias(this IPublishedContent content, ITemplateService templateService)
public static string GetTemplateAlias(this IPublishedContent content, IFileService fileService)
{
if (content.TemplateId.HasValue == false)
{
return string.Empty;
}
ITemplate? template = templateService.GetAsync(content.TemplateId.Value).GetAwaiter().GetResult();
ITemplate? template = fileService.GetTemplate(content.TemplateId.Value);
return template?.Alias ?? string.Empty;
}
@@ -253,15 +253,15 @@ public static class PublishedContentExtensions
/// Determines whether a specific template is allowed for the content item by template alias.
/// </summary>
/// <param name="content">The content item.</param>
/// <param name="templateService">The template service.</param>
/// <param name="fileService">The file service.</param>
/// <param name="contentTypeService">The content type service.</param>
/// <param name="disableAlternativeTemplates">Whether alternative templates are disabled.</param>
/// <param name="validateAlternativeTemplates">Whether to validate alternative templates against allowed templates.</param>
/// <param name="templateAlias">The template alias.</param>
/// <returns><c>true</c> if the template is allowed; otherwise, <c>false</c>.</returns>
public static bool IsAllowedTemplate(this IPublishedContent content, ITemplateService templateService, IContentTypeService contentTypeService, bool disableAlternativeTemplates, bool validateAlternativeTemplates, string templateAlias)
public static bool IsAllowedTemplate(this IPublishedContent content, IFileService fileService, IContentTypeService contentTypeService, bool disableAlternativeTemplates, bool validateAlternativeTemplates, string templateAlias)
{
ITemplate? template = templateService.GetAsync(templateAlias).GetAwaiter().GetResult();
ITemplate? template = fileService.GetTemplate(templateAlias);
return template != null && content.IsAllowedTemplate(contentTypeService, disableAlternativeTemplates, validateAlternativeTemplates, template.Id);
}
@@ -2141,9 +2141,9 @@ public static class PublishedContentExtensions
// with a non-existing published node, will get cache misses and call the DB
// making it a very slow operation.
// INavigationQueryService.TryGetChildrenKeys returns keys already ordered by SortOrder
// and FilterAvailable preserves enumeration order, so no further OrderBy is needed.
return publishedStatusFilteringService.FilterAvailable(childrenKeys, culture);
return publishedStatusFilteringService
.FilterAvailable(childrenKeys, culture)
.OrderBy(x => x.SortOrder);
}
private static IEnumerable<IPublishedContent> EnumerateDescendantsOrSelfInternal(
@@ -106,7 +106,6 @@ public static class UdiGetterExtensions
return entity switch
{
IContent content => content.GetUdi(),
IElement element => element.GetUdi(),
IMedia media => media.GetUdi(),
IMember member => member.GetUdi(),
_ => throw new NotSupportedException($"Content base type {entity.GetType().FullName} is not supported."),
@@ -129,20 +128,6 @@ public static class UdiGetterExtensions
return new GuidUdi(entityType, entity.Key).EnsureClosed();
}
/// <summary>
/// Gets the entity identifier of the entity.
/// </summary>
/// <param name="entity">The entity.</param>
/// <returns>
/// The entity identifier of the entity.
/// </returns>
public static GuidUdi GetUdi(this IElement entity)
{
ArgumentNullException.ThrowIfNull(entity);
return new GuidUdi(Constants.UdiEntityType.Element, entity.Key).EnsureClosed();
}
/// <summary>
/// Gets the entity identifier of the entity.
/// </summary>
@@ -106,7 +106,7 @@ public interface IHostingEnvironment
/// content root are the same, however
/// in netcore the web root is /www therefore this will Map to a physical path within www.
/// </remarks>
[Obsolete("Please use the MapPathWebRoot extension method on an instance of IWebHostEnvironment instead. Scheduled for removal in Umbraco 20.")]
[Obsolete("Please use the MapPathWebRoot extension method on an instance of IWebHostEnvironment instead")]
string MapPathWebRoot(string path);
/// <summary>
@@ -118,7 +118,7 @@ public interface IHostingEnvironment
/// in netcore the web root is /www therefore this will Map to a physical path within www.
/// </remarks>
[Obsolete(
"Please use the MapPathContentRoot extension method on an instance of IHostEnvironment (or IWebHostEnvironment) instead. Scheduled for removal in Umbraco 20.")]
"Please use the MapPathContentRoot extension method on an instance of IHostEnvironment (or IWebHostEnvironment) instead")]
string MapPathContentRoot(string path);
/// <summary>
+32 -63
View File
@@ -36,14 +36,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// <summary>
/// Gets the dictionary of shadow nodes tracking file and directory changes.
/// </summary>
/// <remarks>
/// Uses <see cref="StringComparer.OrdinalIgnoreCase"/> so the shadow exposes case-insensitive
/// path semantics (matching Windows file system behavior) while preserving the original case
/// of paths. Preserving case is required for <see cref="Complete"/>: the stored key is also
/// used to locate the shadow file via <c>_sfs.GetFullPath</c>, which on case-sensitive
/// file systems (e.g. Linux) must match the case the file was actually written with.
/// </remarks>
private Dictionary<string, ShadowNode> Nodes => _nodes ??= new Dictionary<string, ShadowNode>(StringComparer.OrdinalIgnoreCase);
private Dictionary<string, ShadowNode> Nodes => _nodes ??= new Dictionary<string, ShadowNode>();
/// <inheritdoc />
public IEnumerable<string> GetDirectories(string path)
@@ -73,7 +66,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
var normPath = NormPath(path);
if (recursive)
{
Nodes[normPath] = new ShadowNode(true, true, normPath);
Nodes[normPath] = new ShadowNode(true, true);
var remove = Nodes.Where(x => IsDescendant(normPath, x.Key)).ToList();
foreach (KeyValuePair<string, ShadowNode> kvp in remove)
{
@@ -91,7 +84,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Directory is not empty.");
}
Nodes[normPath] = new ShadowNode(true, true, normPath);
Nodes[path] = new ShadowNode(true, true);
var remove = Nodes.Where(x => IsChild(normPath, x.Key)).ToList();
foreach (KeyValuePair<string, ShadowNode> kvp in remove)
{
@@ -138,7 +131,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
if (sd.IsDelete)
{
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
else
@@ -153,13 +146,12 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
var canonicalPath = sf?.CanonicalPath ?? path;
_sfs.AddFile(canonicalPath, stream, overrideIfExists);
Nodes[normPath] = new ShadowNode(false, false, canonicalPath);
_sfs.AddFile(path, stream, overrideIfExists);
Nodes[normPath] = new ShadowNode(false, false);
}
/// <inheritdoc />
@@ -186,7 +178,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
if (Nodes.TryGetValue(NormPath(path), out ShadowNode? sf))
{
return sf.IsDir || sf.IsDelete ? Stream.Null : _sfs.OpenFile(sf.CanonicalPath);
return sf.IsDir || sf.IsDelete ? Stream.Null : _sfs.OpenFile(path);
}
return Inner.OpenFile(path);
@@ -200,8 +192,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
return;
}
var normPath = NormPath(path);
Nodes[normPath] = new ShadowNode(true, false, normPath);
Nodes[NormPath(path)] = new ShadowNode(true, false);
}
/// <inheritdoc />
@@ -235,7 +226,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
if (sd.IsDelete)
{
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
else
@@ -250,15 +241,13 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
var sourceCanonical = sf?.CanonicalPath ?? normSource;
var targetCanonical = tf?.CanonicalPath ?? normTarget;
_sfs.MoveFile(sourceCanonical, targetCanonical, overrideIfExists);
Nodes[normSource] = new ShadowNode(true, false, sourceCanonical);
Nodes[normTarget] = new ShadowNode(false, false, targetCanonical);
_sfs.MoveFile(normSource, normTarget, overrideIfExists);
Nodes[normSource] = new ShadowNode(true, false);
Nodes[normTarget] = new ShadowNode(false, false);
}
/// <inheritdoc />
@@ -280,7 +269,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
if (Nodes.TryGetValue(NormPath(path), out ShadowNode? sf))
{
return sf.IsDir || sf.IsDelete ? string.Empty : _sfs.GetFullPath(sf.CanonicalPath);
return sf.IsDir || sf.IsDelete ? string.Empty : _sfs.GetFullPath(path);
}
return Inner.GetFullPath(path);
@@ -302,7 +291,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
return _sfs.GetLastModified(sf.CanonicalPath);
return _sfs.GetLastModified(path);
}
/// <inheritdoc />
@@ -318,7 +307,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
return _sfs.GetCreated(sf.CanonicalPath);
return _sfs.GetCreated(path);
}
/// <inheritdoc />
@@ -334,7 +323,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
return _sfs.GetSize(sf.CanonicalPath);
return _sfs.GetSize(path);
}
/// <inheritdoc />
@@ -359,7 +348,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
if (sd.IsDelete)
{
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
else
@@ -374,13 +363,12 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
Nodes[dirPath] = new ShadowNode(false, true);
}
}
var canonicalPath = sf?.CanonicalPath ?? path;
_sfs.AddFile(canonicalPath, physicalPath, overrideIfExists, copy);
Nodes[normPath] = new ShadowNode(false, false, canonicalPath);
_sfs.AddFile(path, physicalPath, overrideIfExists, copy);
Nodes[normPath] = new ShadowNode(false, false);
}
/// <summary>
@@ -405,11 +393,11 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
if (Inner.CanAddPhysical)
{
Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Value.CanonicalPath)); // overwrite, move
Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Key)); // overwrite, move
}
else
{
using (Stream stream = _sfs.OpenFile(kvp.Value.CanonicalPath))
using (Stream stream = _sfs.OpenFile(kvp.Key))
{
Inner.AddFile(kvp.Key, stream, true);
}
@@ -453,15 +441,11 @@ internal sealed partial class ShadowFileSystem : IFileSystem
}
/// <summary>
/// Normalizes a path's directory separators to forward slashes.
/// Normalizes a path to lowercase with forward slashes.
/// </summary>
/// <param name="path">The path to normalize.</param>
/// <returns>The normalized path.</returns>
/// <remarks>
/// Case is preserved. Case-insensitive matching is handled by <see cref="Nodes"/>'s
/// <see cref="StringComparer.OrdinalIgnoreCase"/> comparer.
/// </remarks>
private static string NormPath(string path) => path.Replace("\\", "/");
private static string NormPath(string path) => path.ToLowerInvariant().Replace("\\", "/");
/// <summary>
/// Determines whether the input path is a direct child of the specified path.
@@ -472,7 +456,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// <remarks>Values can be "" (root), "foo", "foo/bar"...</remarks>
private static bool IsChild(string path, string input)
{
if (input.StartsWith(path, StringComparison.OrdinalIgnoreCase) == false || input.Length < path.Length + 2)
if (input.StartsWith(path) == false || input.Length < path.Length + 2)
{
return false;
}
@@ -482,7 +466,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
return false;
}
var pos = input.IndexOf('/', path.Length + 1);
var pos = input.IndexOf("/", path.Length + 1, StringComparison.OrdinalIgnoreCase);
return pos < 0;
}
@@ -494,7 +478,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// <returns><c>true</c> if input is a descendant of path; otherwise, <c>false</c>.</returns>
private static bool IsDescendant(string path, string input)
{
if (input.StartsWith(path, StringComparison.OrdinalIgnoreCase) == false || input.Length < path.Length + 2)
if (input.StartsWith(path) == false || input.Length < path.Length + 2)
{
return false;
}
@@ -511,14 +495,12 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
foreach (var file in Inner.GetFiles(path))
{
var normFile = NormPath(file);
Nodes[normFile] = new ShadowNode(true, false, normFile);
Nodes[NormPath(file)] = new ShadowNode(true, false);
}
foreach (var dir in Inner.GetDirectories(path))
{
var normDir = NormPath(dir);
Nodes[normDir] = new ShadowNode(true, true, normDir);
Nodes[NormPath(dir)] = new ShadowNode(true, true);
if (recurse)
{
Delete(dir, true);
@@ -630,12 +612,10 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// </summary>
/// <param name="isDelete">Whether this node represents a deletion.</param>
/// <param name="isdir">Whether this node represents a directory.</param>
/// <param name="canonicalPath">The original-case path tracked by this node.</param>
public ShadowNode(bool isDelete, bool isdir, string canonicalPath)
public ShadowNode(bool isDelete, bool isdir)
{
IsDelete = isDelete;
IsDir = isdir;
CanonicalPath = canonicalPath;
}
/// <summary>
@@ -648,17 +628,6 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// </summary>
public bool IsDir { get; }
/// <summary>
/// Gets the original-case path tracked by this node. For existing-file nodes this is
/// the path used the first time the file was staged in the current shadow scope.
/// </summary>
/// <remarks>
/// All operations against the inner shadow file system (<c>_sfs</c>) must use this
/// path so that re-staging the same logical path with a different case still reaches
/// the same on-disk file on case-sensitive file systems (e.g. Linux).
/// </remarks>
public string CanonicalPath { get; }
/// <summary>
/// Gets a value indicating whether this node represents an existing item (not deleted).
/// </summary>
@@ -64,8 +64,4 @@ public class BlockGridLayoutItem : BlockLayoutItemBase
/// <inheritdoc />
public override bool ReferencesSetting(Guid key)
=> SettingsKey == key || Areas.Any(area => area.ContainsSetting(key));
/// <inheritdoc />
public override IEnumerable<IBlockLayoutItem> GetContainedLayouts()
=> Areas.SelectMany(area => area.Items);
}
@@ -5,18 +5,12 @@ namespace Umbraco.Cms.Core.Models.Blocks;
/// </summary>
public abstract class BlockLayoutItemBase : IBlockLayoutItem
{
/// <inheritdoc />
public Guid Key { get; set; }
/// <inheritdoc />
public Guid ContentKey { get; set; }
/// <inheritdoc />
public Guid? SettingsKey { get; set; }
/// <inheritdoc />
public bool IsExternalContent { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="BlockLayoutItemBase" /> class.
/// </summary>
@@ -50,7 +44,4 @@ public abstract class BlockLayoutItemBase : IBlockLayoutItem
/// <inheritdoc />
public virtual bool ReferencesSetting(Guid key)
=> SettingsKey == key;
/// <inheritdoc />
public virtual IEnumerable<IBlockLayoutItem> GetContainedLayouts() => [];
}
+1 -1
View File
@@ -55,7 +55,7 @@ public abstract class BlockValue
/// <returns>
/// <c>true</c> if the specified block layout alias is supported; otherwise, <c>false</c>.
/// </returns>
[Obsolete("Scheduled for removal in Umbraco 19.")]
[Obsolete("Scheduled for removal in Umbraco 18.")]
public virtual bool SupportsBlockLayoutAlias(string alias) => alias.Equals(PropertyEditorAlias);
}
@@ -8,18 +8,6 @@ namespace Umbraco.Cms.Core.Models.Blocks;
/// </summary>
public interface IBlockLayoutItem
{
/// <summary>
/// Gets or sets the layout item key.
/// </summary>
/// <value>
/// The layout item key.
/// </value>
/// <remarks>
/// Uniquely identifies a layout item. Previously the <see cref="ContentKey"/> could be used for this, but
/// with reusable elements, the same <see cref="ContentKey"/> can appear multiple times in one layout.
/// </remarks>
public Guid Key { get; set; }
/// <summary>
/// Gets or sets the content key.
/// </summary>
@@ -36,11 +24,6 @@ public interface IBlockLayoutItem
/// </value>
public Guid? SettingsKey { get; set; }
/// <summary>
/// Indicates if the content source is local or originates from the element service.
/// </summary>
public bool IsExternalContent { get; set; }
/// <summary>
/// Determines whether this layout item references the specified content key.
/// </summary>
@@ -58,10 +41,4 @@ public interface IBlockLayoutItem
/// <c>true</c> if this layout item references the specified settings key; otherwise, <c>false</c>.
/// </returns>
public bool ReferencesSetting(Guid key) => SettingsKey == key;
/// <summary>
/// Returns any nested layouts for this layout (e.g. area layouts for the Block Grid).
/// </summary>
/// <returns>The nested layouts.</returns>
public IEnumerable<IBlockLayoutItem> GetContainedLayouts();
}
@@ -454,24 +454,7 @@ public static class ContentRepositoryExtensions
/// Clears all publish culture information from the content item.
/// </summary>
/// <param name="content">The content item to clear publish information from.</param>
public static void ClearPublishInfos(this IPublishableContentBase content)
{
if (content.PublishCultureInfos is null)
{
return;
}
// Pass each published culture through ClearPublishInfo([culture]) to ensure correct change tracking.
var cultures = content.PublishCultureInfos.Values.Select(c => c.Culture).ToArray();
foreach (var culture in cultures)
{
content.ClearPublishInfo(culture);
}
// Following #22799 the explicit calls to `ClearPublishInfo` for each culture cause the unpublish in all cultures.
// `PublishCultureInfos` is set to null purely to retain previous behaviour at a property level.
content.PublishCultureInfos = null;
}
public static void ClearPublishInfos(this IPublishableContentBase content) => content.PublishCultureInfos = null;
/// <summary>
/// Returns false if the culture is already unpublished
@@ -8,25 +8,7 @@ namespace Umbraco.Cms.Core.Models.Navigation;
/// </summary>
public sealed class NavigationNode
{
private static readonly Comparison<(Guid Key, int SortOrder)> _sortBySortOrder =
static (a, b) => a.SortOrder.CompareTo(b.SortOrder);
private readonly ConcurrentHashSet<Guid> _children;
/// <summary>
/// Cached snapshot of <see cref="Children"/> ordered by each child's <c>SortOrder</c>.
/// </summary>
/// <remarks>
/// Built lazily by <see cref="GetOrderedChildren"/> on first access and invalidated
/// (set to <c>null</c>) by <see cref="AddChild"/> / <see cref="RemoveChild"/> /
/// <see cref="InvalidateOrderedChildren"/>. Reads are lock-free on the fast path; the
/// build and invalidation paths take <see cref="_orderedChildrenLock"/> so concurrent
/// first-access threads agree on a single canonical array and an in-flight build
/// cannot finish after a concurrent invalidation has cleared it.
/// </remarks>
private Guid[]? _orderedChildren;
private readonly Lock _orderedChildrenLock = new();
private ConcurrentHashSet<Guid> _children;
/// <summary>
/// Gets the unique key of this navigation node.
@@ -71,17 +53,6 @@ public sealed class NavigationNode
/// Updates the sort order of this node.
/// </summary>
/// <param name="newSortOrder">The new sort order value.</param>
/// <remarks>
/// The parent node's cached ordered-children list (if any) is now stale because it sorts
/// by child <c>SortOrder</c>. Callers that hold a reference to the parent should call
/// <see cref="InvalidateOrderedChildren"/> on it; <see cref="NavigationNode"/> does not
/// hold a reference to its parent <see cref="NavigationNode"/> so cannot invalidate it
/// itself.
/// </remarks>
// TODO (V19): Make internal. The contract requires the caller to invalidate the parent's
// ordered-children cache (InvalidateOrderedChildren is internal, so external callers cannot
// satisfy that contract and would silently observe stale ordering on subsequent reads).
// Internal callers in ContentNavigationServiceBase already do the invalidation correctly.
public void UpdateSortOrder(int newSortOrder) => SortOrder = newSortOrder;
/// <summary>
@@ -103,8 +74,6 @@ public sealed class NavigationNode
child.SortOrder = _children.Count;
_children.Add(childKey);
InvalidateOrderedChildren();
}
/// <summary>
@@ -122,91 +91,5 @@ public sealed class NavigationNode
_children.Remove(childKey);
child.Parent = null;
InvalidateOrderedChildren();
}
/// <summary>
/// Returns this node's children ordered by <c>SortOrder</c>.
/// </summary>
/// <param name="navigationStructure">The navigation structure dictionary containing all nodes; needed to look up each child's current <c>SortOrder</c>.</param>
/// <returns>An immutable, sort-order-presorted snapshot of the children. The result is cached and reused across calls until the children set or a child's <c>SortOrder</c> is mutated.</returns>
/// <remarks>
/// Lock-free fast path: a non-null cached array is returned without acquiring the lock.
/// If the cache is empty, <see cref="BuildOrderedChildren"/> is called under the lock to
/// build (with double-checked re-read) and store the canonical array.
/// </remarks>
internal IReadOnlyList<Guid> GetOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
{
// Volatile.Read provides the acquire fence that pairs with the release fence on the
// lock-protected stores in BuildOrderedChildren / InvalidateOrderedChildren. On weak
// memory architectures (e.g. ARM64) a plain read can observe writes out of order with
// the lock release, so without this barrier a reader could in principle see a torn or
// unpublished reference; on x86/x64 the TSO model already gives acquire semantics so
// this compiles to a normal load. Matches the lock-free read idiom in System.Lazy<T>
// and LazyInitializer.EnsureInitialized.
Guid[]? cached = Volatile.Read(ref _orderedChildren);
if (cached is not null)
{
return cached;
}
return BuildOrderedChildren(navigationStructure);
}
/// <summary>
/// Invalidates the cached ordered-children snapshot.
/// </summary>
/// <remarks>
/// Called by <see cref="AddChild"/> and <see cref="RemoveChild"/> automatically. Must be
/// called externally when a child's <c>SortOrder</c> changes (the parent's cache sorts by
/// child <c>SortOrder</c> and so is stale after such an update).
/// </remarks>
internal void InvalidateOrderedChildren()
{
lock (_orderedChildrenLock)
{
_orderedChildren = null;
}
}
private Guid[] BuildOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
{
lock (_orderedChildrenLock)
{
// Double-check under the lock — another thread may have built the cache while we
// were waiting to acquire it.
Guid[]? cached = _orderedChildren;
if (cached is not null)
{
return cached;
}
if (_children.Count == 0)
{
_orderedChildren = [];
return _orderedChildren;
}
var sorted = new List<(Guid Key, int SortOrder)>(_children.Count);
foreach (Guid childKey in _children)
{
if (navigationStructure.TryGetValue(childKey, out NavigationNode? childNode))
{
sorted.Add((childKey, childNode.SortOrder));
}
}
sorted.Sort(_sortBySortOrder);
var result = new Guid[sorted.Count];
for (var i = 0; i < sorted.Count; i++)
{
result[i] = sorted[i].Key;
}
_orderedChildren = result;
return result;
}
}
}
@@ -74,10 +74,6 @@ public class PublishedValueFallback : IPublishedValueFallback
}
break;
case Fallback.Ancestors:
// Ancestors fallback only applies at IPublishedContent level (tree-aware).
// Skip silently here so chained fallbacks still work and direct element calls don't throw.
continue;
default:
throw NotSupportedFallbackMethod(f, "property");
}
@@ -131,10 +127,6 @@ public class PublishedValueFallback : IPublishedValueFallback
}
break;
case Fallback.Ancestors:
// Ancestors fallback only applies at IPublishedContent level (tree-aware).
// Skip silently here so chained fallbacks still work and direct element calls don't throw.
continue;
default:
throw NotSupportedFallbackMethod(f, "element");
}
@@ -6,7 +6,7 @@ using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the <see cref="Services.IScriptService"/> after a script has been deleted.
/// A notification that is used to trigger the IFileService when the DeleteScript method is called in the API, after the script has been deleted.
/// </summary>
public class ScriptDeletedNotification : DeletedNotification<IScript>
{
@@ -6,7 +6,7 @@ using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the <see cref="Services.IScriptService"/> when a script is being deleted.
/// A notification that is used to trigger the IFileService when the DeleteScript method is called in the API.
/// </summary>
public class ScriptDeletingNotification : DeletingNotification<IScript>
{
@@ -10,7 +10,7 @@ namespace Umbraco.Cms.Core.Notifications;
/// Notification that is published after a script file has been saved.
/// </summary>
/// <remarks>
/// This notification is published by the <see cref="Services.IScriptService"/> after the script has been persisted.
/// This notification is published by the <see cref="Services.IFileService"/> after the script has been persisted.
/// It is not cancelable since the save operation has already completed.
/// </remarks>
public class ScriptSavedNotification : SavedNotification<IScript>
@@ -11,7 +11,7 @@ namespace Umbraco.Cms.Core.Notifications;
/// </summary>
/// <remarks>
/// This notification is cancelable, allowing handlers to prevent the save operation.
/// The notification is published by the <see cref="Services.IScriptService"/> before the script is persisted.
/// The notification is published by the <see cref="Services.IFileService"/> before the script is persisted.
/// </remarks>
public class ScriptSavingNotification : SavingNotification<IScript>
{
@@ -6,7 +6,7 @@ using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the <see cref="Services.IStylesheetService"/> after a stylesheet has been deleted.
/// A notification that is used to trigger the IFileService when the DeleteStylesheet method is called in the API, after the stylesheet has been deleted.
/// </summary>
public class StylesheetDeletedNotification : DeletedNotification<IStylesheet>
{
@@ -6,7 +6,7 @@ using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the <see cref="Services.IStylesheetService"/> when a stylesheet is being deleted.
/// A notification that is used to trigger the IFileService when the DeleteStylesheet method is called in the API.
/// </summary>
public class StylesheetDeletingNotification : DeletingNotification<IStylesheet>
{
@@ -10,7 +10,7 @@ namespace Umbraco.Cms.Core.Notifications;
/// Notification that is published after a stylesheet has been saved.
/// </summary>
/// <remarks>
/// This notification is published by the <see cref="Services.IStylesheetService"/> after the stylesheet has been persisted.
/// This notification is published by the <see cref="Services.IFileService"/> after the stylesheet has been persisted.
/// It is not cancelable since the save operation has already completed.
/// </remarks>
public class StylesheetSavedNotification : SavedNotification<IStylesheet>
@@ -11,7 +11,7 @@ namespace Umbraco.Cms.Core.Notifications;
/// </summary>
/// <remarks>
/// This notification is cancelable, allowing handlers to prevent the save operation.
/// The notification is published by the <see cref="Services.IStylesheetService"/> before the stylesheet is persisted.
/// The notification is published by the <see cref="Services.IFileService"/> before the stylesheet is persisted.
/// </remarks>
public class StylesheetSavingNotification : SavingNotification<IStylesheet>
{
@@ -6,7 +6,7 @@ using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the <see cref="Services.ITemplateService"/> after a template has been deleted.
/// A notification that is used to trigger the IFileService when the DeleteTemplate method is called in the API, after the template has been deleted.
/// </summary>
public class TemplateDeletedNotification : DeletedNotification<ITemplate>
{

Some files were not shown because too many files have changed in this diff Show More