Compare commits

..
Author SHA1 Message Date
Laura Neto c087ce9fb5 Bump the version of the Umbraco.TheStarterKit to 18.0.0 in the UmbracoProject template 2026-06-24 11:23:37 +02:00
Jacob Overgaard 8dd8820fa3 build(deps): bumps @umbraco-ui/uui to 2.0.0 2026-06-23 11:54:00 +02:00
Laura Neto 6b76230da5 Bump version to 18.0.0. 2026-06-23 09:38:59 +02:00
Andy Butland 2e3628dad3 Bump version to 18.0.0-rc4. 2026-06-19 18:50:40 +02:00
Jacob OvergaardandClaude Opus 4.8 cb23c84c3d External login: wait for app-entry-points before the login provider decision (#23167)
* External login: wait for app-entry-points before the login provider decision

The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

- extension-initializer-base: `loaded` re-arms to `undefined` while a pass is in
  flight and resolves to `true` unconditionally (including zero extensions), so
  `.asPromise()` gates correctly and never hangs on a default install (which has
  no app-entry-points) — the reason the await was removed in the first place.
- app.element: restore the awaited boot gate before routing.

Tests:
- Unit test for the `loaded` signal contract (zero extensions resolves; a late,
  slow extension is awaited).
- Playwright acceptance test that deploys an app-entry-point registering an
  authProvider after a delay and asserts it is offered on the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(backoffice): guard the loaded-gate timing for permission loading

Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backoffice): harden loaded signal + narrow acceptance test glob (review)

Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:28:23 +02:00
Jesper MadsenandJacob Overgaard 1dfcd9332a Let the external login button show "sign in with {providername}" in languages (#23135) 2026-06-17 15:57:40 +02:00
Jacob OvergaardandClaude Opus 4.8 d84186061c fix(tests): point DomainCacheServiceTests mocks at GetAllAsync
IDomainService.GetAll was removed in #22629; DomainCacheServiceTests was
added later in #23084 against a stale base and still mocked the removed
method, breaking the Release build on release/18.0. Production
DomainCacheService already calls GetAllAsync, so update the four mock
setups to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:52:52 +02:00
Jacob Overgaard 87bc6522f1 build: deploy to npm through a template 2026-06-17 14:26:25 +02:00
Jacob OvergaardandClaude Opus 4.7 c300ebf94a Build: tag prerelease npm publishes with 'next' dist-tag (#22909)
* Build: tag prerelease npm publishes with 'next' dist-tag

Prereleases that flow through Deploy_Npm (e.g. 18.0.0-beta1) currently
land on the `latest` dist-tag, so a bare `npm install @umbraco-cms/backoffice`
resolves to an unstable build. Switch to `--tag next` when
NBGV_PrereleaseVersion is non-empty, leaving `latest` for stable releases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Build: address review feedback on npm prerelease dist-tag

- Add Build to Deploy_Npm dependsOn so stageDependencies.Build.A.outputs
  resolves explicitly (mirrors the Upload_API_Docs pattern).
- Pass npmPrereleaseVersion via env: instead of inline macro expansion in
  bash, so an unset variable won't be interpreted as command substitution.

* Build: source npmPrereleaseVersion via dependencies, not dependsOn

Switches the variable mapping from stageDependencies (which needs Build
in dependsOn) to dependencies.Build.outputs[...], matching the pattern
the stage's condition already uses on line 941. Avoids drawing a
redundant parallel arrow from Build to Deploy_Npm in the ADO stage
graph — Build is already in the ancestor chain via Deploy_NuGet.

* Build: align Deploy_Npm with Umbraco Deploy publish pattern

- Use stageDependencies form in variables: (dependencies.* only works in conditions).
- Source NBGV_PrereleaseVersionNoLeadingHyphen for a cleaner check.
- Replace echo >> .npmrc with npm config set --location=project.
- Collapse if/else into a tag=latest|next shell variable; single npm publish *.tgz.
- Drop unnecessary env: passthrough and npm init -y.

Per Ronald's feedback on PR #22909 — mirrors the Deploy pipeline's release stage.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 14:00:19 +02:00
Ronald BarendseandAndy Butland e2252a8634 Tests: Remove AutoFixture from the shipped Umbraco.Cms.Tests package (#23141)
Tests: Remove AutoFixture from Umbraco.Cms.Tests package
2026-06-17 09:50:01 +02:00
Andy Butland 37e2458475 Merge branch 'release/18.0' of https://github.com/umbraco/Umbraco-CMS into release/18.0 2026-06-15 16:20:46 +02:00
Andy Butland 2461853b11 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

* Addressed code review comments and added further comment to the code.

* Use Lock object.
2026-06-15 16:20:11 +02:00
16c97d613f Elements: Invalidate the id/key map when an element container is deleted (closes #23072) (#23074)
* Refresh the element container cache on delete to ensure the id/key map is invalidated.

* Rename and additional asserts in test.

* Use a dedicated refresher for element container id/key map eviction

Routing container-delete invalidation through ElementCacheRefresher cleared
the entire elements cache on every payload, so deleting a container triggered
a full clear even though no element data changed (and a second clear on top of
the ElementTreeChangeNotification refresh when the container held elements).

Add a dedicated ElementContainerCacheRefresher whose only job is to evict the
container's IIdKeyMap entry, and route EntityContainerDeletedNotification
through it instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Addressed code review feedback.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:16:31 +02:00
Andy Butland cffac2a990 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:37:13 +02:00
Kenn JacobsenandGitHub 6883c6fcfd Tags: Expand ITagService to handle Elements (#23117) 2026-06-15 06:28:52 +02:00
Andy ButlandandGitHub 5dd28e7a13 Tests: Fix failing Management API integration tests (closes #23076) (#23081)
* Include currently missing management API tests in the CI build.

* Fixed failing tests.

* Revert the pipeline updates.

* Addressed code review feedback.
2026-06-07 08:40:23 +02:00
Niels LyngsøandGitHub 87b24912c7 V18: Beta UI adjustments (#22869) 2026-06-05 15:10:54 +02:00
Andy Butland f6c70e8429 Bump version to 18.0.0-rc3. 2026-06-05 06:46:44 +02:00
Laura NetoandGitHub 1dbcf1037a Delivery API - Open API: return inline {} schema for unconstrained property types (#23066)
* Delivery API: return inline {} schema for unconstrained property types

ContentTypeSchemaTransformer now checks the raw STJ schema via JsonSchemaExporter before
calling GetOrCreateSchemaAsync. STJ generates boolean true for unconstrained types (JsonNode,
object, types with custom converters), which the pipeline converts to {}. When the raw schema
is true, an inline {} is returned without registering a named component - a named component
adds no value and misleads API consumers into thinking a concrete model shape exists.

* Delivery API: add Plain JSON property to contract test sample types

Adds a Plain JSON property to the sample article page content type used by the OpenAPI contract
tests. This exercises the unconstrained-type fix: the property should appear as inline {} in the
schema, not as a named JsonNode component. Updates the expected contract to reflect the new
property.

* Re-generate typed-schemas-with-sample-types.json

For some reason the previous change got formatted differently, so it was displaying more changes than it should.

* Simplify comments

* Delivery API: guard unconstrained type check with JsonTypeInfoKind.None
2026-06-04 17:01:49 +02:00
Andy ButlandandGitHub 27909ed18e Elements: Hide element actions from the document notifications dialog (closes #23053) (#23059)
Remove element actions from the notification dialog.
2026-06-04 14:57:32 +02:00
Andy Butland 54827c4c92 Background Jobs: Resolve server role so recurring jobs run when no application URL is configured (#23033)
Resolve server role when no application URL is configured.
2026-06-04 06:44:51 +02:00
Laura NetoandGitHub 214fd03241 OpenAPI: Disable XML documentation source generator (closes #23018) (#23045)
Disable OpenAPI XML documentation source generator

Due to the amount of XML documentation in this solution, the OpenAPI XML documentation source generator produces too many lines of code in a single method (GenerateCacheEntries), which causes a StackOverflowException when running on IIS. The fix disables the analyzer globally via Directory.Build.props.
2026-06-03 12:28:51 +02:00
Jacob OvergaardandGitHub 787f66be3d Dependencies: Bumps @umbraco-ui/uui from 2.0.0-rc.1 to 2.0.0-rc.2 (#23052)
build(deps): bumps @umbraco-ui/uui from 2.0.0-rc.1 to 2.0.0-rc.2
2026-06-03 09:00:01 +00:00
Erik-Jan WestendorpandAndy Butland cd23fc75c5 Localisation: Translate the "Library" section header into other languages (#23043)
* Translate library to Dutch and Spanish

* Add translations for other cultures.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-03 09:36:43 +02:00
Sven GeusensandAndy Butland 0adb5d21f5 Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Move <target/> part of the polyfill to targets file.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-01 14:40:54 +02:00
Andy ButlandandGitHub 446a3795b7 Elements: Clear user and user group start node references when deleting an element container (closes #23010) (#23011)
* Clear user and user group start nodes when deleting an element container.

* Assert user start node references cleared after container delete

Mirrors the post-delete assertion already present in the user group
sibling test so both tests confirm the reference was cleaned up, not
just that no FK exception was thrown.

* Guard against null entity in PersistDeletedItem override

Mirrors the ArgumentNullException guard in the base
EntityContainerRepository.PersistDeletedItem so a null argument throws
the same exception type.
2026-06-01 10:36:11 +02:00
Andy ButlandandGitHub 3f0e0747fa Management API: Declare multipart/form-data on the Create Temporary File endpoint (closes #23017) (#23025)
Add explicit Consumes to management API endpoint that accepts IFormFile.
2026-06-01 09:32:48 +02:00
Laura Neto f3471e961f Bump version to 18.0.0-rc2 2026-05-28 09:56:34 +02:00
590 changed files with 6357 additions and 14377 deletions
+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
-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
+10
View File
@@ -64,4 +64,14 @@
</_ProjectReferencesWithVersions>
</ItemGroup>
</Target>
<!-- Workaround for https://github.com/umbraco/Umbraco-CMS/issues/23018
Due to the amount of XML documentation in this solution, the OpenAPI XML documentation source generator produces
too many lines of code causing a StackOverflowException when running on IIS. For that reason we disable the analyzer.
See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/openapi-comments?view=aspnetcore-10.0#disabling-xml-documentation-support -->
<Target Name="DisableCompileTimeOpenApiXmlGenerator" BeforeTargets="CoreCompile" Condition="'$(IsPackable)' != 'false' or '$(IsTestProject)' == 'true'">
<ItemGroup>
<Analyzer Remove="@(Analyzer)" Condition="'%(Filename)' == 'Microsoft.AspNetCore.OpenApi.SourceGenerators'" />
</ItemGroup>
</Target>
</Project>
+1 -1
View File
@@ -57,7 +57,7 @@
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="Markdig" Version="1.1.3" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
<PackageVersion Include="ncrontab" Version="3.4.0" />
+43 -109
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
@@ -825,74 +825,31 @@ stages:
publishFeedCredentials: "MyGet - Umbraco Nightly"
${{ else }}:
publishFeedCredentials: "MyGet - Pre-releases"
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
- job:
displayName: Push to pre-release feed (npm)
steps:
- checkout: none
- download: current
artifact: npm
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
- template: templates/npm-publish.yml
parameters:
artifactName: npm
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Push to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- job: PublishTestHelpersNpm
displayName: Push TestHelpers to pre-release feed (npm)
steps:
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Push test helpers to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- stage: Deploy_NuGet
displayName: NuGet release
@@ -905,10 +862,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.'
@@ -941,53 +898,30 @@ stages:
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
dependsOn:
- Deploy_NuGet
variables:
# `latest` for stable releases, `next` for prereleases.
npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
jobs:
- job: Publish
displayName: Push to NPM
steps:
- checkout: none
- download: current
artifact: npm
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm/.npmrc
- template: templates/npm-publish.yml
parameters:
artifactName: npm
registry: https://registry.npmjs.org/
customEndpoint: "NPM - Umbraco Backoffice"
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Push to npm
npmTag: $(npmDistTag)
- job: PublishTestHelpers
displayName: Push Test Helpers to NPM
steps:
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
registry: https://registry.npmjs.org/
customEndpoint: "NPM - Umbraco Backoffice"
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Push test helpers to npm
npmTag: $(npmDistTag)
- stage: Upload_API_Docs
pool:
+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
+28
View File
@@ -0,0 +1,28 @@
parameters:
- name: artifactName # "npm" or "npm-testhelpers"
type: string
- name: registry # scoped-registry URL to publish to
type: string
- name: customEndpoint # npmAuthenticate service connection(s)
type: string
- name: displayName # label for the publish step
type: string
- name: npmTag # dist-tag to publish under
type: string
default: latest
steps:
- checkout: none
- download: current
artifact: ${{ parameters.artifactName }}
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
displayName: Add scoped registry to .npmrc
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
customEndpoint: ${{ parameters.customEndpoint }}
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
displayName: ${{ parameters.displayName }}
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
@@ -1,5 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
using System.Text.Json.Serialization.Metadata;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.OpenApi;
@@ -341,6 +343,12 @@ public sealed class ContentTypeSchemaTransformer : IOpenApiSchemaTransformer, IO
var schemaId = GetSchemaId(jsonTypeInfo);
// Types that produce 'true' in JSON Schema (unconstrained: JsonNode, object, custom-converter types) should be inline {} rather than named components.
if (jsonTypeInfo.Kind == JsonTypeInfoKind.None && jsonTypeInfo.GetJsonSchemaAsNode().GetValueKind() == JsonValueKind.True)
{
return new OpenApiSchema();
}
// If this is one of the types we handle, and we already started generating it, return a placeholder
// to avoid circular reference issues.
// In the document transformer, these placeholders will be replaced with the actual schemas.
@@ -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);
@@ -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>
@@ -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();
}
}
@@ -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);
@@ -32,6 +32,7 @@ public class CreateTemporaryFileController : TemporaryFileControllerBase
[HttpPost("")]
[MapToApiVersion("1.0")]
[Consumes("multipart/form-data")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Creates a temporary file.")]
@@ -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 = "~/";
+3 -3
View File
@@ -28924,8 +28924,8 @@
"tags": [
"Redirect Management"
],
"summary": "Deprecated. No longer changes the redirect URL tracking status.",
"description": "This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.",
"summary": "Sets the redirect URL tracking status.",
"description": "Updates the redirect URL tracking configuration according to the provided status.",
"operationId": "PostRedirectManagementStatus",
"parameters": [
{
@@ -33430,7 +33430,7 @@
"operationId": "PostTemporaryFile",
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
@@ -21,7 +21,7 @@ public class ActionElementContainerDelete : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerMove : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerNew : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerUpdate : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementCopy : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementDelete : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementMove : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
+1 -1
View File
@@ -21,7 +21,7 @@ public class ActionElementNew : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementPublish : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementRollback : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementUpdate : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
-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`:
@@ -467,6 +467,20 @@ public static class DistributedCacheExtensions
#endregion
#region ElementContainerCacheRefresher
/// <summary>
/// Invalidates the id/key map for the specified deleted element containers (folders).
/// </summary>
/// <param name="dc">The distributed cache.</param>
/// <param name="deletedContainers">The element containers that were deleted.</param>
public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable<EntityContainer> deletedContainers)
=> dc.RefreshByPayload(
ElementContainerCacheRefresher.UniqueId,
deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key)));
#endregion
#region Published Snapshot
/// <summary>
@@ -0,0 +1,43 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping
/// is evicted from <see cref="Services.IIdKeyMap"/> on every server.
/// </summary>
/// <remarks>
/// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an
/// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node
/// itself, so without this handler the container's stale id/key mapping survives until the next app
/// restart (see #23072).
/// </remarks>
public sealed class ElementContainerDeletedDistributedCacheNotificationHandler
: DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification>
{
private readonly DistributedCache _distributedCache;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class.
/// </summary>
/// <param name="distributedCache">The distributed cache.</param>
public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache)
=> _distributedCache = distributedCache;
/// <inheritdoc />
protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state)
{
EntityContainer[] elementContainers = entities
.Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer)
.ToArray();
if (elementContainers.Length == 0)
{
return;
}
_distributedCache.RemoveElementContainerCache(elementContainers);
}
}
@@ -0,0 +1,109 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Provides cache refresh functionality for element containers (folders).
/// </summary>
/// <remarks>
/// A deleted container's node id is never reused, so its key→id mapping in <see cref="IIdKeyMap"/> must be
/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and
/// the element tree's children query returns nothing until the next app restart. This refresher only evicts
/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader
/// invalidation performed by <see cref="ElementCacheRefresher"/>.
/// </remarks>
public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase<ElementContainerCacheRefresherNotification, ElementContainerCacheRefresher.JsonPayload>
{
private readonly IIdKeyMap _idKeyMap;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresher"/> class.
/// </summary>
public ElementContainerCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
IIdKeyMap idKeyMap,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory)
: base(appCaches, serializer, eventAggregator, factory)
=> _idKeyMap = idKeyMap;
#region Json
/// <summary>
/// Represents a JSON-serializable payload identifying an element container that changed.
/// </summary>
public class JsonPayload
{
/// <summary>
/// Initializes a new instance of the <see cref="JsonPayload"/> class.
/// </summary>
/// <param name="id">The unique integer identifier for the container.</param>
/// <param name="key">The unique GUID key associated with the container.</param>
public JsonPayload(int id, Guid key)
{
Id = id;
Key = key;
}
/// <summary>
/// Gets the unique integer identifier for the container.
/// </summary>
public int Id { get; }
/// <summary>
/// Gets the unique GUID key associated with the container.
/// </summary>
public Guid Key { get; }
}
#endregion
#region Define
/// <summary>
/// Represents a unique identifier for the cache refresher.
/// </summary>
public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21");
/// <inheritdoc/>
public override Guid RefresherUniqueId => UniqueId;
/// <inheritdoc/>
public override string Name => "Element Container Cache Refresher";
#endregion
#region Refresher
/// <inheritdoc/>
public override void Refresh(JsonPayload[] payloads)
{
foreach (JsonPayload payload in payloads)
{
// Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync.
_idKeyMap.ClearCache(payload.Id);
}
base.Refresh(payloads);
}
// These events should never trigger. Everything should be PAYLOAD/JSON.
/// <inheritdoc/>
public override void RefreshAll() => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(int id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(Guid id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Remove(int id) => throw new NotSupportedException();
#endregion
}
@@ -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>
@@ -405,7 +405,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -454,7 +454,8 @@
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
@@ -463,7 +463,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -452,7 +452,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<key alias="clickJackingCheckHeaderFound">
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
<key alias="clickJackingCheckHeaderNotFound">
@@ -403,7 +403,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
private HealthCheckStatus CheckUmbracoApplicationUrl()
{
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
var url = settings.UmbracoApplicationUrl;
string resultMessage;
StatusResultType resultType;
var success = false;
if (url.IsNullOrWhiteSpace())
if (url.IsNullOrWhiteSpace() is false)
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
resultType = StatusResultType.Success;
}
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
{
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
resultType = StatusResultType.Error;
}
else
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
resultType = StatusResultType.Success;
success = true;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
}
return new HealthCheckStatus(resultMessage)
{
ResultType = resultType,
ReadMoreLink = success
ReadMoreLink = resultType == StatusResultType.Success
? null
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
};
@@ -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>
@@ -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() => [];
}
@@ -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();
}
@@ -24,4 +24,9 @@ public enum TaggableObjectTypes
/// Represents member entities (user accounts).
/// </summary>
Member,
/// <summary>
/// Represents element entities.
/// </summary>
Element,
}
@@ -0,0 +1,19 @@
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the Element Container Cache Refresher.
/// </summary>
public class ElementContainerCacheRefresherNotification : CacheRefresherNotification
{
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresherNotification"/> class.
/// </summary>
/// <param name="messageObject">The refresher payload.</param>
/// <param name="messageType">Type of the cache refresher message, <see cref="MessageType"/>.</param>
public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType)
: base(messageObject, messageType)
{
}
}
@@ -1,11 +0,0 @@
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Represents a property index value factory specifically for block list properties.
/// </summary>
/// <remarks>
/// This marker interface allows for specialized indexing of block list content.
/// </remarks>
public interface IBlockListPropertyIndexValueFactory : IPropertyIndexValueFactory
{
}
@@ -1,11 +1,12 @@
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Represents a property index value factory specifically for block grid properties.
/// Represents a property index value factory specifically for block-based property values.
/// </summary>
/// <remarks>
/// This marker interface allows for specialized indexing of block grid content.
/// This marker interface allows for specialized indexing of block content,
/// such as Block List, Block Grid, and Rich Text block values.
/// </remarks>
public interface IBlockGridPropertyIndexValueFactory : IPropertyIndexValueFactory
public interface IBlockValuePropertyIndexValueFactory : IPropertyIndexValueFactory
{
}
@@ -1,11 +0,0 @@
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Represents a property index value factory specifically for single block properties.
/// </summary>
/// <remarks>
/// This marker interface allows for specialized indexing of single block content.
/// </remarks>
public interface ISingleBlockPropertyIndexValueFactory : IPropertyIndexValueFactory
{
}
@@ -3,17 +3,7 @@ using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.PublishedCache;
/// <summary>
/// A service for converting <see cref="BlockItemData"/> into <see cref="IPublishedElement"/>.
/// </summary>
public interface IBlockElementService
{
/// <summary>
/// Creates an <see cref="IPublishedElement"/> instance from <see cref="BlockItemData"/>.
/// </summary>
/// <param name="owner">The <see cref="IPublishedElement"/> that contains the block property which is the origin to the <see cref="BlockItemData"/>.</param>
/// <param name="blockItemData">The <see cref="BlockItemData"/> containing the data to convert into an <see cref="IPublishedElement"/>.</param>
/// <param name="preview">Whether to perform the conversion for preview.</param>
/// <returns>The created <see cref="IPublishedElement"/>, or null if an element could not be created from the <see cref="BlockItemData"/>.</returns>
Task<IPublishedElement?> BuildElementAsync(IPublishedElement owner, BlockItemData blockItemData, bool? preview = null);
Task<IPublishedElement?> BuildElementAsync(BlockItemData blockItemData, bool? preview = null);
}
+18
View File
@@ -54,6 +54,18 @@ public interface ITagService : IService
/// </summary>
IEnumerable<TaggedEntity> GetTaggedMembersByTag(string tag, string? group = null, string? culture = null);
/// <summary>
/// Gets all elements tagged with any tag in the specified group.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null) => [];
/// <summary>
/// Gets all elements tagged with the specified tag.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null) => [];
/// <summary>
/// Gets all tags.
/// </summary>
@@ -100,6 +112,12 @@ public interface ITagService : IService
/// </summary>
IEnumerable<ITag> GetAllMemberTags(string? group = null, string? culture = null);
/// <summary>
/// Gets all element tags.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null) => [];
/// <summary>
/// Gets all tags attached to an entity via a property.
/// </summary>
+27
View File
@@ -101,6 +101,24 @@ public class TagService : RepositoryService, ITagService
}
}
/// <inheritdoc />
public IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Element, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTaggedEntitiesByTag(TaggableObjectTypes.Element, tag, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetAllTags(string? group = null, string? culture = null)
{
@@ -162,6 +180,15 @@ public class TagService : RepositoryService, ITagService
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTagsForEntityType(TaggableObjectTypes.Element, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetTagsForProperty(int contentId, string propertyTypeAlias, string? group = null, string? culture = null)
{
@@ -54,7 +54,7 @@ public static class UmbracoBuilderExtensions
.DeliveryApiContentIndexName)
.ConfigureOptions<ConfigureIndexOptions>();
services.AddSingleton<IApplicationRoot>(sp => ActivatorUtilities.CreateInstance<UmbracoApplicationRoot>(sp));
services.AddSingleton<IApplicationRoot, UmbracoApplicationRoot>();
services.AddSingleton<ILockFactory, UmbracoLockFactory>();
services.AddSingleton<ConfigurationEnabledDirectoryFactory>();
@@ -55,9 +55,7 @@ public class LuceneIndexDiagnostics : IIndexDiagnostics
Directory luceneDir = Index.GetLuceneDirectory();
var d = new Dictionary<string, object?>
{
#pragma warning disable CS0618 // CommitCount is obsolete and reported unused, but retained to avoid any risk of change to existing behaviour. Remove this entry when a future Examine upgrade removes the underlying field.
[nameof(UmbracoExamineIndex.CommitCount)] = Index.CommitCount,
#pragma warning restore CS0618
[nameof(UmbracoExamineIndex.DefaultAnalyzer)] = Index.DefaultAnalyzer.GetType().Name,
["LuceneDirectory"] = luceneDir.GetType().Name
};
@@ -1,10 +1,6 @@
using Examine;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Extensions;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
using Umbraco.Cms.Core.Hosting;
namespace Umbraco.Cms.Infrastructure.Examine;
@@ -13,24 +9,14 @@ namespace Umbraco.Cms.Infrastructure.Examine;
/// </summary>
public class UmbracoApplicationRoot : IApplicationRoot
{
private readonly IHostEnvironment _hostEnvironment;
private readonly IHostingEnvironment _hostingEnvironment;
// TODO (V20): Remove this obsolete constructor and the [ActivatorUtilitiesConstructor] attribute below.
// Also revert the registration in UmbracoBuilderExtensions to:
// services.AddSingleton<IApplicationRoot, UmbracoApplicationRoot>();
// (the factory form is required so [ActivatorUtilitiesConstructor] is honored).
[Obsolete("Use the constructor accepting IHostEnvironment. Scheduled for removal in Umbraco 20.")]
public UmbracoApplicationRoot(IHostingEnvironment hostingEnvironment)
: this(StaticServiceProvider.Instance.GetRequiredService<IHostEnvironment>())
{
}
[ActivatorUtilitiesConstructor]
public UmbracoApplicationRoot(IHostEnvironment hostEnvironment)
=> _hostEnvironment = hostEnvironment;
=> _hostingEnvironment = hostingEnvironment;
public DirectoryInfo ApplicationRoot
=> new(Path.Combine(
_hostEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempData),
"ExamineIndexes"));
=> new(
Path.Combine(
_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempData),
"ExamineIndexes"));
}
@@ -1,9 +1,5 @@
using Examine;
using Examine.Lucene;
using Examine.Lucene.Directories;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.DependencyInjection;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
namespace Umbraco.Cms.Infrastructure.Examine
@@ -13,25 +9,11 @@ namespace Umbraco.Cms.Infrastructure.Examine
/// </summary>
public class UmbracoTempEnvFileSystemDirectoryFactory : FileSystemDirectoryFactory
{
[Obsolete("Use the constructor accepting IOptionsMonitor<LuceneDirectoryIndexOptions>. Scheduled for removal in Umbraco 20.")]
public UmbracoTempEnvFileSystemDirectoryFactory(
IApplicationIdentifier applicationIdentifier,
ILockFactory lockFactory,
IHostingEnvironment hostingEnvironment)
: this(
applicationIdentifier,
lockFactory,
hostingEnvironment,
StaticServiceProvider.Instance.GetRequiredService<IOptionsMonitor<LuceneDirectoryIndexOptions>>())
{
}
public UmbracoTempEnvFileSystemDirectoryFactory(
IApplicationIdentifier applicationIdentifier,
ILockFactory lockFactory,
IHostingEnvironment hostingEnvironment,
IOptionsMonitor<LuceneDirectoryIndexOptions> indexOptions)
: base(new DirectoryInfo(GetTempPath(applicationIdentifier, hostingEnvironment)), lockFactory, indexOptions)
: base(new DirectoryInfo(GetTempPath(applicationIdentifier, hostingEnvironment)), lockFactory)
{
}
@@ -84,8 +84,19 @@ public class TouchServerJob : RecurringBackgroundJobBase
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
if (string.IsNullOrWhiteSpace(serverAddress))
{
_logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
return Task.CompletedTask;
// No application URL is known yet: either detection is off (WebRouting:ApplicationUrlDetection is
// None with no UmbracoApplicationUrl set), or detection is on but no request has been served yet.
// Register with the machine name as a placeholder so server-role election can still proceed (uniqueness
// comes from the server identity, not this address). If a URL is later detected from a request, the next
// touch overwrites the placeholder.
serverAddress = Environment.MachineName;
_logger.LogDebug(
"No application URL available; registering server with placeholder address {ServerAddress}.",
serverAddress);
}
else
{
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
}
try
-51
View File
@@ -384,57 +384,6 @@ using (ICoreScope scope = ScopeProvider.CreateCoreScope())
3. **Lazy loading outside scope** - NPoco relationships must load within scope
4. **Large migrations** - Split into multiple steps if > 1000 lines
5. **Repository logic in services** - Keep repos thin, logic in services
6. **Unbatched `WHERE IN` on user-sized collections** - See "Avoiding the SQL Server 2100-parameter limit" below
### Avoiding the SQL Server 2100-parameter limit
SQL Server caps a single statement at 2100 parameters. When an `IN` clause is built from a collection sized by user data, that cap can be hit — and the symptom is a runtime `SqlException` (error 8003) on customer installs that nobody hit in dev.
**The constant and helpers**:
- `Constants.Sql.MaxParameterCount = 2000` (in `Umbraco.Core`, `Constants-Sql.cs`) — the ceiling we target (2100 minus headroom for joined predicates already in the SQL).
- `IEnumerable<T>.InGroupsOf(groupSize)` (in `Umbraco.Core`, `Extensions/EnumerableExtensions.cs`) — extension method to batch a collection.
- `Database.FetchByGroups<TResult, TSource>(source, groupSize, sqlFactory)` (in `Umbraco.Infrastructure`, `Persistence/NPocoDatabaseExtensions.cs`) — NPoco helper that batches a fetch.
**The safe patterns** (use one of these any time the collection size is user-driven):
```csharp
// Pattern 1: batch a DeleteMany / Execute / Fetch by looping.
foreach (IEnumerable<int> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Database.DeleteMany<FooDto>().Where(x => group.Contains(x.Id)).Execute();
}
// Pattern 2: batched fetch with NPoco helper.
List<FooDto> dtos = Database.FetchByGroups<FooDto, int>(
ids,
Constants.Sql.MaxParameterCount,
batch => Sql().Select<FooDto>().From<FooDto>().WhereIn<FooDto>(x => x.Id, batch));
// Pattern 3: reserve headroom for other parameters in the same statement.
foreach (IEnumerable<int> group in entityIds.InGroupsOf(Constants.Sql.MaxParameterCount - userGroupIds.Length))
{
// statement uses entityIds + userGroupIds, so subtract the other predicate's parameter count from the budget
}
```
**Decision rule when writing or reviewing a `WHERE IN`-style query**:
Look at what drives the size of the collection feeding the `IN`. Ask: *could this realistically exceed 2000 on a large install?* Risky drivers — batch any query backed by these:
- All content / media / member nodes (or descendants of a deep tree).
- A product of two scaling dimensions, e.g. `documents × languages`, `properties × versions`, `relations × endpoints`.
- Configuration-tunable batch sizes (`CacheSettings.DocumentSeedBatchSize`, `NuCacheSettings.SqlPageSize`, etc.). The default may be safe but the customer can raise it.
- Anything that scans property data, version history, relations, or audit logs across many nodes.
Safe drivers — don't bother batching:
- Languages / content types / member groups / user groups — bounded by install configuration, typically <100.
- "Per single content item" collections — properties on one document, versions of one document, tokens for one external login.
- IDs supplied directly by a user action through the UI (picker selections, bulk actions on a page of results).
If you're not sure, batch — the cost is one loop and an `IEnumerable<T>` allocation per batch; the cost of being wrong is a SqlException on a customer's biggest site.
**For new public APIs** that take an `IEnumerable<int>`/`IEnumerable<Guid>` and feed it into a query, batch internally even if no current caller is large — package authors and future callers will not know about the 2000-limit ceiling.
**Don't** rely on `if (ids.Length > MaxParameterCount) throw` as a substitute for batching. Throwing only moves the problem; the caller has no obvious way to recover and will most likely just fail in production.
---
@@ -104,7 +104,6 @@ internal sealed class JsonConfigManipulator : IConfigManipulator
}
/// <inheritdoc />
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public async Task SaveDisableRedirectUrlTrackingAsync(bool disable)
=> await CreateOrUpdateConfigValueAsync(DisableRedirectUrlTrackingPath, disable);
@@ -285,9 +285,7 @@ public static partial class UmbracoBuilderExtensions
/// <returns>The same <see cref="Umbraco.Cms.Core.DependencyInjection.IUmbracoBuilder"/> instance so that multiple calls can be chained.</returns>
public static IUmbracoBuilder AddPropertyIndexValueFactories(this IUmbracoBuilder builder)
{
builder.Services.AddSingleton<IBlockListPropertyIndexValueFactory, BlockListPropertyIndexValueFactory>();
builder.Services.AddSingleton<IBlockGridPropertyIndexValueFactory, BlockGridPropertyIndexValueFactory>();
builder.Services.AddSingleton<ISingleBlockPropertyIndexValueFactory, SingleBlockPropertyIndexValueFactory>();
builder.Services.AddSingleton<IBlockValuePropertyIndexValueFactory, BlockValuePropertyIndexValueFactory>();
builder.Services.AddSingleton<ITagPropertyIndexValueFactory, TagPropertyIndexValueFactory>();
builder.Services.AddSingleton<IRichTextPropertyIndexValueFactory, RichTextPropertyIndexValueFactory>();
builder.Services.AddSingleton<IDateOnlyPropertyIndexValueFactory, DateOnlyPropertyIndexValueFactory>();
@@ -468,6 +466,7 @@ public static partial class UmbracoBuilderExtensions
.AddNotificationHandler<MemberTypeChangedNotification, MemberTypeChangedDistributedCacheNotificationHandler>()
.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>()
.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>()
.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>()
;
// add notification handlers for auditing
@@ -52,8 +52,8 @@ internal sealed class DatabaseDataCreator
},
new()
{
Name = "Find all logs that are within the namespace 'Umbraco.Cms'",
Query = "StartsWith(SourceContext, 'Umbraco.Cms')",
Name = "Find all logs that are from the namespace 'Umbraco.Core'",
Query = "StartsWith(SourceContext, 'Umbraco.Core')",
},
new()
{
@@ -76,7 +76,7 @@ public class MigrateSingleBlockList : AsyncMigrationBase
SingleBlockListConfigurationCache blockListConfigurationCache,
IDataValueEditorFactory dataValueEditorFactory,
IIOHelper ioHelper,
ISingleBlockPropertyIndexValueFactory blockValuePropertyIndexValueFactory,
IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory,
IBlockEditorElementTypeCache elementTypeCache,
AppCaches appCaches)
: base(context)
@@ -7,7 +7,6 @@ namespace Umbraco.Cms.Infrastructure.Notifications
/// <summary>
/// Notification that is raised when a recurring background job is triggered or executed.
/// </summary>
// TODO (V19): Mark this class as abstract.
public class RecurringBackgroundJobNotification : ObjectNotification<IRecurringBackgroundJob>
{
/// <summary>
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Infrastructure.Scoping;
@@ -34,4 +35,27 @@ internal sealed class ElementContainerRepository : EntityContainerRepository, IE
cacheSyncService)
{
}
protected override void PersistDeletedItem(EntityContainer entity)
{
if (entity == null)
{
throw new ArgumentNullException(nameof(entity));
}
// Element containers can be referenced as start nodes on individual users (umbracoUserStartNode)
// and on user groups (umbracoUserGroup.startElementId). Both reference umbracoNode.id via FK,
// so we must clear those references before deleting the underlying node.
var args = new { id = entity.Id };
Database.Execute(
$"DELETE FROM {QuoteTableName(Constants.DatabaseSchema.Tables.UserStartNode)} WHERE {QuoteColumnName("startNode")} = @id",
args);
Database.Execute(
$@"UPDATE {QuoteTableName(Constants.DatabaseSchema.Tables.UserGroup)}
SET {QuoteColumnName("startElementId")} = NULL
WHERE {QuoteColumnName("startElementId")} = @id",
args);
base.PersistDeletedItem(entity);
}
}
@@ -249,24 +249,14 @@ internal sealed class RedirectUrlRepository : EntityRepositoryBase<Guid, IRedire
protected override IEnumerable<IRedirectUrl> PerformGetAll(params Guid[]? ids)
{
if (ids is null || ids.Length == 0)
if (ids?.Length > Constants.Sql.MaxParameterCount)
{
return Database.Fetch<RedirectUrlDto>(GetBaseQuery(false))
.WhereNotNull()
.Select(Map)
.WhereNotNull();
}
// Batch the WhereIn fetch so we never exceed SQL Server's 2100 parameter limit.
// EntityRepositoryBase.GetMany already groups IDs, but we keep the batching here as
// a defensive measure for safety and consistency at the repository boundary.
var dtos = new List<RedirectUrlDto>(ids.Length);
foreach (IEnumerable<Guid> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, group);
dtos.AddRange(Database.Fetch<RedirectUrlDto>(sql));
throw new NotSupportedException(
$"This repository does not support more than {Constants.Sql.MaxParameterCount} ids.");
}
Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, ids);
List<RedirectUrlDto> dtos = Database.Fetch<RedirectUrlDto>(sql);
return dtos.WhereNotNull().Select(Map).WhereNotNull();
}
@@ -647,6 +647,8 @@ ON (tagset.tag = {cmsTags}.tag AND tagset.{group} = {cmsTags}.{group} AND COALES
return Constants.ObjectTypes.Media;
case TaggableObjectTypes.Member:
return Constants.ObjectTypes.Member;
case TaggableObjectTypes.Element:
return Constants.ObjectTypes.Element;
default:
throw new ArgumentOutOfRangeException(nameof(type));
}
@@ -75,7 +75,6 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
[
sx.ColumnWithAlias("x", "otherId", "nodeId"),
sx.ColumnWithAlias("n", "uniqueId", "nodeKey"),
sx.ColumnWithAlias("n", "text", "nodeName"),
sx.ColumnWithAlias("n", "nodeObjectType", "nodeObjectType"),
$"COALESCE({sx.ColumnWithAlias("d", "published")}, {sx.ColumnWithAlias("e", "published")}) AS nodePublished",
sx.ColumnWithAlias("ctn", "uniqueId", "contentTypeKey"),
@@ -110,10 +110,10 @@ public abstract class BlockEditorPropertyNotificationHandlerBase<TBlockLayoutIte
private void TraverseObject(JsonObject obj)
{
// we'll assume that the object is a data representation of a block based editor if it contains "contentData", "settingsData" and "layout".
if (obj["contentData"] is JsonArray contentData && obj["settingsData"] is JsonArray settingsData && obj["layout"] is JsonObject layoutData)
// we'll assume that the object is a data representation of a block based editor if it contains "contentData" and "settingsData".
if (obj["contentData"] is JsonArray contentData && obj["settingsData"] is JsonArray settingsData)
{
ParseKeys(contentData, settingsData, layoutData);
ParseKeys(contentData, settingsData);
return;
}
@@ -123,46 +123,12 @@ public abstract class BlockEditorPropertyNotificationHandlerBase<TBlockLayoutIte
}
}
private void ParseKeys(JsonArray contentData, JsonArray settingsData, JsonObject layoutData)
private void ParseKeys(JsonArray contentData, JsonArray settingsData)
{
// recurse a JSON object to find all contained block editor layouts
List<JsonObject> GetLayoutItemsRecursively(JsonObject jsonObject)
{
var layoutItems = new List<JsonObject>();
if (jsonObject.ContainsKey("key") && jsonObject.ContainsKey("contentKey"))
{
// assume it's a layout if it has "key" and "contentKey"
layoutItems.Add(jsonObject);
}
foreach (JsonNode property in jsonObject.Select(v => v.Value).WhereNotNull())
{
IEnumerable<JsonObject> childrenToRecurse = property is JsonObject jsonObjectChild
? [jsonObjectChild]
: property is JsonArray jsonArrayChild
? jsonArrayChild.OfType<JsonObject>()
: [];
layoutItems.AddRange(childrenToRecurse.SelectMany(GetLayoutItemsRecursively));
}
return layoutItems;
}
// grab keys applicable for replacement from all the layouts - that is:
// - the key of the layout itself ("key").
// - the key of the content item ("contentKey").
// - ONLY for local content; do NOT replace content item keys for shared content.
// - the key of the settings item ("settingsKey") if present.
List<JsonObject> layoutItems = GetLayoutItemsRecursively(layoutData);
var keys = layoutItems.SelectMany(layoutItem => new[]
{
layoutItem["key"]?.GetValue<string>(),
layoutItem["isExternalContent"]?.GetValue<bool>() is not true
? layoutItem["contentKey"]?.GetValue<string>()
: null,
layoutItem["settingsKey"]?.GetValue<string>(),
})
.WhereNotNull()
// grab all keys from the objects of contentData and settingsData
var keys = contentData.Select(c => c?["key"])
.Union(settingsData.Select(s => s?["key"]))
.Select(keyToken => keyToken?.GetValue<string>().NullOrWhiteSpaceAsNull())
.ToArray();
// the following is solely for avoiding functionality wise breakage. we should consider removing it eventually, but for the time being it's harmless.
@@ -127,7 +127,7 @@ public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockVal
}
private static bool IsBlockEditorDataEmpty([NotNullWhen(false)] BlockEditorData<TValue, TLayout>? editorData)
=> editorData is null || editorData.BlockValue.Layout.Count == 0;
=> editorData is null || editorData.BlockValue.ContentData.Count == 0;
// We don't throw on error here because we want to be able to parse what we can, even if some of the data is invalid. In cases where migrating
// from nested content to blocks, we don't want to trigger a fatal error for retrieving references, as this isn't vital to the operation.
@@ -63,20 +63,13 @@ public class BlockEditorValues<TValue, TLayout>
private BlockEditorData<TValue, TLayout>? Clean(BlockEditorData<TValue, TLayout> blockEditorData)
{
if (blockEditorData.BlockValue.Layout.Count == 0)
if (blockEditorData.BlockValue.ContentData.Count == 0)
{
// if there's no content ensure there's no settings too
blockEditorData.BlockValue.SettingsData.Clear();
return null;
}
if (blockEditorData.BlockValue.ContentData.Count == 0
&& blockEditorData.BlockValue.SettingsData.Count == 0)
{
// no local content or settings; the block editor must contain only global elements
return blockEditorData;
}
var contentTypePropertyTypes = new Dictionary<string, Dictionary<string, IPropertyType>>();
// filter out any content that isn't referenced in the layout references
@@ -26,7 +26,7 @@ public class BlockGridPropertyEditor : BlockGridPropertyEditorBase
public BlockGridPropertyEditor(
IDataValueEditorFactory dataValueEditorFactory,
IIOHelper ioHelper,
IBlockGridPropertyIndexValueFactory blockValuePropertyIndexValueFactory)
IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory)
: base(dataValueEditorFactory, blockValuePropertyIndexValueFactory)
=> _ioHelper = ioHelper;
@@ -25,9 +25,9 @@ namespace Umbraco.Cms.Core.PropertyEditors;
/// </summary>
public abstract class BlockGridPropertyEditorBase : DataEditor, IValueSchemaProvider
{
private readonly IBlockGridPropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
private readonly IBlockValuePropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockGridPropertyIndexValueFactory blockValuePropertyIndexValueFactory)
protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory)
: base(dataValueEditorFactory)
{
_blockValuePropertyIndexValueFactory = blockValuePropertyIndexValueFactory;
@@ -1,23 +0,0 @@
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.PropertyEditors;
internal sealed class BlockGridPropertyIndexValueFactory
: BlockValuePropertyIndexValueFactoryBase<BlockGridValue>, IBlockGridPropertyIndexValueFactory
{
public BlockGridPropertyIndexValueFactory(
PropertyEditorCollection propertyEditorCollection,
IElementService elementService,
IJsonSerializer jsonSerializer,
IOptionsMonitor<IndexingSettings> indexingSettings)
: base(propertyEditorCollection, elementService, jsonSerializer, indexingSettings)
{
}
protected override IEnumerable<RawDataItem> GetDataItems(BlockGridValue input, bool published)
=> GetDataItems(input.GetLayouts() ?? [], input.ContentData, input.Expose, published);
}
@@ -25,7 +25,7 @@ public class BlockListPropertyEditor : BlockListPropertyEditorBase
public BlockListPropertyEditor(
IDataValueEditorFactory dataValueEditorFactory,
IIOHelper ioHelper,
IBlockListPropertyIndexValueFactory blockValuePropertyIndexValueFactory,
IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory,
IJsonSerializer jsonSerializer)
: base(dataValueEditorFactory, blockValuePropertyIndexValueFactory, jsonSerializer)
=> _ioHelper = ioHelper;
@@ -21,13 +21,13 @@ namespace Umbraco.Cms.Core.PropertyEditors;
/// </summary>
public abstract class BlockListPropertyEditorBase : DataEditor, IValueSchemaProvider
{
private readonly IBlockListPropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
private readonly IBlockValuePropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
private readonly IJsonSerializer _jsonSerializer;
/// <summary>
/// Initializes a new instance of the <see cref="BlockListPropertyEditorBase"/> class.
/// </summary>
protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockListPropertyIndexValueFactory blockValuePropertyIndexValueFactory, IJsonSerializer jsonSerializer)
protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory, IJsonSerializer jsonSerializer)
: base(dataValueEditorFactory)
{
_blockValuePropertyIndexValueFactory = blockValuePropertyIndexValueFactory;
@@ -1,26 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.PropertyEditors;
internal sealed class BlockListPropertyIndexValueFactory
: BlockValuePropertyIndexValueFactoryBase<BlockListValue>, IBlockListPropertyIndexValueFactory
{
public BlockListPropertyIndexValueFactory(
PropertyEditorCollection propertyEditorCollection,
IElementService elementService,
IJsonSerializer jsonSerializer,
IOptionsMonitor<IndexingSettings> indexingSettings)
: base(propertyEditorCollection, elementService, jsonSerializer, indexingSettings)
{
}
protected override IEnumerable<RawDataItem> GetDataItems(BlockListValue input, bool published)
=> GetDataItems(input.GetLayouts() ?? [], input.ContentData, input.Expose, published);
}
@@ -0,0 +1,45 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Serialization;
namespace Umbraco.Cms.Core.PropertyEditors;
internal sealed class BlockValuePropertyIndexValueFactory :
BlockValuePropertyIndexValueFactoryBase<BlockValuePropertyIndexValueFactory.IndexValueFactoryBlockValue>,
IBlockValuePropertyIndexValueFactory
{
/// <summary>
/// Initializes a new instance of the <see cref="BlockValuePropertyIndexValueFactory"/> class.
/// </summary>
/// <param name="propertyEditorCollection">The <see cref="PropertyEditorCollection"/> containing available property editors.</param>
/// <param name="jsonSerializer">The <see cref="IJsonSerializer"/> used for serializing and deserializing JSON values.</param>
/// <param name="indexingSettings">The <see cref="IOptionsMonitor{IndexingSettings}"/> providing access to indexing configuration settings.</param>
public BlockValuePropertyIndexValueFactory(
PropertyEditorCollection propertyEditorCollection,
IJsonSerializer jsonSerializer,
IOptionsMonitor<IndexingSettings> indexingSettings)
: base(propertyEditorCollection, jsonSerializer, indexingSettings)
{
}
protected override IEnumerable<RawDataItem> GetDataItems(IndexValueFactoryBlockValue input, bool published)
=> GetDataItems(input.ContentData, input.Expose, published);
// we only care about the content data when extracting values for indexing - not the layouts nor the settings
internal sealed class IndexValueFactoryBlockValue
{
/// <summary>
/// Gets or sets the list of content block item data.
/// </summary>
public List<BlockItemData> ContentData { get; set; } = new();
/// <summary>
/// Gets or sets the collection of <see cref="BlockItemVariation"/> instances that should be exposed by the index value factory.
/// </summary>
public List<BlockItemVariation> Expose { get; set; } = new();
}
}
@@ -4,7 +4,6 @@ using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Examine;
using Umbraco.Extensions;
@@ -13,17 +12,14 @@ namespace Umbraco.Cms.Core.PropertyEditors;
internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : JsonPropertyIndexValueFactoryBase<TSerialized>
{
private readonly PropertyEditorCollection _propertyEditorCollection;
private readonly IElementService _elementService;
protected BlockValuePropertyIndexValueFactoryBase(
PropertyEditorCollection propertyEditorCollection,
IElementService elementService,
IJsonSerializer jsonSerializer,
IOptionsMonitor<IndexingSettings> indexingSettings)
: base(jsonSerializer, indexingSettings)
{
_propertyEditorCollection = propertyEditorCollection;
_elementService = elementService;
}
protected override IEnumerable<IndexValue> Handle(
@@ -110,74 +106,37 @@ internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : J
/// <summary>
/// Unwraps block item data as data items.
/// </summary>
protected IEnumerable<RawDataItem> GetDataItems(IEnumerable<IBlockLayoutItem> layouts, IList<BlockItemData> contentData, IList<BlockItemVariation> expose, bool published)
protected IEnumerable<RawDataItem> GetDataItems(IList<BlockItemData> contentData, IList<BlockItemVariation> expose, bool published)
{
List<RawDataItem> indexData;
if (published is false)
{
indexData = contentData.Select(ToRawData).ToList();
return contentData.Select(ToRawData);
}
else
var indexData = new List<RawDataItem>();
foreach (BlockItemData blockItemData in contentData)
{
indexData = new();
foreach (BlockItemData blockItemData in contentData)
var exposedCultures = expose
.Where(e => e.ContentKey == blockItemData.Key)
.Select(e => e.Culture)
.ToArray();
if (exposedCultures.Any() is false)
{
var exposedCultures = expose
.Where(e => e.ContentKey == blockItemData.Key)
.Select(e => e.Culture)
.ToArray();
if (exposedCultures.Any() is false)
{
continue;
}
if (exposedCultures.Contains(null)
|| exposedCultures.ContainsAll(blockItemData.Values.Select(v => v.Culture)))
{
indexData.Add(ToRawData(blockItemData));
continue;
}
indexData.Add(
ToRawData(
blockItemData.ContentTypeKey,
blockItemData.Values.Where(value => value.Culture is null || exposedCultures.Contains(value.Culture))));
continue;
}
}
IBlockLayoutItem[] layoutsAsArray = layouts as IBlockLayoutItem[] ?? layouts.ToArray();
if (exposedCultures.Contains(null)
|| exposedCultures.ContainsAll(blockItemData.Values.Select(v => v.Culture)))
{
indexData.Add(ToRawData(blockItemData));
continue;
}
// Get the shared element keys from all layouts.
// NOTE: While the Grid areas are modeled to contain areas within areas, in reality it cannot be configured as
// such, so this "top-level aggregation" of shared content keys works in effect.
Guid[] sharedElementKeys = layoutsAsArray
.Union(layoutsAsArray.SelectMany(l => l.GetContainedLayouts()))
.Where(l => l.IsExternalContent)
.Select(l => l.ContentKey)
.ToArray();
if (sharedElementKeys.Length > 0)
{
IEnumerable<IElement> elements = _elementService.GetByIds(sharedElementKeys);
indexData.AddRange(
elements.Select(element => new RawDataItem
{
ContentTypeKey = element.ContentType.Key,
Properties = element
.Properties
.SelectMany(property => property
.Values
.Select(value => new RawPropertyData
{
Alias = property.Alias,
Culture = value.Culture,
Value = published
? value.PublishedValue
: value.EditedValue,
}))
.ToArray(),
}));
indexData.Add(
ToRawData(
blockItemData.ContentTypeKey,
blockItemData.Values.Where(value => value.Culture is null || exposedCultures.Contains(value.Culture))));
}
return indexData;
@@ -287,29 +287,11 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
protected void MapBlockValueToEditor(IProperty property, TValue blockValue, string? culture, string? segment)
{
EnsureLayoutItemKeys(blockValue);
MapBlockItemDataToEditor(property, blockValue.ContentData, culture, segment);
MapBlockItemDataToEditor(property, blockValue.SettingsData, culture, segment);
_blockEditorVarianceHandler.AlignExposeVariance(blockValue);
}
// Ensures that all layout items have a key (for backwards data format compatibility).
private static void EnsureLayoutItemKeys(TValue blockValue)
{
if (!blockValue.Layout.TryGetValue(blockValue.PropertyEditorAlias, out IEnumerable<IBlockLayoutItem>? layout))
{
return;
}
// All layout items with an empty key will be assigned the content key of the layout item.
// This ensures data consistency across multiple sessions.
foreach (IBlockLayoutItem layoutItem in layout.Where(layoutItem => layoutItem.Key == Guid.Empty))
{
layoutItem.Key = layoutItem.ContentKey;
}
}
protected IEnumerable<Guid> ConfiguredElementTypeKeys(IBlockConfiguration configuration)
{
yield return configuration.ContentElementTypeKey;
@@ -3,7 +3,6 @@ using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Examine;
using Umbraco.Extensions;
@@ -20,16 +19,14 @@ internal sealed class RichTextPropertyIndexValueFactory : BlockValuePropertyInde
/// </summary>
/// <param name="propertyEditorCollection">A collection containing all available property editors.</param>
/// <param name="jsonSerializer">The serializer used for handling JSON data.</param>
/// <param name="elementService">Service for accessing elements.</param>
/// <param name="indexingSettings">The monitor providing current indexing settings.</param>
/// <param name="logger">The logger used for logging diagnostic information.</param>
public RichTextPropertyIndexValueFactory(
PropertyEditorCollection propertyEditorCollection,
IElementService elementService,
IJsonSerializer jsonSerializer,
IOptionsMonitor<IndexingSettings> indexingSettings,
ILogger<RichTextPropertyIndexValueFactory> logger)
: base(propertyEditorCollection, elementService, jsonSerializer, indexingSettings)
: base(propertyEditorCollection, jsonSerializer, indexingSettings)
{
_jsonSerializer = jsonSerializer;
_logger = logger;
@@ -159,7 +156,7 @@ internal sealed class RichTextPropertyIndexValueFactory : BlockValuePropertyInde
}
protected override IEnumerable<RawDataItem> GetDataItems(RichTextEditorValue input, bool published)
=> GetDataItems(input.Blocks?.GetLayouts() ?? [], input.Blocks?.ContentData ?? [], input.Blocks?.Expose ?? [], published);
=> GetDataItems(input.Blocks?.ContentData ?? [], input.Blocks?.Expose ?? [], published);
/// <summary>
/// Strips HTML tags from content, replacing them with spaces to preserve word boundaries for indexing.
@@ -27,7 +27,7 @@ public class SingleBlockPropertyEditor : DataEditor
{
private readonly IJsonSerializer _jsonSerializer;
private readonly IIOHelper _ioHelper;
private readonly ISingleBlockPropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
private readonly IBlockValuePropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SingleBlockPropertyEditor"/> class.
@@ -40,7 +40,7 @@ public class SingleBlockPropertyEditor : DataEditor
IDataValueEditorFactory dataValueEditorFactory,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
ISingleBlockPropertyIndexValueFactory blockValuePropertyIndexValueFactory)
IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory)
: base(dataValueEditorFactory)
{
_jsonSerializer = jsonSerializer;
@@ -1,26 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.PropertyEditors;
internal sealed class SingleBlockPropertyIndexValueFactory
: BlockValuePropertyIndexValueFactoryBase<SingleBlockValue>, ISingleBlockPropertyIndexValueFactory
{
public SingleBlockPropertyIndexValueFactory(
PropertyEditorCollection propertyEditorCollection,
IElementService elementService,
IJsonSerializer jsonSerializer,
IOptionsMonitor<IndexingSettings> indexingSettings)
: base(propertyEditorCollection, elementService, jsonSerializer, indexingSettings)
{
}
protected override IEnumerable<RawDataItem> GetDataItems(SingleBlockValue input, bool published)
=> GetDataItems(input.GetLayouts() ?? [], input.ContentData, input.Expose, published);
}
@@ -94,7 +94,7 @@ public sealed class BlockEditorConverter
Key = data.Key,
};
return _blockElementService.BuildElementAsync(owner, alignedData, preview).GetAwaiter().GetResult();
return _blockElementService.BuildElementAsync(alignedData, preview).GetAwaiter().GetResult();
}
/// <summary>
@@ -9,7 +9,6 @@ using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors.DeliveryApi;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
@@ -31,60 +30,10 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters
private readonly BlockEditorVarianceHandler _blockEditorVarianceHandler;
private readonly ILanguageService _languageService;
private readonly IPropertyRenderingContextAccessor _propertyRenderingContextAccessor;
private readonly IElementCacheService _elementCacheService;
/// <summary>
/// Initializes a new instance of the <see cref="BlockGridPropertyValueConverter"/> class.
/// </summary>
/// <param name="proflog">The logger used for profiling and diagnostics.</param>
/// <param name="blockConverter">The converter responsible for handling block editor values.</param>
/// <param name="jsonSerializer">The serializer used for JSON serialization and deserialization.</param>
/// <param name="apiElementBuilder">The builder for creating API elements from block data.</param>
/// <param name="constructorCache">The cache for block grid property value constructors.</param>
/// <param name="variationContextAccessor">Provides access to the current variation context.</param>
/// <param name="blockEditorVarianceHandler">Handles variance logic for block editors.</param>
/// <param name="languageService">Service for accessing all languages.</param>
/// <param name="propertyRenderingContextAccessor">Provides access to the current rendering context.</param>
/// <param name="elementCacheService">The cache for elements.</param>
public BlockGridPropertyValueConverter(
IProfilingLogger proflog,
BlockEditorConverter blockConverter,
IJsonSerializer jsonSerializer,
IApiElementBuilder apiElementBuilder,
BlockGridPropertyValueConstructorCache constructorCache,
IVariationContextAccessor variationContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler,
ILanguageService languageService,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor,
IElementCacheService elementCacheService)
{
_proflog = proflog;
_blockConverter = blockConverter;
_jsonSerializer = jsonSerializer;
_apiElementBuilder = apiElementBuilder;
_constructorCache = constructorCache;
_variationContextAccessor = variationContextAccessor;
_blockEditorVarianceHandler = blockEditorVarianceHandler;
_languageService = languageService;
_propertyRenderingContextAccessor = propertyRenderingContextAccessor;
_elementCacheService = elementCacheService;
}
/// <inheritdoc cref="BlockGridPropertyValueConverter(IProfilingLogger, BlockEditorConverter, IJsonSerializer, IApiElementBuilder, BlockGridPropertyValueConstructorCache, IVariationContextAccessor, BlockEditorVarianceHandler, ILanguageService, IPropertyRenderingContextAccessor)"/>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 20.")]
public BlockGridPropertyValueConverter(
IProfilingLogger proflog,
BlockEditorConverter blockConverter,
IJsonSerializer jsonSerializer,
IApiElementBuilder apiElementBuilder,
BlockGridPropertyValueConstructorCache constructorCache,
IVariationContextAccessor variationContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler)
: this(proflog, blockConverter, jsonSerializer, apiElementBuilder, constructorCache, variationContextAccessor, blockEditorVarianceHandler, StaticServiceProvider.Instance.GetRequiredService<ILanguageService>(), StaticServiceProvider.Instance.GetRequiredService<IPropertyRenderingContextAccessor>())
{
}
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V20.")]
public BlockGridPropertyValueConverter(
IProfilingLogger proflog,
BlockEditorConverter blockConverter,
@@ -95,17 +44,29 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters
BlockEditorVarianceHandler blockEditorVarianceHandler,
ILanguageService languageService,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor)
: this(
proflog,
blockConverter,
jsonSerializer,
apiElementBuilder,
constructorCache,
variationContextAccessor,
blockEditorVarianceHandler,
languageService,
propertyRenderingContextAccessor,
StaticServiceProvider.Instance.GetRequiredService<IElementCacheService>())
{
_proflog = proflog;
_blockConverter = blockConverter;
_jsonSerializer = jsonSerializer;
_apiElementBuilder = apiElementBuilder;
_constructorCache = constructorCache;
_variationContextAccessor = variationContextAccessor;
_blockEditorVarianceHandler = blockEditorVarianceHandler;
_languageService = languageService;
_propertyRenderingContextAccessor = propertyRenderingContextAccessor;
}
/// <inheritdoc cref="BlockGridPropertyValueConverter(IProfilingLogger, BlockEditorConverter, IJsonSerializer, IApiElementBuilder, BlockGridPropertyValueConstructorCache, IVariationContextAccessor, BlockEditorVarianceHandler, ILanguageService, IPropertyRenderingContextAccessor)"/>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public BlockGridPropertyValueConverter(
IProfilingLogger proflog,
BlockEditorConverter blockConverter,
IJsonSerializer jsonSerializer,
IApiElementBuilder apiElementBuilder,
BlockGridPropertyValueConstructorCache constructorCache,
IVariationContextAccessor variationContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler)
: this(proflog, blockConverter, jsonSerializer, apiElementBuilder, constructorCache, variationContextAccessor, blockEditorVarianceHandler, StaticServiceProvider.Instance.GetRequiredService<ILanguageService>(), StaticServiceProvider.Instance.GetRequiredService<IPropertyRenderingContextAccessor>())
{
}
@@ -119,7 +80,7 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters
/// <inheritdoc />
public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType)
=> PropertyCacheLevel.Elements;
=> PropertyCacheLevel.Element;
/// <inheritdoc />
public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview)
@@ -194,7 +155,7 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters
return null;
}
var creator = new BlockGridPropertyValueCreator(_blockConverter, _variationContextAccessor, _propertyRenderingContextAccessor, _blockEditorVarianceHandler, _elementCacheService, _jsonSerializer, _constructorCache, _languageService);
var creator = new BlockGridPropertyValueCreator(_blockConverter, _variationContextAccessor, _propertyRenderingContextAccessor, _blockEditorVarianceHandler, _jsonSerializer, _constructorCache, _languageService);
return creator.CreateBlockModelAsync(owner, referenceCacheLevel, intermediateBlockModelValue, preview, configuration.Blocks, configuration.GridColumns).GetAwaiter().GetResult();
}
}
@@ -1,6 +1,5 @@
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
@@ -17,22 +16,19 @@ internal sealed class BlockGridPropertyValueCreator : BlockPropertyValueCreatorB
/// </summary>
/// <param name="blockEditorConverter">The service used to convert block editor data into strongly typed objects.</param>
/// <param name="variationContextAccessor">Provides access to the current variation context for content.</param>
/// <param name="propertyRenderingContextAccessor">Provides access to the current rendering context.</param>
/// <param name="blockEditorVarianceHandler">Handles variance logic for block editors, such as culture or segment variations.</param>
/// <param name="jsonSerializer">The serializer used to handle JSON data for block grid properties.</param>
/// <param name="constructorCache">A cache for constructors used when creating block grid property values, improving performance.</param>
/// <param name="languageService">Service used to retrieve language information for fallback resolution.</param>
/// <param name="elementCacheService">The cache for elements.</param>
public BlockGridPropertyValueCreator(
BlockEditorConverter blockEditorConverter,
IVariationContextAccessor variationContextAccessor,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler,
IElementCacheService elementCacheService,
IJsonSerializer jsonSerializer,
BlockGridPropertyValueConstructorCache constructorCache,
ILanguageService languageService)
: base(blockEditorConverter, variationContextAccessor, propertyRenderingContextAccessor, blockEditorVarianceHandler, languageService, elementCacheService)
: base(blockEditorConverter, variationContextAccessor, propertyRenderingContextAccessor, blockEditorVarianceHandler, languageService)
{
_jsonSerializer = jsonSerializer;
_constructorCache = constructorCache;
@@ -10,7 +10,6 @@ using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors.DeliveryApi;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Extensions;
@@ -35,7 +34,6 @@ public class BlockListPropertyValueConverter : PropertyValueConverterBase, IDeli
private readonly BlockEditorVarianceHandler _blockEditorVarianceHandler;
private readonly ILanguageService _languageService;
private readonly IPropertyRenderingContextAccessor _propertyRenderingContextAccessor;
private readonly IElementCacheService _elementCacheService;
/// <summary>
/// Initializes a new instance of the <see cref="BlockListPropertyValueConverter"/> class.
@@ -50,7 +48,6 @@ public class BlockListPropertyValueConverter : PropertyValueConverterBase, IDeli
/// <param name="blockEditorVarianceHandler">Handles variance for block editors.</param>
/// <param name="languageService">Service used to retrieve language information for fallback resolution.</param>
/// <param name="propertyRenderingContextAccessor">Accessor for the current property rendering context.</param>
/// <param name="elementCacheService">The cache for elements.</param>
public BlockListPropertyValueConverter(
IProfilingLogger proflog,
BlockEditorConverter blockConverter,
@@ -61,8 +58,7 @@ public class BlockListPropertyValueConverter : PropertyValueConverterBase, IDeli
IVariationContextAccessor variationContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler,
ILanguageService languageService,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor,
IElementCacheService elementCacheService)
IPropertyRenderingContextAccessor propertyRenderingContextAccessor)
{
_proflog = proflog;
_blockConverter = blockConverter;
@@ -74,7 +70,6 @@ public class BlockListPropertyValueConverter : PropertyValueConverterBase, IDeli
_blockEditorVarianceHandler = blockEditorVarianceHandler;
_languageService = languageService;
_propertyRenderingContextAccessor = propertyRenderingContextAccessor;
_elementCacheService = elementCacheService;
}
/// <inheritdoc cref="BlockListPropertyValueConverter(IProfilingLogger, BlockEditorConverter, IContentTypeService, IApiElementBuilder, IJsonSerializer, BlockListPropertyValueConstructorCache, IVariationContextAccessor, BlockEditorVarianceHandler, ILanguageService, IPropertyRenderingContextAccessor)"/>
@@ -92,33 +87,6 @@ public class BlockListPropertyValueConverter : PropertyValueConverterBase, IDeli
{
}
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V20.")]
public BlockListPropertyValueConverter(
IProfilingLogger proflog,
BlockEditorConverter blockConverter,
IContentTypeService contentTypeService,
IApiElementBuilder apiElementBuilder,
IJsonSerializer jsonSerializer,
BlockListPropertyValueConstructorCache constructorCache,
IVariationContextAccessor variationContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler,
ILanguageService languageService,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor)
: this(
proflog,
blockConverter,
contentTypeService,
apiElementBuilder,
jsonSerializer,
constructorCache,
variationContextAccessor,
blockEditorVarianceHandler,
languageService,
propertyRenderingContextAccessor,
StaticServiceProvider.Instance.GetRequiredService<IElementCacheService>())
{
}
/// <inheritdoc />
public override bool IsConverter(IPublishedPropertyType propertyType)
=> propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.BlockList);
@@ -160,7 +128,7 @@ public class BlockListPropertyValueConverter : PropertyValueConverterBase, IDeli
/// <inheritdoc />
public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType)
=> PropertyCacheLevel.Elements;
=> PropertyCacheLevel.Element;
/// <inheritdoc />
public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview)
@@ -228,7 +196,7 @@ public class BlockListPropertyValueConverter : PropertyValueConverterBase, IDeli
return null;
}
var creator = new BlockListPropertyValueCreator(_blockConverter, _variationContextAccessor, _propertyRenderingContextAccessor, _blockEditorVarianceHandler, _elementCacheService, _jsonSerializer, _constructorCache, _languageService);
var creator = new BlockListPropertyValueCreator(_blockConverter, _variationContextAccessor, _propertyRenderingContextAccessor, _blockEditorVarianceHandler, _jsonSerializer, _constructorCache, _languageService);
return creator.CreateBlockModelAsync(owner, referenceCacheLevel, intermediateBlockModelValue, preview, configuration.Blocks).GetAwaiter().GetResult();
}
}
@@ -1,6 +1,5 @@
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
@@ -16,22 +15,19 @@ internal sealed class BlockListPropertyValueCreator : BlockPropertyValueCreatorB
/// </summary>
/// <param name="blockEditorConverter">The service used to convert block editor data into strongly typed objects.</param>
/// <param name="variationContextAccessor">Provides access to the current variation context, used for handling content variations.</param>
/// <param name="propertyRenderingContextAccessor">Provides access to the current rendering context.</param>
/// <param name="blockEditorVarianceHandler">Handles variance logic for block editors, determining how values vary by culture or segment.</param>
/// <param name="jsonSerializer">The serializer used for serializing and deserializing JSON data related to block list properties.</param>
/// <param name="constructorCache">A cache that stores constructors for block list property values to improve performance.</param>
/// <param name="languageService">Service used to retrieve language information for fallback resolution.</param>
/// <param name="elementCacheService">The cache for elements.</param>
public BlockListPropertyValueCreator(
BlockEditorConverter blockEditorConverter,
IVariationContextAccessor variationContextAccessor,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler,
IElementCacheService elementCacheService,
IJsonSerializer jsonSerializer,
BlockListPropertyValueConstructorCache constructorCache,
ILanguageService languageService)
: base(blockEditorConverter, variationContextAccessor, propertyRenderingContextAccessor, blockEditorVarianceHandler, languageService, elementCacheService)
: base(blockEditorConverter, variationContextAccessor, propertyRenderingContextAccessor, blockEditorVarianceHandler, languageService)
{
_jsonSerializer = jsonSerializer;
_constructorCache = constructorCache;
@@ -6,7 +6,6 @@ using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters;
@@ -22,7 +21,6 @@ internal abstract class BlockPropertyValueCreatorBase<TBlockModel, TBlockItemMod
private readonly IPropertyRenderingContextAccessor _propertyRenderingContextAccessor;
private readonly BlockEditorVarianceHandler _blockEditorVarianceHandler;
private readonly ILanguageService _languageService;
private readonly IElementCacheService _elementCacheService;
/// <summary>
/// Creates a specific data converter for the block property implementation.
@@ -66,14 +64,13 @@ internal abstract class BlockPropertyValueCreatorBase<TBlockModel, TBlockItemMod
/// <returns></returns>
protected delegate TBlockItemModel? EnrichBlockItemModelFromConfiguration(TBlockItemModel item, TBlockLayoutItem layoutItem, TBlockConfiguration configuration, CreateBlockItemModelFromLayout blockItemModelCreator);
protected BlockPropertyValueCreatorBase(BlockEditorConverter blockEditorConverter, IVariationContextAccessor variationContextAccessor, IPropertyRenderingContextAccessor propertyRenderingContextAccessor, BlockEditorVarianceHandler blockEditorVarianceHandler, ILanguageService languageService, IElementCacheService elementCacheService)
protected BlockPropertyValueCreatorBase(BlockEditorConverter blockEditorConverter, IVariationContextAccessor variationContextAccessor, IPropertyRenderingContextAccessor propertyRenderingContextAccessor, BlockEditorVarianceHandler blockEditorVarianceHandler, ILanguageService languageService)
{
BlockEditorConverter = blockEditorConverter;
_variationContextAccessor = variationContextAccessor;
_propertyRenderingContextAccessor = propertyRenderingContextAccessor;
_blockEditorVarianceHandler = blockEditorVarianceHandler;
_languageService = languageService;
_elementCacheService = elementCacheService;
}
protected BlockEditorConverter BlockEditorConverter { get; }
@@ -124,14 +121,17 @@ internal abstract class BlockPropertyValueCreatorBase<TBlockModel, TBlockItemMod
CreateBlockModelFromItems createModelFromItems,
EnrichBlockItemModelFromConfiguration? enrichBlockItem = null)
{
if (converted.Layout is null || converted.Layout.Any() is false)
if (converted.BlockValue.ContentData.Count == 0)
{
return createEmptyModel();
}
TBlockConfiguration[] blockConfigurationsAsArray = blockConfigurations as TBlockConfiguration[] ?? blockConfigurations.ToArray();
var blockConfigMap = blockConfigurationsAsArray.ToDictionary(bc => bc.ContentElementTypeKey);
var blockContentDataMap = converted.BlockValue.ContentData.ToDictionary(b => b.Key);
if (converted.Layout is null)
{
return createEmptyModel();
}
var blockConfigMap = blockConfigurations.ToDictionary(bc => bc.ContentElementTypeKey);
VariationContext variationContext = _variationContextAccessor.VariationContext ?? new VariationContext();
var languagesByIsoCode = (await _languageService.GetAllAsync())
.ToDictionary(l => l.IsoCode, StringComparer.OrdinalIgnoreCase);
@@ -139,33 +139,15 @@ internal abstract class BlockPropertyValueCreatorBase<TBlockModel, TBlockItemMod
// Convert the content data
var contentPublishedElements = new Dictionary<Guid, IPublishedElement>();
// Get all layouts.
// NOTE: While the Grid areas are modeled to contain areas within areas, in reality it cannot be configured as
// such, so this "top-level aggregation" of layouts works in effect.
IBlockLayoutItem[] allLayouts = converted
.Layout
.SelectMany(layout => new[] { layout }.Union(layout.GetContainedLayouts()))
.ToArray();
foreach (var layout in allLayouts)
foreach (BlockItemData data in converted.BlockValue.ContentData)
{
IPublishedElement? element = null;
BlockItemData? data = null;
if (layout.IsExternalContent)
if (!blockConfigMap.ContainsKey(data.ContentTypeKey))
{
element = await _elementCacheService.GetByKeyAsync(layout.ContentKey, preview);
if (preview is false && element?.IsPublished(variationContext.Culture) is false)
{
element = null;
}
}
else if (blockContentDataMap.TryGetValue(layout.ContentKey, out data))
{
element = BlockEditorConverter.ConvertToElement(owner, data, referenceCacheLevel, preview);
continue;
}
if (element is null)
IPublishedElement? element = BlockEditorConverter.ConvertToElement(owner, data, referenceCacheLevel, preview);
if (element == null)
{
continue;
}
@@ -180,19 +162,15 @@ internal abstract class BlockPropertyValueCreatorBase<TBlockModel, TBlockItemMod
? variationContext.Segment.NullOrWhiteSpaceAsNull()
: null;
string? resolvedCulture = null;
if (layout.IsExternalContent is false)
Fallback fallback = _propertyRenderingContextAccessor.PropertyRenderingContext?.Fallback ?? default;
if (BlockExposeFallbackHelper.IsBlockExposed(expose, element.Key, expectedBlockVariationCulture, expectedBlockVariationSegment, fallback, languagesByIsoCode, defaultIsoCode, out var resolvedCulture) is false)
{
Fallback fallback = _propertyRenderingContextAccessor.PropertyRenderingContext?.Fallback ?? default;
if (BlockExposeFallbackHelper.IsBlockExposed(expose, element.Key, expectedBlockVariationCulture, expectedBlockVariationSegment, fallback, languagesByIsoCode, defaultIsoCode, out resolvedCulture) is false)
{
continue;
}
continue;
}
// If the block was exposed via fallback to a different culture, recreate the element
// with that culture's variation context so its property values come from the resolved culture.
if (resolvedCulture is not null && resolvedCulture.InvariantEquals(expectedBlockVariationCulture) is false && data is not null)
if (resolvedCulture is not null && resolvedCulture.InvariantEquals(expectedBlockVariationCulture) is false)
{
VariationContext? originalContext = _variationContextAccessor.VariationContext;
try
@@ -3,7 +3,6 @@
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
@@ -19,9 +18,7 @@ internal sealed class RichTextBlockPropertyValueCreator : BlockPropertyValueCrea
/// </summary>
/// <param name="blockEditorConverter">The <see cref="BlockEditorConverter"/> used to convert block editor values.</param>
/// <param name="variationContextAccessor">The <see cref="IVariationContextAccessor"/> providing access to the variation context.</param>
/// <param name="propertyRenderingContextAccessor">Provides access to the current rendering context.</param>
/// <param name="blockEditorVarianceHandler">The <see cref="BlockEditorVarianceHandler"/> that handles block editor variance.</param>
/// <param name="elementCacheService">The cache for elements.</param>
/// <param name="jsonSerializer">The <see cref="IJsonSerializer"/> used for JSON serialization and deserialization.</param>
/// <param name="constructorCache">The <see cref="RichTextBlockPropertyValueConstructorCache"/> used to cache rich text block property value constructors.</param>
/// <param name="languageService">Service used to retrieve language information for fallback resolution.</param>
@@ -30,11 +27,10 @@ internal sealed class RichTextBlockPropertyValueCreator : BlockPropertyValueCrea
IVariationContextAccessor variationContextAccessor,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler,
IElementCacheService elementCacheService,
IJsonSerializer jsonSerializer,
RichTextBlockPropertyValueConstructorCache constructorCache,
ILanguageService languageService)
: base(blockEditorConverter, variationContextAccessor, propertyRenderingContextAccessor, blockEditorVarianceHandler, languageService, elementCacheService)
: base(blockEditorConverter, variationContextAccessor, propertyRenderingContextAccessor, blockEditorVarianceHandler, languageService)
{
_jsonSerializer = jsonSerializer;
_constructorCache = constructorCache;
@@ -15,7 +15,6 @@ using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors.DeliveryApi;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
@@ -47,7 +46,6 @@ public class RteBlockRenderingValueConverter : SimpleRichTextValueConverter, IDe
private readonly BlockEditorVarianceHandler _blockEditorVarianceHandler;
private readonly ILanguageService _languageService;
private readonly IPropertyRenderingContextAccessor _propertyRenderingContextAccessor;
private readonly IElementCacheService _elementCacheService;
private DeliveryApiSettings _deliveryApiSettings;
private readonly IDisposable? _deliveryApiSettingsChangeSubscription;
@@ -71,7 +69,6 @@ public class RteBlockRenderingValueConverter : SimpleRichTextValueConverter, IDe
/// <param name="deliveryApiSettingsMonitor">Monitors settings for the Delivery API.</param>
/// <param name="languageService">Service used to retrieve language information for fallback resolution.</param>
/// <param name="propertyRenderingContextAccessor">Accessor for the current property rendering context.</param>
/// <param name="elementCacheService">The cache for elements.</param>
public RteBlockRenderingValueConverter(
HtmlLocalLinkParser linkParser,
HtmlUrlParser urlParser,
@@ -88,8 +85,7 @@ public class RteBlockRenderingValueConverter : SimpleRichTextValueConverter, IDe
BlockEditorVarianceHandler blockEditorVarianceHandler,
IOptionsMonitor<DeliveryApiSettings> deliveryApiSettingsMonitor,
ILanguageService languageService,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor,
IElementCacheService elementCacheService)
IPropertyRenderingContextAccessor propertyRenderingContextAccessor)
{
_linkParser = linkParser;
_urlParser = urlParser;
@@ -106,7 +102,6 @@ public class RteBlockRenderingValueConverter : SimpleRichTextValueConverter, IDe
_blockEditorVarianceHandler = blockEditorVarianceHandler;
_languageService = languageService;
_propertyRenderingContextAccessor = propertyRenderingContextAccessor;
_elementCacheService = elementCacheService;
_deliveryApiSettings = deliveryApiSettingsMonitor.CurrentValue;
_deliveryApiSettingsChangeSubscription = deliveryApiSettingsMonitor.OnChange(settings => _deliveryApiSettings = settings);
@@ -133,45 +128,6 @@ public class RteBlockRenderingValueConverter : SimpleRichTextValueConverter, IDe
{
}
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V20.")]
public RteBlockRenderingValueConverter(
HtmlLocalLinkParser linkParser,
HtmlUrlParser urlParser,
HtmlImageSourceParser imageSourceParser,
IApiRichTextElementParser apiRichTextElementParser,
IApiRichTextMarkupParser apiRichTextMarkupParser,
IPartialViewBlockEngine partialViewBlockEngine,
BlockEditorConverter blockEditorConverter,
IJsonSerializer jsonSerializer,
IApiElementBuilder apiElementBuilder,
RichTextBlockPropertyValueConstructorCache constructorCache,
ILogger<RteBlockRenderingValueConverter> logger,
IVariationContextAccessor variationContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler,
IOptionsMonitor<DeliveryApiSettings> deliveryApiSettingsMonitor,
ILanguageService languageService,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor)
: this(
linkParser,
urlParser,
imageSourceParser,
apiRichTextElementParser,
apiRichTextMarkupParser,
partialViewBlockEngine,
blockEditorConverter,
jsonSerializer,
apiElementBuilder,
constructorCache,
logger,
variationContextAccessor,
blockEditorVarianceHandler,
deliveryApiSettingsMonitor,
languageService,
propertyRenderingContextAccessor,
StaticServiceProvider.Instance.GetRequiredService<IElementCacheService>())
{
}
/// <summary>
/// Gets the cache level for the property.
/// </summary>
@@ -372,7 +328,7 @@ public class RteBlockRenderingValueConverter : SimpleRichTextValueConverter, IDe
return null;
}
var creator = new RichTextBlockPropertyValueCreator(_blockEditorConverter, _variationContextAccessor, _propertyRenderingContextAccessor, _blockEditorVarianceHandler, _elementCacheService, _jsonSerializer, _constructorCache, _languageService);
var creator = new RichTextBlockPropertyValueCreator(_blockEditorConverter, _variationContextAccessor, _propertyRenderingContextAccessor, _blockEditorVarianceHandler, _jsonSerializer, _constructorCache, _languageService);
return creator.CreateBlockModelAsync(owner, referenceCacheLevel, blocks, preview, configuration.Blocks).GetAwaiter().GetResult();
}
@@ -12,7 +12,6 @@ using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PropertyEditors.DeliveryApi;
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Extensions;
@@ -37,7 +36,6 @@ public class SingleBlockPropertyValueConverter : PropertyValueConverterBase, IDe
private readonly BlockEditorVarianceHandler _blockEditorVarianceHandler;
private readonly ILanguageService _languageService;
private readonly IPropertyRenderingContextAccessor _propertyRenderingContextAccessor;
private readonly IElementCacheService _elementCacheService;
/// <summary>
/// Initializes a new instance of the <see cref="SingleBlockPropertyValueConverter"/> class.
@@ -51,7 +49,6 @@ public class SingleBlockPropertyValueConverter : PropertyValueConverterBase, IDe
/// <param name="blockEditorVarianceHandler">Handles variance logic for block editors.</param>
/// <param name="languageService">Service used to retrieve language information for fallback resolution.</param>
/// <param name="propertyRenderingContextAccessor">Accessor for the current property rendering context.</param>
/// <param name="elementCacheService">The cache for elements.</param>
public SingleBlockPropertyValueConverter(
IProfilingLogger proflog,
BlockEditorConverter blockConverter,
@@ -61,8 +58,7 @@ public class SingleBlockPropertyValueConverter : PropertyValueConverterBase, IDe
IVariationContextAccessor variationContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler,
ILanguageService languageService,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor,
IElementCacheService elementCacheService)
IPropertyRenderingContextAccessor propertyRenderingContextAccessor)
{
_proflog = proflog;
_blockConverter = blockConverter;
@@ -73,7 +69,6 @@ public class SingleBlockPropertyValueConverter : PropertyValueConverterBase, IDe
_blockEditorVarianceHandler = blockEditorVarianceHandler;
_languageService = languageService;
_propertyRenderingContextAccessor = propertyRenderingContextAccessor;
_elementCacheService = elementCacheService;
}
/// <inheritdoc cref="SingleBlockPropertyValueConverter(IProfilingLogger, BlockEditorConverter, IApiElementBuilder, IJsonSerializer, BlockListPropertyValueConstructorCache, IVariationContextAccessor, BlockEditorVarianceHandler, ILanguageService, IPropertyRenderingContextAccessor)"/>
@@ -90,21 +85,6 @@ public class SingleBlockPropertyValueConverter : PropertyValueConverterBase, IDe
{
}
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V20.")]
public SingleBlockPropertyValueConverter(
IProfilingLogger proflog,
BlockEditorConverter blockConverter,
IApiElementBuilder apiElementBuilder,
IJsonSerializer jsonSerializer,
BlockListPropertyValueConstructorCache constructorCache,
IVariationContextAccessor variationContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler,
ILanguageService languageService,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor)
: this(proflog, blockConverter, apiElementBuilder, jsonSerializer, constructorCache, variationContextAccessor, blockEditorVarianceHandler, languageService, propertyRenderingContextAccessor, StaticServiceProvider.Instance.GetRequiredService<IElementCacheService>())
{
}
/// <inheritdoc />
public override bool IsConverter(IPublishedPropertyType propertyType)
=> propertyType.EditorAlias.InvariantEquals(Constants.PropertyEditors.Aliases.SingleBlock);
@@ -114,7 +94,7 @@ public class SingleBlockPropertyValueConverter : PropertyValueConverterBase, IDe
/// <inheritdoc />
public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType)
=> PropertyCacheLevel.Elements;
=> PropertyCacheLevel.Element;
/// <inheritdoc />
public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object? source, bool preview)
@@ -169,7 +149,7 @@ public class SingleBlockPropertyValueConverter : PropertyValueConverterBase, IDe
}
var creator = new SingleBlockPropertyValueCreator(_blockConverter, _variationContextAccessor, _propertyRenderingContextAccessor, _blockEditorVarianceHandler, _elementCacheService, _jsonSerializer, _constructorCache, _languageService);
var creator = new SingleBlockPropertyValueCreator(_blockConverter, _variationContextAccessor, _propertyRenderingContextAccessor, _blockEditorVarianceHandler, _jsonSerializer, _constructorCache, _languageService);
return creator.CreateBlockModelAsync(owner, referenceCacheLevel, intermediateBlockModelValue, preview, configuration.Blocks).GetAwaiter().GetResult();
}
}
@@ -1,6 +1,5 @@
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
@@ -17,9 +16,7 @@ internal sealed class SingleBlockPropertyValueCreator : BlockPropertyValueCreato
/// </summary>
/// <param name="blockEditorConverter">The service used to convert block editor data.</param>
/// <param name="variationContextAccessor">Provides access to the current variation context.</param>
/// <param name="propertyRenderingContextAccessor">Provides access to the current rendering context.</param>
/// <param name="blockEditorVarianceHandler">Handles variance logic for block editors.</param>
/// <param name="elementCacheService">The cache for elements.</param>
/// <param name="jsonSerializer">The serializer used for JSON serialization and deserialization.</param>
/// <param name="constructorCache">A cache for constructors used in block list property value creation.</param>
/// <param name="languageService">Service used to retrieve language information for fallback resolution.</param>
@@ -28,11 +25,10 @@ internal sealed class SingleBlockPropertyValueCreator : BlockPropertyValueCreato
IVariationContextAccessor variationContextAccessor,
IPropertyRenderingContextAccessor propertyRenderingContextAccessor,
BlockEditorVarianceHandler blockEditorVarianceHandler,
IElementCacheService elementCacheService,
IJsonSerializer jsonSerializer,
BlockListPropertyValueConstructorCache constructorCache,
ILanguageService languageService)
: base(blockEditorConverter, variationContextAccessor, propertyRenderingContextAccessor, blockEditorVarianceHandler, languageService, elementCacheService)
: base(blockEditorConverter, variationContextAccessor, propertyRenderingContextAccessor, blockEditorVarianceHandler, languageService)
{
_jsonSerializer = jsonSerializer;
_constructorCache = constructorCache;
@@ -122,30 +122,11 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
.Where(key => key != Guid.Empty)
.ToArray();
// EntityService.GetAll returns entities in database (not Lucene score) order, which
// would discard the relevance ranking. Re-order to match the search result sequence.
IEnumerable<IEntitySlim> orderedItems;
if (keys.Length > 0)
{
var keyOrder = new Dictionary<Guid, int>(keys.Length);
for (var i = 0; i < keys.Length; i++)
{
keyOrder.TryAdd(keys[i], i);
}
orderedItems = _entityService
.GetAll(objectType, keys)
.OrderBy(entity => keyOrder.TryGetValue(entity.Key, out var index) ? index : int.MaxValue)
.ToArray();
}
else
{
orderedItems = [];
}
return Task.FromResult(new PagedModel<IEntitySlim>
{
Items = orderedItems,
Items = keys.Any()
? _entityService.GetAll(objectType, keys)
: Enumerable.Empty<IEntitySlim>(),
Total = totalFound
});
}
@@ -47,21 +47,30 @@ public class LogViewerRepository : LogViewerRepositoryBase
var filesForCurrentDay = Directory.GetFiles(_loggingConfiguration.LogDirectory, filesToFind);
// Foreach file we find - open it. Any failure reading a single file (open error,
// unrecoverable parse error, etc.) should not prevent the remaining files for the
// day or date range from being read.
// Foreach file we find - open it
foreach (var filePath in filesForCurrentDay)
{
try
// Open log file & add contents to the log collection
// Which we then use LINQ to page over
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
ReadLogFile(filePath, logFilter, logs);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Skipped log file {FilePath} after a file-level error; the file may be inaccessible or unreadable.",
filePath);
using (var stream = new StreamReader(fs))
{
var reader = new LogEventReader(stream);
while (TryRead(reader, out LogEvent? evt))
{
// We may get a null if log line is malformed
if (evt == null)
{
continue;
}
if (logFilter.TakeLogEvent(evt))
{
logs.Add(evt);
}
}
}
}
}
}
@@ -79,63 +88,6 @@ public class LogViewerRepository : LogViewerRepositoryBase
}).ToArray();
}
private void ReadLogFile(string filePath, ILogFilter logFilter, List<LogEvent> logs)
{
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var stream = new StreamReader(fs);
var reader = new LogEventReader(stream);
var errorCount = 0;
Exception? firstError = null;
while (true)
{
LogEvent? evt;
try
{
if (!reader.TryRead(out evt))
{
break;
}
}
catch (Exception ex) when (ex is Newtonsoft.Json.JsonException or InvalidDataException)
{
// Serilog.Formatting.Compact.Reader uses Newtonsoft.Json internally and surfaces
// its exceptions (Umbraco's own serialization is on System.Text.Json, but that
// doesn't apply here — we have to catch what the reader actually throws).
// JsonException covers parse failures (e.g. an unterminated string in a truncated
// entry); InvalidDataException covers structurally-valid JSON that isn't a valid
// Serilog Compact event. Either way the offending line has been consumed from the
// underlying StreamReader and the next TryRead call advances. Anything else
// (IOException, decoder failures, etc.) is propagated to the file-level catch in
// GetLogs so we don't risk a tight loop or silently swallow a more serious failure.
errorCount++;
firstError ??= ex;
continue;
}
// LogEventReader may return true with a null event for a benign skip.
if (evt is null)
{
continue;
}
if (logFilter.TakeLogEvent(evt))
{
logs.Add(evt);
}
}
if (errorCount > 0)
{
_logger.LogWarning(
firstError,
"Encountered {ErrorCount} unreadable line(s) while reading log file {FilePath}. The file may contain partially-written or corrupt entries; affected lines were skipped.",
errorCount,
filePath);
}
}
private IReadOnlyDictionary<string, string?> MapLogMessageProperties(IReadOnlyDictionary<string, LogEventPropertyValue>? properties)
{
var result = new Dictionary<string, string?>();
@@ -169,4 +121,21 @@ public class LogViewerRepository : LogViewerRepositoryBase
}
private static string GetSearchPattern(DateTime day) => $"*{day:yyyyMMdd}*.json";
private bool TryRead(LogEventReader reader, out LogEvent? evt)
{
try
{
return reader.TryRead(out evt);
}
catch (Exception ex)
{
// As we are reading/streaming one line at a time in the JSON file
// Thus we can not report the line number, as it will always be 1
_logger.LogError(ex, "Unable to parse a line in the JSON log file");
evt = null;
return true;
}
}
}
@@ -317,28 +317,21 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// <inheritdoc/>
public async Task<IEnumerable<ContentCacheNode>> GetDocumentSourcesAsync(IEnumerable<Guid> keys, bool preview = false)
{
// Batch the WHERE IN to stay within SQL Server's parameter limit.
// The configurable document seed batch size is applied upstream; this method only enforces MaxParameterCount.
Guid[] keysArray = keys as Guid[] ?? keys.ToArray();
var dtos = new List<ContentSourceDto>(keysArray.Length);
foreach (IEnumerable<Guid> group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.WhereIn<NodeDto>(x => x.UniqueId, group)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.WhereIn<NodeDto>(x => x.UniqueId, keys)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
dtos.AddRange(await Database.FetchAsync<ContentSourceDto>(sql));
}
List<ContentSourceDto> dtos = await Database.FetchAsync<ContentSourceDto>(sql);
var filtered = dtos
dtos = dtos
.Where(x => x is not null)
.Where(x => preview || ((x.PubDataRaw is not null || x.PubData is not null) && (!x.Published || x.PubName is not null)))
.ToList();
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
return filtered
return dtos
.Select(x => CreateContentNodeKit(x, serializer, preview))
.OfType<ContentCacheNode>();
}
@@ -503,27 +496,20 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// <inheritdoc/>
public async Task<IEnumerable<ContentCacheNode>> GetMediaSourcesAsync(IEnumerable<Guid> keys)
{
// Batch the WHERE IN by Constants.Sql.MaxParameterCount so callers configuring
// CacheSettings.MediaSeedBatchSize above that limit do not hit SQL Server's 2100 parameter limit.
Guid[] keysArray = keys as Guid[] ?? keys.ToArray();
var dtos = new List<ContentSourceDto>(keysArray.Length);
foreach (IEnumerable<Guid> group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
.WhereIn<NodeDto>(x => x.UniqueId, group)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
.WhereIn<NodeDto>(x => x.UniqueId, keys)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
dtos.AddRange(await Database.FetchAsync<ContentSourceDto>(sql));
}
List<ContentSourceDto> dtos = await Database.FetchAsync<ContentSourceDto>(sql);
var filtered = dtos
dtos = dtos
.Where(x => x is not null)
.ToList();
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media);
return filtered
return dtos
.Select(x => CreateMediaNodeKit(x, serializer));
}
@@ -743,135 +729,107 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// </summary>
private List<CacheRebuildPublishableContentDto> GetDocumentMetadataForNodes(List<int> nodeIds)
{
// Query content metadata with both edit and published version info.
// Query content metadata with both edit and published version info
// Uses nested join pattern to ensure we only get the published ContentVersion
// (where a DocumentVersionDto with Published=true exists).
// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
var results = new List<CacheRebuildPublishableContentDto>(nodeIds.Count);
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<DocumentDto>(x => x.Published)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "EditVersionId"),
x => Alias(x.Text, "EditName"),
x => Alias(x.VersionDate, "EditVersionDate"),
x => Alias(x.UserId, "EditWriterId"))
.AndSelect<ContentVersionDto>(
"pcv",
x => Alias(x.Id, "PublishedVersionId"),
x => Alias(x.Text, "PublishedName"),
x => Alias(x.VersionDate, "PublishedVersionDate"),
x => Alias(x.UserId, "PublishedWriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((n, d) => n.NodeId == d.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
// (where a DocumentVersionDto with Published=true exists)
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<DocumentDto>(x => x.Published)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "EditVersionId"),
x => Alias(x.Text, "EditName"),
x => Alias(x.VersionDate, "EditVersionDate"),
x => Alias(x.UserId, "EditWriterId"))
.AndSelect<ContentVersionDto>(
"pcv",
x => Alias(x.Id, "PublishedVersionId"),
x => Alias(x.Text, "PublishedName"),
x => Alias(x.VersionDate, "PublishedVersionDate"),
x => Alias(x.UserId, "PublishedWriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((n, d) => n.NodeId == d.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
// Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true.
// This ensures pcv only includes rows where there's a published DocumentVersion.
.LeftJoin<ContentVersionDto>(
j => j.InnerJoin<DocumentVersionDto>("pdv")
.On<ContentVersionDto, DocumentVersionDto>(
(left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"),
"pcv")
// Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true
// This ensures pcv only includes rows where there's a published DocumentVersion
.LeftJoin<ContentVersionDto>(
j => j.InnerJoin<DocumentVersionDto>("pdv")
.On<ContentVersionDto, DocumentVersionDto>(
(left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"),
"pcv")
.On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv")
.WhereIn<NodeDto>(x => x.NodeId, group);
.On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv")
.WhereIn<NodeDto>(x => x.NodeId, nodeIds);
results.AddRange(Database.Fetch<CacheRebuildPublishableContentDto>(sql));
}
return results;
return Database.Fetch<CacheRebuildPublishableContentDto>(sql);
}
/// <summary>
/// Gets property data for the specified node IDs using efficient JOIN on nodeId.
/// This avoids the expensive WHERE IN on versionId that causes index scans.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildPropertyDto> GetPropertyDataForNodes(List<int> nodeIds)
{
var results = new List<CacheRebuildPropertyDto>();
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
// JOIN through nodeId → versionId path for efficient query plan.
Sql<ISqlContext> sql = Sql()
.Select<PropertyDataDto>(
x => x.VersionId,
x => x.LanguageId,
x => x.Segment,
x => x.IntegerValue,
x => x.DecimalValue,
x => x.DateValue,
x => x.VarcharValue,
x => x.TextValue)
.AndSelect<PropertyTypeDto>(x => Alias(x.Alias, "PropertyAlias"))
.From<PropertyDataDto>()
.InnerJoin<PropertyTypeDto>().On<PropertyDataDto, PropertyTypeDto>((pd, pt) => pd.PropertyTypeId == pt.Id)
.InnerJoin<ContentVersionDto>().On<PropertyDataDto, ContentVersionDto>((pd, cv) => pd.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, group);
// JOIN through nodeId → versionId path for efficient query plan
Sql<ISqlContext> sql = Sql()
.Select<PropertyDataDto>(
x => x.VersionId,
x => x.LanguageId,
x => x.Segment,
x => x.IntegerValue,
x => x.DecimalValue,
x => x.DateValue,
x => x.VarcharValue,
x => x.TextValue)
.AndSelect<PropertyTypeDto>(x => Alias(x.Alias, "PropertyAlias"))
.From<PropertyDataDto>()
.InnerJoin<PropertyTypeDto>().On<PropertyDataDto, PropertyTypeDto>((pd, pt) => pd.PropertyTypeId == pt.Id)
.InnerJoin<ContentVersionDto>().On<PropertyDataDto, ContentVersionDto>((pd, cv) => pd.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, nodeIds);
results.AddRange(Database.Fetch<CacheRebuildPropertyDto>(sql));
}
return results;
return Database.Fetch<CacheRebuildPropertyDto>(sql);
}
/// <summary>
/// Gets culture variation data for the specified node IDs.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildCultureDto> GetCultureDataForNodes(List<int> nodeIds)
{
var results = new List<CacheRebuildCultureDto>();
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<ContentVersionCultureVariationDto>(x => x.VersionId, x => x.Name, x => x.UpdateDate)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<ContentVersionCultureVariationDto>()
.InnerJoin<LanguageDto>().On<ContentVersionCultureVariationDto, LanguageDto>((cv, l) => cv.LanguageId == l.Id)
.InnerJoin<ContentVersionDto>().On<ContentVersionCultureVariationDto, ContentVersionDto>((ccv, cv) => ccv.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, group);
Sql<ISqlContext> sql = Sql()
.Select<ContentVersionCultureVariationDto>(x => x.VersionId, x => x.Name, x => x.UpdateDate)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<ContentVersionCultureVariationDto>()
.InnerJoin<LanguageDto>().On<ContentVersionCultureVariationDto, LanguageDto>((cv, l) => cv.LanguageId == l.Id)
.InnerJoin<ContentVersionDto>().On<ContentVersionCultureVariationDto, ContentVersionDto>((ccv, cv) => ccv.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, nodeIds);
results.AddRange(Database.Fetch<CacheRebuildCultureDto>(sql));
}
return results;
return Database.Fetch<CacheRebuildCultureDto>(sql);
}
/// <summary>
/// Gets document culture variation data (edited status per culture) for the specified node IDs. Used for documents.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildPublishableCultureDto> GetDocumentCultureDataForNodes(List<int> nodeIds)
{
var results = new List<CacheRebuildPublishableCultureDto>();
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<DocumentCultureVariationDto>(x => x.NodeId, x => x.Edited)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<DocumentCultureVariationDto>()
.InnerJoin<LanguageDto>().On<DocumentCultureVariationDto, LanguageDto>((dcv, l) => dcv.LanguageId == l.Id)
.WhereIn<DocumentCultureVariationDto>(x => x.NodeId, group);
Sql<ISqlContext> sql = Sql()
.Select<DocumentCultureVariationDto>(x => x.NodeId, x => x.Edited)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<DocumentCultureVariationDto>()
.InnerJoin<LanguageDto>().On<DocumentCultureVariationDto, LanguageDto>((dcv, l) => dcv.LanguageId == l.Id)
.WhereIn<DocumentCultureVariationDto>(x => x.NodeId, nodeIds);
results.AddRange(Database.Fetch<CacheRebuildPublishableCultureDto>(sql));
}
return results;
return Database.Fetch<CacheRebuildPublishableCultureDto>(sql);
}
/// <summary>
@@ -1400,38 +1358,31 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// <summary>
/// Gets content metadata for the specified node IDs using efficient JOIN. Used for media and members.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildContentDto> GetContentMetadataForNodes(List<int> nodeIds)
{
var results = new List<CacheRebuildContentDto>(nodeIds.Count);
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "VersionId"),
x => Alias(x.VersionDate, "VersionDate"),
x => Alias(x.UserId, "WriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
.WhereIn<NodeDto>(x => x.NodeId, group);
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "VersionId"),
x => Alias(x.VersionDate, "VersionDate"),
x => Alias(x.UserId, "WriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
.WhereIn<NodeDto>(x => x.NodeId, nodeIds);
results.AddRange(Database.Fetch<CacheRebuildContentDto>(sql));
}
return results;
return Database.Fetch<CacheRebuildContentDto>(sql);
}
/// <summary>
@@ -1,84 +1,45 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.HybridCache.Factories;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
/// <inheritdoc/>
internal class BlockElementService : IBlockElementService
{
private readonly IPublishedContentTypeCache _publishedContentTypeCache;
private readonly IPublishedContentFactory _publishedContentFactory;
private readonly IPublishedModelFactory _publishedModelFactory;
private readonly ILanguageService _languageService;
public BlockElementService(
IPublishedContentTypeCache publishedContentTypeCache,
IPublishedContentFactory publishedContentFactory,
IPublishedModelFactory publishedModelFactory,
ILanguageService languageService)
IPublishedModelFactory publishedModelFactory)
{
_publishedContentTypeCache = publishedContentTypeCache;
_publishedContentFactory = publishedContentFactory;
_publishedModelFactory = publishedModelFactory;
_languageService = languageService;
}
/// <inheritdoc/>
public async Task<IPublishedElement?> BuildElementAsync(IPublishedElement owner, BlockItemData blockItemData, bool? preview = null)
public Task<IPublishedElement?> BuildElementAsync(BlockItemData blockItemData, bool? preview = null)
{
ILanguage[]? allLanguages = null;
ILanguage? defaultLanguage = null;
// Only convert element types - content types will cause an exception when PublishedModelFactory creates the model
IPublishedContentType? publishedContentType = _publishedContentTypeCache.Get(PublishedItemType.Element, blockItemData.ContentTypeKey);
if (publishedContentType is null || publishedContentType.IsElement is false)
{
return null;
return Task.FromResult<IPublishedElement?>(null);
}
var propertyData = new Dictionary<string, PropertyData[]>();
foreach (IGrouping<string, BlockPropertyValue> properties in blockItemData.Values.GroupBy(value => value.Alias))
{
IPublishedPropertyType? propertyType = publishedContentType.GetPropertyType(properties.Key);
if (propertyType is null)
propertyData[properties.Key] = properties.Select(property => new PropertyData
{
continue;
}
if (propertyType.VariesByCulture() && owner.ContentType.VariesByCulture() is false)
{
// Special case:
// The element property type varies by culture, but the owner element (e.g. the page) content type does not
// vary by culture. Since the created element is fully culture aware at render time, we need to replicate
// property values across all available languages, to make them available for rendering.
allLanguages ??= (await _languageService.GetAllAsync()).ToArray();
defaultLanguage ??= allLanguages.SingleOrDefault(l => l.IsDefault)
?? throw new InvalidOperationException("Could not find the default language.");
BlockPropertyValue property = properties.FirstOrDefault(p => p.Culture.InvariantEquals(defaultLanguage.IsoCode))
?? properties.First();
propertyData[properties.Key] = allLanguages.Select(language => new PropertyData
{
Culture = language.IsoCode,
Segment = property.Segment ?? string.Empty, // throws an error if there is no value
Value = property.Value,
}).ToArray();
}
else
{
propertyData[properties.Key] = properties.Select(property => new PropertyData
{
Culture = property.Culture ?? string.Empty, // throws an error if there is no value
Segment = property.Segment ?? string.Empty, // throws an error if there is no value
Value = property.Value,
}).ToArray();
}
Culture = property.Culture ?? string.Empty, // throws an error if there is no value
Segment = property.Segment ?? string.Empty, // throws an error if there is no value
Value = property.Value,
}).ToArray();
}
var published = preview is not true;
@@ -86,15 +47,9 @@ internal class BlockElementService : IBlockElementService
const string name = "n/a";
IEnumerable<string> cultures = publishedContentType.VariesByCulture()
? propertyData
.SelectMany(p => p.Value.Select(v => v.Culture))
.Where(c => c.IsNullOrWhiteSpace() is false)
.OfType<string>()
.Distinct()
: [];
var cultureInfos = cultures.ToDictionary(
var cultureInfos = (publishedContentType.VariesByCulture()
? blockItemData.Values.Select(value => value.Culture).WhereNotNull().Distinct()
: []).ToDictionary(
culture => culture,
_ => new CultureVariation
{
@@ -128,6 +83,6 @@ internal class BlockElementService : IBlockElementService
};
var result = _publishedContentFactory.ToIPublishedElement(contentCacheNode, draft);
return result.CreateModel(_publishedModelFactory);
return Task.FromResult(result.CreateModel(_publishedModelFactory));
}
}
@@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
@@ -9,23 +9,41 @@ using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
/// <summary>
/// Implements <see cref="IDomainCacheService" />, providing an in-memory cache of the configured <see cref="Domain" />s.
/// </summary>
/// <remarks>
/// The cache is lazily populated from the database on first access and kept up to date in response to domain
/// cache refresher notifications. It is registered as a singleton, so a single instance serves all requests.
/// </remarks>
public class DomainCacheService : IDomainCacheService
{
private readonly IDomainService _domainService;
private readonly ICoreScopeProvider _coreScopeProvider;
private readonly ConcurrentDictionary<int, Domain> _domains;
private bool _initialized = false;
private readonly Lock _initializationLock = new();
// Both fields are written under _initializationLock but read on the hot path (request routing) without
// it. Marking them volatile makes those lock-free reads acquire-reads, so a reader is guaranteed to see
// the fully populated dictionary and the completed-initialization flag together, never a stale or
// half-published value. This is required for correctness on weak memory models such as ARM; on x86/x64
// ordinary reads already have acquire semantics, but we cannot rely on that.
private volatile ConcurrentDictionary<int, Domain> _domains = new();
private volatile bool _initialized;
/// <summary>
/// Initializes a new instance of the <see cref="DomainCacheService" /> class.
/// </summary>
/// <param name="domainService">The service used to load domains from the database.</param>
/// <param name="coreScopeProvider">The provider used to create scopes for database access.</param>
public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider)
{
_domainService = domainService;
_coreScopeProvider = coreScopeProvider;
_domains = new ConcurrentDictionary<int, Domain>();
}
/// <inheritdoc />
public IEnumerable<Domain> GetAll(bool includeWildcards)
{
InitializeIfMissing();
@@ -34,22 +52,38 @@ public class DomainCacheService : IDomainCacheService
: _domains.Select(x => x.Value).OrderBy(x => x.SortOrder);
}
/// <summary>
/// Loads the domains on first access, ensuring the cache is populated before any caller reads from it.
/// </summary>
private void InitializeIfMissing()
{
// Lazy, on-demand initialization triggered by the first request to reach the cache.
// The flag must only be set to true *after* the domains have been loaded and published.
// Setting it beforehand creates a window where a concurrent caller observes _initialized == true,
// skips loading, and reads an empty domain cache. On a multi-site setup that empties domain
// resolution, causing every site to fall back to the first root node (see ContentFinderByUrlNew).
// The double-checked lock ensures a single load while concurrent readers block until it completes.
if (_initialized)
{
return;
}
_initialized = true;
LoadDomains();
lock (_initializationLock)
{
if (_initialized)
{
return;
}
LoadDomains();
_initialized = true;
}
}
/// <inheritdoc />
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false)
{
InitializeIfMissing();
// probably this could be optimized with an index
// but then we'd need a custom DomainStore of some sort
IEnumerable<Domain> list = _domains.Values.Where(x => x.ContentId == documentId);
if (includeWildcards == false)
{
@@ -66,6 +100,7 @@ public class DomainCacheService : IDomainCacheService
return documentId > 0 && GetAssigned(documentId, includeWildcards).Any();
}
/// <inheritdoc />
public void Refresh(DomainCacheRefresher.JsonPayload[] payloads)
{
foreach (DomainCacheRefresher.JsonPayload payload in payloads)
@@ -102,20 +137,23 @@ public class DomainCacheService : IDomainCacheService
continue; // anomaly
}
var newDomain = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
// Feels wierd to use key and oldvalue, but we're using neither when updating.
_domains.AddOrUpdate(
domain.Id,
new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder),
(key, oldValue) => newDomain);
_domains[domain.Id] = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
break;
}
}
}
/// <summary>
/// Reads the configured domains from the database into a fresh dictionary and atomically swaps it in
/// as the current cache.
/// </summary>
private void LoadDomains()
{
// Build the replacement set in a local dictionary and publish it with a single write to the
// (volatile) _domains field. A reader never observes a partially populated cache during a RefreshAll
// rebuild, and the published set contains exactly the current domains (any removed since the last
// load are absent).
var newDomains = new ConcurrentDictionary<int, Domain>();
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
{
scope.ReadLock(Constants.Locks.Domains);
@@ -124,11 +162,11 @@ public class DomainCacheService : IDomainCacheService
.Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false)
.Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder)))
{
_domains.AddOrUpdate(domain.Id, domain, (key, oldValue) => domain);
newDomains[domain.Id] = domain;
}
scope.Complete();
}
_domains = newDomains;
}
}
@@ -77,8 +77,6 @@ public class UmbracoApplicationBuilder : IUmbracoApplicationBuilder, IUmbracoEnd
// Only use backoffice rewrites if backoffice is enabled
if (ApplicationServices.GetService<IBackOfficeEnabledMarker>() is not null)
{
// Must run before the rewriter so the cache-bust hash is still present on the request path.
AppBuilder.UseUmbracoBackOfficeCacheHeaders();
AppBuilder.UseUmbracoBackOfficeRewrites();
}
+1 -9
View File
@@ -62,8 +62,7 @@ Umbraco.Web.Common/
│ └── UmbracoPublishedContentCultureProvider.cs
├── Middleware/
│ ├── BootFailedMiddleware.cs # Startup failure handling (81 lines)
── PreviewAuthenticationMiddleware.cs # Preview mode auth (84 lines)
│ └── UmbracoBackOfficeCacheHeadersMiddleware.cs # Cache-Control on cache-busted backoffice asset path
── PreviewAuthenticationMiddleware.cs # Preview mode auth (84 lines)
├── Routing/
│ ├── IAreaRoutes.cs # Area routing interface
│ ├── IRoutableDocumentFilter.cs # Content routing filter
@@ -257,8 +256,6 @@ ASP.NET Core Identity sign-in manager for members.
### Middleware
**Convention**: middleware lives in `Middleware/` as a class implementing `IMiddleware`, registered as a singleton next to its dependencies' registration (generic middleware in `AddWebComponents`; feature-specific middleware where the feature's services are added, e.g. backoffice middleware in `AddBackOfficeCore`), and wired into the pipeline via `app.UseMiddleware<TMiddleware>()`. Companion `IApplicationBuilder` extension methods are thin one-line `UseMiddleware<T>()` wrappers — inline `builder.Use(async …)` lambdas bypass DI and are harder to test; `CspNonceExtensions` and `Web.UI/WebApplicationExtensions` are tiny pre-existing exceptions, not a precedent for new work.
**BootFailedMiddleware** (lines 17-81):
- Intercepts requests when `RuntimeLevel == BootFailed`
- Debug mode: Rethrows exception for stack trace
@@ -269,11 +266,6 @@ ASP.NET Core Identity sign-in manager for members.
- Skips client-side requests and backoffice paths
- Uses `IPreviewService.TryGetPreviewClaimsIdentityAsync()`
**UmbracoBackOfficeCacheHeadersMiddleware**:
- Sets `Cache-Control: public, max-age=31536000, immutable` on responses under the cache-busted backoffice asset prefix (`/umbraco/backoffice/<hash>/…`); `no-cache` in debug mode
- Runs before `UseUmbracoBackOfficeRewrites` so the original (hash-bearing) path can be matched
- Non-destructive: uses `Response.OnStarting` + `ContainsKey` guard so any consumer override wins
---
## 4. Routing
@@ -229,19 +229,6 @@ public static class ApplicationBuilderExtensions
return app;
}
/// <summary>
/// Registers <see cref="UmbracoBackOfficeCacheHeadersMiddleware"/> to set the default
/// <c>Cache-Control</c> header on responses served from the cache-busted BackOffice assets path.
/// </summary>
/// <remarks>
/// See <see cref="UmbracoBackOfficeCacheHeadersMiddleware"/> for behaviour, debug-mode semantics,
/// and the precedence rules for consumer overrides. Must be registered before
/// <see cref="UseUmbracoBackOfficeRewrites"/> so that the original request path (still containing
/// the cache-bust hash) can be matched.
/// </remarks>
public static IApplicationBuilder UseUmbracoBackOfficeCacheHeaders(this IApplicationBuilder builder)
=> builder.UseMiddleware<UmbracoBackOfficeCacheHeadersMiddleware>();
/// <summary>
/// Configure a virtual path with IApplicationBuilder.UseRewriter for BackOffice assets to allow cache-busting using the url
/// /umbraco/backoffice/!cache-busting-id!/assets/index.js => /umbraco/backoffice/assets/index.js.
@@ -1,100 +0,0 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;
using Umbraco.Cms.Web.Common.Hosting;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
namespace Umbraco.Cms.Web.Common.Middleware;
/// <summary>
/// Sets the default <c>Cache-Control</c> response header on requests served from the cache-busted
/// BackOffice assets path (<c>/umbraco/backoffice/&lt;hash&gt;/...</c>).
/// </summary>
/// <remarks>
/// <para>
/// The path prefix contains a deployment-wide hash derived from the Umbraco version
/// (see <see cref="IBackOfficePathGenerator.BackOfficeCacheBustHash"/>). Because the URL itself
/// changes whenever the version changes, all responses served under that prefix are safe to mark
/// as <c>immutable</c> with a long <c>max-age</c>, regardless of whether the on-disk filename
/// contains a content hash.
/// </para>
/// <para>
/// In debug mode the underlying built assets may change while the app is running (typically
/// from a developer rebuilding the backoffice without restarting the host). The header is
/// therefore set to <c>no-cache</c>, which still allows the browser to store the response
/// but forces an <c>If-None-Match</c> revalidation on the next request — yielding fast 304s
/// when nothing has changed and full 200s when the file on disk has been rebuilt.
/// <c>no-store</c> would force a full re-download on every request, which is unnecessary.
/// </para>
/// <para>
/// This middleware is non-destructive to consumer customisation:
/// <list type="bullet">
/// <item>
/// The header is only set when no <c>Cache-Control</c> value is already present on the
/// response, so synchronous overrides written upstream (including
/// <c>StaticFileOptions.OnPrepareResponse</c>) take precedence.
/// </item>
/// <item>
/// The header is set via <c>HttpResponse.OnStarting</c>; consumer callbacks registered
/// later in the pipeline fire first (LIFO) and can therefore override the default.
/// </item>
/// <item>
/// Non-2xx responses (e.g. 404) are not marked as immutable to avoid long-lived caching
/// of error responses.
/// </item>
/// </list>
/// </para>
/// <para>
/// Must run before <see cref="Umbraco.Extensions.ApplicationBuilderExtensions.UseUmbracoBackOfficeRewrites"/>
/// so the original request path (still containing the cache-bust hash) can be matched.
/// </para>
/// </remarks>
/// <seealso cref="Microsoft.AspNetCore.Http.IMiddleware" />
public class UmbracoBackOfficeCacheHeadersMiddleware : IMiddleware
{
private readonly string _prefix;
private readonly string _headerValue;
public UmbracoBackOfficeCacheHeadersMiddleware(
IBackOfficePathGenerator backOfficePathGenerator,
IHostingEnvironment hostingEnvironment)
{
// Normalise to a single leading slash, no trailing slash — defensive against any
// future change in IBackOfficePathGenerator's output shape.
_prefix = "/" + backOfficePathGenerator.BackOfficeAssetsPath.TrimStart('/').TrimEnd('/');
_headerValue = hostingEnvironment.IsDebugMode
? "no-cache"
: "public, max-age=31536000, immutable";
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (IsCacheableAssetRequest(context.Request))
{
context.Response.OnStarting(static state =>
{
(HttpResponse response, string value) = ((HttpResponse, string))state;
if (ShouldSetCacheControl(response))
{
response.Headers[HeaderNames.CacheControl] = value;
}
return Task.CompletedTask;
}, (context.Response, _headerValue));
}
await next(context);
}
// Only GET/HEAD: POST/PUT/DELETE responses aren't cacheable in the immutable sense and
// OPTIONS is used for CORS preflight, where a long cache lifetime would prevent the
// browser from re-issuing preflights when needed.
private bool IsCacheableAssetRequest(HttpRequest request)
=> (HttpMethods.IsGet(request.Method) || HttpMethods.IsHead(request.Method))
&& request.Path.StartsWithSegments(_prefix, StringComparison.OrdinalIgnoreCase);
// Include 304 alongside 2xx: intermediate caches (CDNs, proxies) use the Cache-Control on
// the 304 response to update freshness for the cached body.
private static bool ShouldSetCacheControl(HttpResponse response)
=> response.StatusCode is (>= 200 and < 300) or 304
&& !response.Headers.ContainsKey(HeaderNames.CacheControl);
}

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