Compare commits

...
Author SHA1 Message Date
MoleandGitHub b87d519bf2 Cache: Only write to url table on a single server in load balanced environments to remove lock contention (#22890)
* Move database writes out of cache refreshers

* add tests

* Fix up tests
2026-05-20 17:08:33 +02:00
Andy Butlandandmole f255fd7bff Bump version to 17.4.2. 2026-05-20 09:18:56 +02:00
7527de7c56 Migrations: Add auto upgrade coordination for load-balanced setups (#22815)
* Add auto upgrade coordination for load balanced setups

* Add tests

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(infrastructure): move TryBecomeLeaderAsync inside try/catch in UnattendedUpgradeBackgroundService

Ensures DB exceptions thrown during migration coordination set BootFailed
rather than faulting the background service silently.

* Fix feedback

* Update src/Umbraco.Infrastructure/Install/MigrationCoordinator.cs

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

* Recheck state

* fix(tests): update concurrent race test for post-claim DetermineRuntimeLevel check

The winner now calls DetermineRuntimeLevel() once from the post-claim check
and must see Upgrading; the loser polls twice before seeing Run. Transition
the mock on the second call instead of the first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 09:18:56 +02:00
df12a3e467 Cache: Add scope-level cache version tier to reduce DB hits in bulk operations (#22563)
* Cache cacheversion on scope

* Add tests

* Cache: Use ConcurrentDictionary for the inner per-scope version map

The inner Dictionary<string, Guid> was not thread-safe. Replacing it
with ConcurrentDictionary<string, Guid> removes the hidden assumption
that the root scope is only accessed from a single thread at a time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update src/Umbraco.Core/Cache/IRepositoryCacheVersionAccessor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessorTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Skips failing test so we can run nightly. THIS NEEDS TO BE REVERTED

* Revert "Skips failing test so we can run nightly. THIS NEEDS TO BE REVERTED"

This reverts commit c34d1736c336b3fcf7803b44e88f6018fa45c275.

* Only write version once pr. scope

* Add tests

* Remove unnececary locks

* Fix thread-safety: replace HashSet with ConcurrentHashSet and use GetOrAdd to eliminate TOCTOU races

* Add unit tests for RepositoryCacheVersionService

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andreas Zerbst <andr317c@live.dk>
2026-05-20 09:18:56 +02:00
Niels LyngsøandGitHub ba29b91301 Slider: fix duplicated property editor settings properties (#22898)
* fix duplicate slider pe-settings properties

* remove comment

* avoid throws
2026-05-19 14:19:34 +00:00
Andy ButlandandGitHub 336bffe4c4 Output Caching: Correctly gate auto-registration of UseOutputCache() middleware (#22897)
Correct the gating of the call to UseOutputCache() to only proceed Umbraco managed caching via configuration is enabled, and not consider existing implementation specific registrations.
2026-05-19 16:18:21 +02:00
426e516c61 Content Workspace: Load Data-Types based on Loaded Content Types (#22886)
* Load Data-Types based on Loaded Content Types

* Update Comment

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* mergeObservables approach

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-19 15:02:13 +02:00
Andy Butland c718a3ce12 Bump version to 17.4.1. 2026-05-19 15:01:44 +02:00
Andy Butland 3aa87fec96 Bump version to 17.4.0. 2026-05-13 17:39:57 +02:00
6c5873047b Auth: Un-deprecate getLatestToken and route per-request fetches through it (#22736)
* Auth: un-deprecates getLatestToken and routes per-request fetches through it

getLatestToken is the only public API for "wait for any in-flight refresh,
trigger one if the access token has expired, then return". External and
internal consumers were warned off it without an equivalent replacement:
configureClient only helps @hey-api/openapi-ts clients, and consumers using
axios/ky/native fetch had no other gate.

- Removes the @deprecated JSDoc + UmbDeprecation.warn() call so the public
  surface no longer prints a console warning per call.
- Uses getLatestToken.bind(this) for the auth callback inside configureClient
  and the token callback inside getOpenApiConfiguration so both paths share
  the same #ensureTokenReady gate.
- Replaces the hard-coded `Authorization: Bearer [redacted]` in unlinkLogin
  and #makeLinkTokenRequest with `Bearer ${await getLatestToken()}` so those
  fetches participate in the refresh coordination rather than firing with a
  potentially-revoked cookie.

Also wires the UmbracoExtension template's entrypoint to call
authContext.configureClient(client), matching the v18 template change. The
framework awaits onInit, so this guarantees the API client is fully
configured before any element in the extension can use it.

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

* Auth: tightens UmbAuthContext correctness and accepts any hey-api client

Pulls in a batch of non-breaking improvements to UmbAuthContext that came
out of an audit on the back of the un-deprecation work in this PR:

Public surface:
- configureClient(client) now accepts a new structural UmbApiClient type
  (exported from @umbraco-cms/backoffice/http-client). Each @hey-api/openapi-ts
  generation produces a fully-bound Client<…>; the backoffice's umbHttpClient
  and an extension's regenerated client are structurally identical but TS
  treats them as distinct generic instantiations. The widened parameter lets
  extensions wire their own client without `as never` casts at call sites.
  bindDefaultInterceptors keeps its strict typeof umbHttpClient parameter
  (preserving autocomplete inside interceptor callbacks); the cast happens
  once, internally.

Correctness:
- The auth context now holds a single UmbApiInterceptorController, lazy-
  initialised on first configureClient() call. Previously each call
  instantiated a new controller, which re-provided the UmbAuthSignalerContext
  on the host and stacked listeners — visible the moment an extension also
  called configureClient. One controller for the lifetime of the host, all
  configured clients share it.
- completeAuthorizationRequest checks sessionStorage before asking
  window.opener for the PKCE verifier. The previous order hung for the full
  postMessage timeout whenever oauth_complete loaded with a non-OAuth
  window.opener (which is set for ANY window.open target). The opener
  postMessage timeout is also dropped from 5s to 1.5s — a real popup parent
  responds within milliseconds; longer is just wait time for the unrelated-
  opener case.
- The cross-tab 'authorized' BroadcastChannel handler now routes through
  #setSessionLocally so the timestamp math stays in one place. The
  'sessionUpdate' handler still applies pre-computed timestamps directly
  (peer broadcast already did the math) but does so inside the
  #inSessionUpdateCallback guard, so a synchronous session$ observer can no
  longer trigger a spurious /token refresh on top of a peer's update.
- #ensureTokenReady drops its query-then-request pattern. Now always queues
  behind the umb:token-refresh lock with a no-op callback — if the lock is
  free it acquires immediately, if held it waits. Eliminates the race window
  between query() and request().
- destroy() invokes #popupCleanup before tearing down so an in-flight popup
  flow's window-level message listener and closed-poll interval don't leak
  past the context's lifetime. The cleanup helper itself now resolves the
  popup-flow Promise — every termination path (authorized, popup closed,
  superseded by a new flow, context destroyed) is observable to the awaiter
  instead of hanging forever.

Cleanup:
- makeAuthorizationRequest is annotated Promise<void> so the redirect and
  popup branches share an explicit return type.
- unlinkLogin wraps the parsed problem-details payload in a real Error (with
  the original payload exposed on `.cause`) so callers using `instanceof
  Error` or expecting a stack trace get sane behaviour.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:57:32 +02:00
Andy Butland 58b047bf7e Published Content: Fix Fallback.ToAncestors with no match throwing exception at property level (closes #22759) (#22763)
* Fix Fallback.ToAncestors regression at property level.

* Further unit tests.
2026-05-08 10:17:20 +02:00
Jacob OvergaardandGitHub ae4ac2a4b9 build(deps): bumps @umbraco-ui/uui to 1.17.3 (#22753) 2026-05-08 08:52:11 +02:00
Andy ButlandandJacob Overgaard 17e73eee28 Color Picker: Refresh stored label when data type label changes (closes #22741) (#22761)
* Update stored color label if changed on save of document with color picker.

* Clarify intent of change event dispatch in label sync

* Make comparison case insensitive.

* Added unit tests for new behaviour.
2026-05-07 21:14:52 +02:00
8ab68b574f Backoffice: Add localize.htmlString() helper to prevent XSS in HTML-rendered translations (#22731)
* docs(claude): document how unsafeHTML should be used together with escapeHTML()

* fix: adds escapeHTML where appropriate in order not to render html directly

* chore: removes small nitpick fallback

* docs(claude): fixes incorrect using of unsafeHTML

* feat(localization): add localize.htmlString() and convert call sites

Adds a new `htmlString()` method on UmbLocalizationController that escapes
interpolated args via escapeHTML and returns a Lit unsafeHTML directive.
This is the safe replacement for the manual `unsafeHTML(this.localize.string(...))`
pattern, which leaves user-controlled args un-escaped (XSS hazard).

Converts all direct `unsafeHTML(localize.string|term(...))` call sites
across modals, rollback views, packager, property editors, and entity
actions. Also fixes the latent XSS in `trash.action.ts` (sibling of the
previously-fixed `delete.action.ts`).

Updates docs/security.md with guidance on `string()` vs `htmlString()`
and the modal-content wrapping pattern.

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

* chore(eslint): add no-unsafe-localize rule to flag unsafeHTML(localize.string|term(...))

Catches the XSS pattern this PR's helper replaces, so future regressions
are caught at lint time instead of in review (or in a security advisory).
Suggests `localize.htmlString(...)` as the safe replacement.

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

* fix(localization): stringify htmlString args before escaping

Addresses review feedback on PR #22731. escapeHTML() short-circuits on
non-strings (returns the value unchanged), so an arg like
{ toString: () => '<script>...</script>' } would bypass the escape and
render unescaped via unsafeHTML.

Stringifies args before escaping while preserving `undefined` so
string()'s placeholder semantics are unchanged. Adds a regression test
covering the toString() bypass.

Also adds the missing html/unsafeHTML imports to the security.md
example so the snippet is self-contained.

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

* fix(installer-consent-element): sanitise content before rendering it

* fix(dashboard-telem-element): sanitise html before rendering

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: LLaverty <liamlaverty@gmail.com>
2026-05-06 16:11:47 +02:00
Andy Butland 626f0a9ee1 Bump version to 17.4.0-rc3. 2026-05-06 11:39:17 +02:00
Andy ButlandandClaude Opus 4.7 cd406fba43 Remove npm/docs manual approval gates, keep MyGet-cascade fix.
The manual approval gates added for the duplicate-version rerun were
single-use scaffolding for that specific release. Remove them and
tighten Deploy_Npm and Upload_API_Docs to require Deploy_NuGet to
have actually succeeded (Succeeded or SucceededWithIssues) — so a
NuGet failure deliberately blocks the npm release and docs upload.

Keep the structural change to inspect dependencies.Deploy_NuGet.result
directly rather than rely on the transitive succeeded(). That fix is
permanent: it's what protects npm and docs from cascade-skipping
whenever MyGet has another upstream outage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:26:25 +02:00
Andy ButlandandClaude Opus 4.7 caeb354064 Allow npm release and API docs upload to run when MyGet or NuGet fails.
Both stages used implicit succeeded(), which is transitive across the
full ancestor graph. A MyGet failure (or a NuGet failure on a re-run
where the version is already published) would therefore cascade-skip
both stages even though their own work is independent of those feeds.

Switch them to inspect dependencies.Deploy_NuGet.result directly so
they remain eligible when NuGet ran and either succeeded or failed,
while still being skipped when Deploy_NuGet itself was Skipped (e.g.
non-release runs). Upload_API_Docs additionally requires Build_Docs
to have produced artifacts.

Add a manual approval gate (ManualValidation@0 server job) to each
stage so a NuGet failure caused by something genuinely unrecoverable
(e.g. expired API key) doesn't auto-promote npm or docs publishes -
the operator must explicitly approve each downstream stage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:19:53 +02:00
dec99737b7 Build pipeline: Add manual approval gate to NuGet release (#22695)
* Add manual deploy to NuGet for when MyGet publish fails.

* Simplified instructions for manual approval.

* Gate NuGet release on MyGet's direct result, not transitive succeeded/failed.

succeeded() and failed() are transitive across the full ancestor graph,
so a failure in Unit/Integration/E2E (which skips Deploy_MyGet) still made
or(succeeded(), failed()) evaluate to true and opened the approval gate
on a broken build. Inspect dependencies.Deploy_MyGet.result instead so
Deploy_NuGet only becomes eligible when MyGet itself actually ran.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:43:09 +02:00
Niels LyngsøandGitHub bbebb07e8c Blueprints: Fix creating documents from blueprints (closes #21996) (#22688)
cherry picked fix from #22422
2026-05-04 10:03:31 +00:00
f2dc9e7031 Block permissions: Correction of read-only inheritance and language access (#22522)
* remove inheritance of readonly state

* keep rendering edit in read-only mode

* INVARIANT variant id as static

* parse readonly state, without variant ids as origin is the property read-only state

* stop inheriting read only

* no need for async

* setup read only state based on user permissions

* simplify document-block-property-level-permissions

* make isPermittedForObservableVariant return undefined in bad case

* revert

* improve life cycle for extension initializer

* fix and clean-up

* clean up

* unit test for the actual problem

* clean up

* clean up

* revert logic

* transform access context into local controller

* re-introduce submit create button

* simplify match

* update js docs

* strict compare on config object level, to cover multiple conditions of the same alias.

* Revert "transform access context into local controller"

This reverts commit 1a83d9586b.

* rename file in manifest

* RTE: set manager readOnly

* set fallback on readOnly

* inherit readOnly state when block workspace is invariant

* read-only tag for Block Workspace

* make guard fallback reactive

* observe readOnly languages

* no if sentence

* observe fallback for property + name guards

* prevent cancelled context get to cause problems

* revert removal of  || this._isReadOnly check for component rendering

* add comment for clarification

* remove style import

* mark as readonly and make js-const

* remove `as const`

* unit test for reactive fallback feature

* more guard unit tests

* more variantId tests

* move block language access controller to block package

* Update base-extension-initializer.controller.ts

* fix test

* improve switch condition

* offset condition

* Block Workspace: Add data-mark for acceptance test locator

* apply entity-type to the workspace data-mark

* layout-headline

* Updated locator to use new data-mark

* Updated tests to make them less fragile

* null ctrl alias for constructor initiated observations

* import directly

* do not react to not existing user-data or missing context

* add comment

* refactor package registration logic

* package name for code editor

* leave unregistere out

* await load all bundles

Co-authored-by: Copilot <copilot@github.com>

* move initializer to app element

* Batch register extensions with validation

* remove await on load for extension initializers

* Debounce extension updates and set loaded flag

* remove unused imports

* refactor backoffice -> app

* clean up imports

* rename comment

Co-authored-by: Copilot <copilot@github.com>

* base extension initializer is loaded update

* app loader

Co-authored-by: Copilot <copilot@github.com>

* embed umbraco-packages

* remove lazy loads from dataSourceDataMapper

* revert

* enable routes to be undefined

Co-authored-by: Copilot <copilot@github.com>

* comment

Co-authored-by: Copilot <copilot@github.com>

* make sure load only calls once

Co-authored-by: Copilot <copilot@github.com>

* comments and todos

* destroy consumer if existing

* block language access tests

* load user at the end of loading all package modules

* assign symbol for is-trashed observer

* revert language readonly rules

Co-authored-by: Copilot <copilot@github.com>

* is-trashed context + observation

Co-authored-by: Copilot <copilot@github.com>

* read-only as view prop for block list

Co-authored-by: Copilot <copilot@github.com>

* readonly as view prop

* readonly prop for grid,rte,single

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Mads Rasmussen <madsr@hey.com>
Co-authored-by: Andreas Zerbst <andr317c@live.dk>
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 09:40:34 +00:00
Andy Butland 5e1aabcce6 Bump version to 17.4.0-rc2. 2026-05-04 10:15:16 +02:00
Kenn JacobsenandGitHub f08bd93793 Cherry-picked the missing constant for "unroutable" (#22672) 2026-05-01 15:49:16 +02:00
Andy ButlandandZeegaan 79e7b95253 Redirect Tracker: Prevent creation of redirects from unrouteable URLs (closes #22652, #22256) (#22657)
* Prevent creation of redirects when the old route is unroutable.

* Addressed code review feedback.

* Extend fix to handle case where a second, child page is "redirected" after preview was left open.

(cherry picked from commit 728789aaf6)
2026-05-01 16:31:30 +09:00
Zeegaan d76daf5b4e bump version 2026-05-01 16:30:27 +09:00
Isioma Nnodumandmole 0c021bedec bug(#22607) Add Directory.Packages.props and update restore command (#22608)
* bug(#22607) Add Directory.Packages.props and update restore command

Updated Dockerfile to include Directory.Packages.props and modified restore command to resolve docker build errors during dotnet restore step. Resolves issue #22607

* fix(template): conditionally copy Directory.Packages.props in Dockerfile

Only copy Directory.Packages.props when CPM is enabled, as per-project
package management users won't have this file in their build context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: mole <nikolajlauridsen@protonmail.ch>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit df3cd50e7f)
2026-04-29 11:08:08 +02:00
Mole 0af0c47f69 Docker Compose template: Improve secrets handling and add script to trust development certificates (#22613)
* Generate random guid for cert pass

* Changes from review

* Move cert generation and add script to trust cert on host machine

* Generate simple hmac key

(cherry picked from commit fcf5af3d16)
2026-04-29 11:08:04 +02:00
b49a0905d6 Repositories: Quote table and column names in raw SQL in MemberFilterRepository (closes #22615) (#22616)
* fix raw sql with ISqlSyntaxProvider name escaping

* reduce hard coded strings

* Fix Raw Sql in MemberFilterRepository

* fix formating

* remove migrations update changes

* Update src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MemberFilterRepository.cs

manually validated

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Minor code tidy.

* Add further integration tests.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-28 12:07:12 +02:00
Dirk SeefeldandAndy Butland 435c4abb42 Migrations: Fix raw SQL with ISqlSyntaxProvider table and column quoting (closes #22603) (#22604)
* fix raw sql with ISqlSyntaxProvider name escaping

* reduce hard coded strings

* Fix Raw Sql in MemberFilterRepository

* fix formating

* restore MemberFilterRepository

* Correct usage of field name constant.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-28 12:07:02 +02:00
Andy ButlandandNiels Lyngsø 3de31c4a19 Segments: Preserve segmented property values after save (closes #22166) (#22173)
* Preserve segment-specific property values after save and publish.

* Addressed feedback from code review.

* Moved fix to a projection in UmbPropertyValuePresetVariantBuilderController.

---------

Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2026-04-26 09:44:26 +02:00
Jacob Overgaard 91837ebd4d Merge branch 'release/17.4.0' of https://github.com/umbraco/Umbraco-CMS into release/17.4.0 2026-04-23 11:29:17 +02:00
Jacob Overgaard dbcc982251 Merge remote-tracking branch 'origin/main' into release/17.4.0 2026-04-23 11:28:08 +02:00
d911505a00 Slider: Add minimumRange configuration for range sliders (partially closes #22067) (#22078)
* Definition and validation of minimum range for slide property editor.

* Address code review feedback.

* Treat an incorrectly configured negative minimum range as zero.

---------

Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2026-04-23 11:27:50 +02:00
Andy Butland 86176f7461 Subscriber Server Role: Skip URL/alias persistence on subscribers with read-only databases (closes #22570) (#22572)
* Ensure that DocumentUrlService and DocumentUrlAliasService will respect read-only, subscriber databases.

* Fixed breaking change in constructor.

* Clarified comment.

* Use pattern matching in SkipDatabaseWrites() check.
2026-04-23 11:08:01 +02:00
Andy ButlandandGitHub 107cfbf9f6 Subscriber Server Role: Skip URL/alias persistence on subscribers with read-only databases (closes #22570) (#22572)
* Ensure that DocumentUrlService and DocumentUrlAliasService will respect read-only, subscriber databases.

* Fixed breaking change in constructor.

* Clarified comment.

* Use pattern matching in SkipDatabaseWrites() check.
2026-04-23 11:02:19 +02:00
Jacob Overgaard dffd60edf6 set version to 17.4.0-rc 2026-04-23 10:30:44 +02:00
Jacob Overgaard 3ab9d7c492 Merge remote-tracking branch 'origin/main' into release/17.4.0 2026-04-23 10:28:55 +02:00
Andreas ZerbstandGitHub f1eaf604e8 Nightly Pipeline: Skip E2E and Integration stages when Build fails (#22568)
Updated dependsOn so the tests dont run if build failed/cancelled
2026-04-23 12:27:48 +07:00
Andy Butland 1045ad7ae2 Document URL Aliases: De-duplicate repeated aliases to prevent upgrade failure (#22569)
* Ensure DocumentUrlAliasService safely de-duplicates repeated aliases.

* Addressed code review feedback.
2026-04-23 06:46:43 +02:00
Andy Butland 44a42352cb Bump version to 17.5.0-rc. 2026-04-23 06:43:21 +02:00
Andy ButlandandGitHub b70e2ae7bc Document URL Aliases: De-duplicate repeated aliases to prevent upgrade failure (#22569)
* Ensure DocumentUrlAliasService safely de-duplicates repeated aliases.

* Addressed code review feedback.
2026-04-22 23:40:35 +02:00
2f09fd4ca0 Frontend: Fix umb-table Firefox rendering when columns change (closes #22411) (#22414)
* fix(frontend): use keyed repeat for umb-table columns to fix Firefox rendering (#22411)

Column rendering used .map() without keys, causing Firefox's CSS
table-* layout to break when columns changed after initial render.
Switch to repeat() with column.alias keys so Lit properly inserts/removes
DOM nodes. Also removes a stray </uui-table-cell> closing tag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(frontend): wrap umb-table in Lit `keyed` so Firefox rebuilds the table when columns change

The `repeat()` + alias key change alone did not fix the Firefox issue: Firefox's
`display: table-*` layout engine fails to relayout when cells are inserted into
existing rows, even when Lit's keyed reconciliation does the right thing.

Wrap the `<uui-table>` render in `keyed(columnKey, ...)` so that whenever the
column set changes (keyed on the joined column aliases), Lit discards the entire
subtree and builds a fresh one. Firefox then paints a brand-new table and its
buggy incremental relayout path never runs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(frontend): document UmbTableColumn.alias uniqueness constraint

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): reattach sorter on column rebuild and harden column key

Address two review comments on the keyed() rebuild:

- UmbSorterController caches its container element on first
  initialization, so when keyed() replaces <uui-table> the sorter stays
  attached to the detached node. Toggle disable()/enable() in updated()
  when the column signature changes and the table is sortable, so the
  sorter reattaches to the fresh table.
- Build the column key via JSON.stringify instead of a pipe-joined
  string, so aliases containing '|' can't collide and defeat the rebuild.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 17:53:46 +01:00
Andy ButlandandGitHub 1725de6a9c Decimal: Allow decimal values when step size is not configured (closes #22127) (#22128) 2026-04-22 18:11:10 +02:00
1a316255c8 Document Management: Clear per-culture published flags when copying a document (closes #22540) (#22567)
* Content: Clear per-culture published flags when copying a document (closes #22540)

When copying a published culture-variant document, the document-level
published flag was cleared on the copy, but the per-culture published
info (mapped to umbracoDocumentCultureVariation.published) was carried
over from the source. This left the database in an inconsistent state
where the document was unpublished overall but each culture row
reported published=1.

Clear PublishCultureInfos on both the root copy and its descendants
alongside the existing Published=false assignment so no culture
variations are persisted as published on the copy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Address review feedback: use ClearPublishInfos() helper + add recursive test

- Replace direct property assignment with the existing ClearPublishInfos()
  extension method for semantic clarity and consistency with UnpublishCulture.
- Rename test to match the Can_Copy_* convention used by neighbouring tests.
- Add a second test that exercises the recursive descendant path, confirming
  per-culture published flags are also cleared on descendants.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Updates integration tests to explicitly verify the fix.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-22 15:43:08 +00:00
Andy ButlandandGitHub 183c85e560 Permissions: Route UI permission retrieval through IContentPermissionService (closes #22351) (#22400)
* Route UI permission retrieval through IContentPermissionService.

* Addressed code review feedback.

* Update OpenApi.json and client-side types.
2026-04-22 11:09:02 +00:00
1792cfe6f2 Surface controllers: validate redirect url in public surface controllers (#22561)
* fix: prevent open redirect in public surface controllers by validating RedirectUrl with Url.IsLocalUrl

* Update src/Umbraco.Web.Website/Controllers/UmbLoginStatusController.cs

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

* Update src/Umbraco.Web.Website/Controllers/UmbProfileController.cs

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

* Update src/Umbraco.Web.Website/Controllers/UmbRegisterController.cs

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

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-22 10:44:54 +00:00
Andy ButlandandGitHub 28fb93f792 Backoffice: Stop UI filtering invariant document URLs by display culture (closes #22556) (#22560)
* Avoid UI filtering invariant document URLs by display culture.

* Clarified comments.
2026-04-22 11:58:29 +02:00
9adf5307e3 Security: Prevent XXE opportunity in OEmbedProviderBase (#22550)
* test(OEmbedProviderSecurityTests): Tests for permissive DtdProcessing (CA3075)

* fix(OEmbedProviderBase): Update GetXmlResponseAsync to prevent overly-permissive DtdProcessing

https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca3075

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* refactor(OEmbed,-OEmbedTests): close string reader inputs, linting, remove check for "DTD" in exception message

* Update src/Umbraco.Core/Media/EmbedProviders/OEmbedProviderBase.cs

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-22 08:44:26 +00:00
65b85fd5d2 Relations: Swallow exceptions when retrieving references from incompatible property values (closes #22197) (#22207)
* Correct logging and swallowing of exceptions when retrieving references with changed property types.

* Addressed code review feedback.

* Change multi URL picker to fall back to returning an empty collection if the links JSON could not be deserialised.

---------

Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>
2026-04-22 10:31:56 +02:00
Andy ButlandandGitHub 00f0d2340e Cache: Gracefully handle inconsistent published version state (closes #22293) (#22296)
* Defensively handle case where published status in databse is corrupt.

* Addressed code review feedback.

* Further code review feedback.

* Similar fix for NRE in rebuild of document URLs.
2026-04-22 09:31:00 +02:00
Andy ButlandandGitHub 518adf51b0 Cache: Add deferred content type rebuild mode with de-duplication (#22194)
* Add option for rebuild following content type update in the background.

* Add integration test for deferred rebuild.

* Addressed code review feedback.

* add retry and graceful shutdown to deferred cache rebuild.

* Prevent shared DB connection in deferred rebuild background task.

* Move deferred rebuild trigger to post-scope notification.

* Introduce similar deferred behaviour for Examine reindexing.

* Prevent background cache rebuild from blocking foreground content saves.

* Handle potential case of primary key constraint violation when deferred rebuilding content cache and a content item is saved.

* Improved variable naming.
2026-04-22 07:55:12 +02:00
Andy ButlandandGitHub ef1f760847 Migrations: Fix Label long-string data type dbType (closes #22553) (#22557)
* Add migration to fix data type storage for labels configured with a long string value type.

* Fixed class name and added additional test from code review feedback.

* Further code review feedback.

* Add further test.
2026-04-22 12:46:00 +09:00
Andy ButlandandGitHub 5b3ab2ea2a Published Content Cache: Defensive hardening against race conditions (closes #22254, #22384) (#22393)
* Defensive checks against published content being cached as unavailable.

* Addressed code review feedback.

* Make field readonly.
2026-04-22 09:40:26 +09:00
Andy ButlandandGitHub 9dc49df369 Migrations: Optimise sortable value population for date properties (#22547)
* Optimise the populate sortable column migration.

* Further optimisation from code review feedback.
2026-04-22 09:17:34 +09:00
70d1a05a4e EF Core Scoping: Allow separate database connections for custom DbContexts (closes #22131) (#22133)
* Support separate database DbContexts in AddUmbracoDbContext.

* update internal callers to use new non-obsolete AddUmbracoDbContext overload

- UmbracoEFCoreComposer now calls the new overload with explicit shareUmbracoConnection: true
- Add #pragma CS0618 suppression for v18-obsolete overloads delegating to v19-obsolete overloads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update further internal caller to use non-obsolete method.

* Addressed code review feedback.

* Updates after merge/final local review.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 13:18:41 +00:00
Andy ButlandandGitHub 818019dc22 Migrations: Fix local link migration losing fragments and query strings (closes #22152) (#22153)
* Correct handling of querystring and anchors in rich text local links.

* Re-organised test class.

* Address code review comments.
2026-04-21 21:22:43 +09:00
Andy ButlandandGitHub a42cdc6656 Members: Fix SQL error when combining member type and group filters on filter endpoint (#22209)
Fix member repository filter query construction to support filter by member type and group.
2026-04-21 13:32:12 +02:00
Andy ButlandandGitHub c64f431a23 Performance: Optimize FullDataSetRepositoryCachePolicy usage across all repositories (#22264)
* Optimize ContentTypeRepository to avoid unnecessary deep-cloning on cache reads.

* Used lightweight benchmark and addressed code review comments.

* Optimize TemplateRepository to avoid unnecessary deep-cloning on cache reads.

* Optimize DomainRepository to avoid unnecessary deep-cloning on cache reads.

* Optimize remaining repositories to avoid unnecessary deep-cloning on cache reads.
2026-04-21 13:23:23 +02:00
94336588af Users: Show success dialog after creating API user (closes #21921) (#22426)
* Present dialog for further action after creating an API user.

* Addressed code review feedback.

---------

Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-04-21 11:11:47 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jacob Overgaard
d78eb98109 Bump the npm_and_yarn group across 3 directories with 4 updates (#22537)
* Bump the npm_and_yarn group across 3 directories with 4 updates

Bumps the npm_and_yarn group with 1 update in the /src/Umbraco.Web.UI.Client directory: [basic-ftp](https://github.com/patrickjuchli/basic-ftp).
Bumps the npm_and_yarn group with 2 updates in the /src/Umbraco.Web.UI.Login directory: [picomatch](https://github.com/micromatch/picomatch) and [handlebars](https://github.com/handlebars-lang/handlebars.js).
Bumps the npm_and_yarn group with 1 update in the /tests/Umbraco.Tests.AcceptanceTest directory: [lodash](https://github.com/lodash/lodash).


Updates `basic-ftp` from 5.2.2 to 5.3.0
- [Release notes](https://github.com/patrickjuchli/basic-ftp/releases)
- [Changelog](https://github.com/patrickjuchli/basic-ftp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/patrickjuchli/basic-ftp/compare/v5.2.2...v5.3.0)

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Removes `handlebars`

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Updates `lodash` from 4.17.23 to 4.18.1
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

Updates `lodash` from 4.17.23 to 4.18.1
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

Updates `lodash` from 4.17.23 to 4.18.1
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: basic-ftp
  dependency-version: 5.3.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: handlebars
  dependency-version: 
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps-dev): bumps @hey-api/openapi-ts to 0.85.2 for everything

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2026-04-21 11:02:25 +00:00
Andy ButlandandGitHub dc1ac9fb8d Boot Failed: Add missing BootFailed.html error page (closes #17144) (#22120)
* Added missing "boot failed" page and adjust gitignore to include in repository.

* Adjust base path to support virtual directory hosting.
2026-04-21 19:47:13 +09:00
Jacob Overgaard 8484bab495 Issue Deduplication: Fix tool name and add manual dispatch
The allowlist referenced mcp__github__create_issue_comment, which
doesn't exist in github-mcp-server v0.17.1 (the tool is
add_issue_comment). Claude's attempts to comment were denied, so
duplicates were labelled but no explanation comment was posted.

Also adds a workflow_dispatch trigger with an issue_number input and
enables show_full_output so future denials are visible in logs.
2026-04-21 11:49:05 +02:00
LLavertyandJacob Overgaard 3de27358d5 docs(security.md): Update Sanitize HTML documentation to prefer the umbraco-cms interface instead of DOMPurify 2026-04-21 11:37:27 +02:00
Niels LyngsøandGitHub d3d0e40fd3 Eslint: Rule for Manifest Aliases (#22316)
* eslint rule for Manifest Aliases

* update to handle propertyEditorSchema aliases

* make typescript check

* support localization alias

* Make consts for theme manifests

* no rules for themes

* fix not used, double media-type-root manifest, clean up.

* Improve pascal cases test
2026-04-21 11:32:35 +02:00
Andy ButlandandGitHub 25941fd749 Members: Add lightweight external-only members (closes #12741) (#22162)
* Models, service, repository and migration for external members.

* Integrate identity for external members in MemberUserStore.

* When autolinking external member, skip member type.

* Populate profile.

* Revoke member tokens for delivery API for external members.

* Audit notification handling.

* Management API updates for external members.

* Added IMemberFilterService for combined member queries from management API.

* Referenced by member controller with external members.

* Guard password reset for external members.

* Remove ExternalMemberSettings.

* Convert between content and external members.

* Fixed ambiguous constructor.

* Update OpenApi.json.

* Update client SDK.

* Backoffice ui for external members.

* Refactor member collection retrievel to use presentation factory.
Fixes in testing.

* Fixes from testing.

* Fix icon display on member picker.

* Add external member support to member picker value converter.

* Delete fix, sync data fix, Examine indexing, member collection default icon.

* Add cache refreshers for external members.

* Remove unused "fast path" for just updating login properties.

* Addresed code review feedback.

* Further integration tests.

* Fixed failing unit test.

* Update typed client.

* Addressed code review feedback.

* Early return to reduce nesting in ReferencedByMemberController.

* Introduce MemberPresentationService and MemberReferenceService to move logic out of controllers.

* Test for and fix SQLite deadlock related to cross-store uniqueness checks.

* Additional fix for the "content" member creation.

* Defer external member Examine indexing via the background task queue.

* Add update date to external member record (aligning with content members).

* Add TreatLoginAsMemberUpdate config so member re-index can be skipped on login.

* Add logging to help verify the indexing path chosen on login and register.

* Move ExternalMemberService into Core to align with MemberService.

* Fix deserialization issue with Json payloads.

* Display of external member profile data in backoffice.

* Fixed breaking change.

* Consider existing behaviour of bumping update date on login to be a bug, so no need for configuration and backward compatibility efforts.
2026-04-21 11:28:10 +02:00
a6e6585d42 Management API: Reduce user start node tree filtering code duplication (#22486)
* Reduce user start node tree filtering code duplication

Extract shared start node filtering logic from UserStartNodeTreeControllerBase
into a dedicated service hierarchy (IUserStartNodeTreeFilterService and
domain-specific implementations for documents and media).

Existing constructor signatures and protected members are preserved as
obsolete to maintain backward compatibility for external consumers.

* Disambiguate DI constructor resolution for tree controllers

Adds obsolete constructors accepting both the legacy dependencies and the new IDocument/IMediaStartNodeTreeFilterService to the eight concrete tree controllers and to MediaTreeControllerBase. These serve as a superset constructor that lets the DI container unambiguously resolve a single constructor, since the new and existing obsolete constructors have non-subset parameter sets and [ActivatorUtilitiesConstructor] is not honoured by CallSiteFactory at ServiceProvider validation time.

* Address review feedback

- Change constructors on DocumentStartNodeTreeFilterService and
  MediaStartNodeTreeFilterService from public to internal (classes are
  already internal).
- Add [EditorBrowsable(Never)] to the disambiguation constructors so
  IDEs hide them from autocomplete.
- Add inline comments explaining the empty-array fallback in the
  obsolete GetUserStartNodeIds/GetUserStartNodePaths overrides.

* Revert filter service constructors to public

DI container requires public constructors for activation, even on
internal classes. Reverts the internal change from the previous commit.

* Add unit tests for UserStartNodeTreeFilterService

Tests ShouldBypassStartNodeFiltering (root access, data type ignore,
no access), MapWithAccessFiltering (access/no-access/missing entities),
and delegation to IUserStartNodeEntitiesService for root, child and
sibling filtering including mixed access scenarios.

* Simplify obsolete-ctor path on document and media tree controllers (#22546)

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-21 11:26:52 +02:00
Niels LyngsøandGitHub 0afa6f30fe Block Editor: Create Modal Size Overwrite (#22386)
implement data-type config for block catagloue modal size
2026-04-21 11:17:36 +02:00
25d382b1c0 Removed line clamp for data type picker (closes #22515) (#22526)
* Removed line clamp for data type picker

* Removed line clamp on additional labels

---------

Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-04-21 10:55:31 +02:00
e4e6e04091 User Groups: Add ability to manage users directly from the group workspace (#22215)
* add users section into user group

* fix test failed

* fix unchange issue

* add notification

* add remainging count

* update take 100

* split user list into separate element

* add localization for text

* add repository for user list in user group

* update key message

* remove remainingCount from user-input

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-21 09:45:25 +01:00
Andreas ZerbstandGitHub 63fe8cfd55 E2E: QA: Added acceptance tests for member authentication (#22466)
* Added early steps of member auth

* Cleaned up

* Cleaned up again

* Cleaned up

* Fixes based on comments

* Updated name of helper

* Reverted to old smokeTest command
2026-04-21 08:17:12 +00:00
Andy ButlandandGitHub c507f43912 Relations: Fire relation notifications for automatic relations (closes #22222) (#22345)
* Emit relation saved and deleted notification when automatic relations are added and removed during content updates.

* Addressed code review feedback.
2026-04-21 10:16:25 +02:00
4fc3a56c8f V17/media notification (#22484)
* swapping from column to row

* adds same look for when you upload image on a content node

* Remove duplicated css property

---------

Co-authored-by: engjlr <enl@umbraco.dk>
Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-04-21 08:56:50 +02:00
d58d5b246b User Service: Remove IBackOfficeUserStore service location from read methods (closes #22404) (#22408)
* Avoid requirement for IBackOfficeStore registrations for non-backoffice configured setups.

* Apply same update to other read method potentially called from non backoffice setups.

* Remove comment.

* Preserve GetUserById upgrade fallback; strengthen test assertions

- Add IRuntimeState to UserService and mirror the DbException catch
  from BackOfficeUserStore.GetAsync(int) in GetUserById, so the
  upgrade-time fallback to GetForUpgrade is preserved.
- Use non-empty arguments in the delivery-only integration test so
  the repository-backed code paths are actually exercised, not just
  the early-return guards.
- Update UserServiceCrudTests to pass IRuntimeState to the new
  constructor parameter.

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

* Introduce IBackOfficeUserReader to avoid code duplication for user read methods between UserService and BackOfficeUserStore.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 08:56:31 +02:00
ed8246390c Authorization: Fix publish with descendants returning 403 with granular permissions (closes #22140) (#22148)
* Fix branch authorization from requiring recycle bin permission.

* Use named parameters.

---------

Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>
2026-04-20 13:46:19 +00:00
46c402dda9 Upgrade Screen: Detect and display correct "from" version (closes #20980) (#22387)
* fix(api): resolve correct old version on upgrade screen (closes #20980)

The upgrade screen always showed the first version of the current major
(e.g. 17.0.0) regardless of the actual database state. This was because
UpgradeSettingsFactory constructed OldVersion from just the running
app's major version number.

The fix adds UmbracoPlan.GetVersionForState() which walks the migration
transition chain and extracts version numbers from migration type
namespaces (V_{major}_{minor}_{patch} convention). RuntimeState calls
this during startup and exposes the result via a new
IRuntimeState.CurrentMigrationVersion property (with a default null
implementation to avoid breaking changes). UpgradeSettingsFactory uses
this resolved version with a fallback to the previous behaviour.

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

* fix(api): return 9.4.0 for InitialState in GetVersionForState

InitialState is the final migration state of 9.4 (the lowest supported
upgrade). Returning null caused the fallback to show <major>.0.0 for
databases at that state. Now correctly resolves to 9.4.0.

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

* chore(infrastructure): add TODO (V18) to update initialVersion when InitialState changes

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

* Added TODO for 18.

* Addressed code review feedback.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 14:25:58 +02:00
Andy ButlandandGitHub 27263c56ea Migrations: Await EF Core premigrations for OpenIddict (closes #22200) (#22205)
Await EF Core premigrations for OpenIddict.
2026-04-20 20:40:52 +09:00
Andy ButlandandGitHub afd885f358 Developer Tools: Add umb-bump-version skill for automating version bumps (#22438)
* Adds a bump version skill.

* Amends from code review.

* Further updates from code review.
2026-04-20 12:53:47 +02:00
Andy ButlandandGitHub 307e5d5c1b Backoffice Identity: Add Override method to IBackOfficeSecurityAccessor for background processing (#22499)
* Allow packages and hosted services to set an ambient backoffice identity via AsyncLocal for scenarios where no HttpContext is available.

* Addressed code review feedback.
2026-04-20 12:41:38 +02:00
0248dcc020 Performance: Avoid allocating a string if _publishedContentCache has a cached version in MediaCacheService. (#22535)
* Avoid allocating a string if _publishedContentCache has a cached version & removed preview param, it was always false

* Clarified comment, used GetCacheKey method from location where string was being created.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-20 10:03:44 +00:00
a9a370c357 Performance: Use GeneratedRegex instead of generating at runtime in string extensions (#22534)
* Use GeneratedRegex instead of generating at runtime

* Add unit tests to verify refactored code.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-20 11:31:43 +02:00
Andy ButlandandGitHub 92c5d3f6ed Templating: Correct the updated Navigation snippet (closes #22528) (#22530)
* Corrects the navigation snippet.

* Treat Model as required in Navigation snippet.
2026-04-20 11:17:15 +02:00
927eacf5c1 Update npm dependencies for v17.4.0-rc (#22464)
* update npm dependencies for v17.4.0 minor release

* update dependencies package

* fix lint errors

* remove Dribbble from lucide to simple icons

* revert @hey-api/openapi-ts bump

* chore: regenerate sdk.gen.ts

* chore: regenerate msw sw

* chore: regenerate icons

* build: excludes "mocks/tools" from being compiled

it is an isolated project and so can be used independent of the backoffice

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2026-04-20 09:06:31 +00:00
Andy ButlandandGitHub bbf5760c2d Trees: Respect 'Ignore user start nodes' on expand (closes #22487) (#22510)
* Propagate tree context's additional request args to tree item children, ensuring tree item children respect the "ignore user start nodes" data type setting for content pickers.

* Add unit tests for additional request args forwarding to tree item children manager.
2026-04-20 10:55:21 +02:00
Andy ButlandandGitHub fc87b3efda Documents: Present blueprint options from collection view Create button (closes #22529) (#22533)
* Add option to select blueprint when creating a document from a collection view.

* Addressed code review feedback.
2026-04-20 10:27:02 +02:00
Niels LyngsøandGitHub a1359abeb9 Icons: extends icon data + improved search (#22436)
* extend icons with information from theseaurus

* implement new icon search logic

* clean-up data

* sorting with a backup of the name

* refactor into a controller

* improve multi word group search

* embed lucide data

* rename tech into technology

* remove paper from dollar
2026-04-17 18:29:52 +02:00
4d27e1972d Backoffice Mocks: Fixes to Kitchen Sink mock data (#22512)
* upgrade msw, migrate all interceptors, and update backoffice integration

* Fix msw test runner integration

* wip mock sets

* align news mock data

* clean up

* add interface for mock sets

* Refactor mock DBs to use dataSet directly

* export as data

* align exports

* Update index.ts

* simplify

* remove createTemplateScaffold from data set

* remove getGroupByName from mock set

* remove getGroupWithResultsByName from mock set

* remove getIndexByName from mock set

* remove unused getSearchResultsMockData function

* Add kenn mock data set with SQLite transformation scripts

Introduces a new "kenn" mock data set generated from an Umbraco SQLite database export.

Transformation scripts (devops/sqlite-to-mock/):
- Database connection helper using sql.js
- Transform scripts for data-types, document-types, media-types, documents, media, users, templates, languages, and dictionary

Generated kenn data set includes:
- 156 data types
- 48 document types with properties, containers, and compositions
- 11 media types
- 41 documents with property values and variants
- 75 media items
- 4 users and 6 user groups
- 10 templates, 2 languages, 5 dictionary items

Usage: VITE_MOCK_SET=kenn npm run dev

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix Tab/Group container type mapping in document types

The PropertyGroupType enum in Umbraco.Core defines:
- Group = 0
- Tab = 1

The transformer had this inverted. Fixed the mapping and regenerated
document-type.data.ts with correct container types.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix user group fallbackPermissions in transformer

Read permissions from umbracoUserGroup2Permission table instead of
the empty userGroupDefaultPermissions column. Also handles mapping
legacy single-letter permission codes to new Umb.Document.* format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove deprecated createTemplateScaffold from template transformer

This function was removed from the UmbMockDataSet interface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Move sqlite-to-mock script to main package.json

Consolidate the SQLite transformation tooling into the main package by:
- Adding sql.js, @types/sql.js, and tsx as devDependencies
- Adding sqlite-to-mock script that runs transformations and lints output
- Removing the separate devops/sqlite-to-mock/package.json and lock file

This allows the transformation scripts to reuse the main node_modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* add url manager and url handlers

* Add CLI parameters to sqlite-to-mock script

The script now requires db-path and set-alias arguments:
  npm run sqlite-to-mock -- <db-path> <set-alias>

Changes:
- Add configure(), getDatabase(), getOutputDir(), closeDatabase() for lazy init
- Add CLI argument parsing with validation
- Remove direct execution calls from transform scripts
- Move eslint formatting into the script

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Generate complete mock data sets and auto-discover sets

- Add generate-supporting-files.ts to create index.ts and all
  placeholder/static files needed for a complete mock data set
- Use import.meta.glob for dynamic mock set discovery instead
  of hardcoded switch statement

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add mock handler for document type configuration

Introduces a new mock handler to serve document type configuration data, updates the mock database to provide configuration, and integrates the handler into the document type mock handlers.

* make all mock data optional

* Add custom service worker to bypass static asset requests

Introduces umbServiceWorker.js to intercept and bypass static asset requests before reaching MSW, improving startup performance in Vite development.

* fix type errors

* Update template-query.manager.ts

* change to runtime load of mock data

* Update package-lock.json

* wip mock set switcher

* Use umbMockManager.availableSetNames in mock header

* remove the test set

* clean up

* move mock files to the client project root

* rename folder

* move sqllite tool into mocks folder

* clean up

* rename folder

* update the correct tsconfig file

* Update README.md

* fix path

* mock search

* add mocks for tree siblings endpoint

* add mocks for document type and media type allowed parents

* split user permissions mock data into its own mock set

* Initialize localization registry in date test

* don't use consts. Inits too much from the modules

* Update property-editor-ui-user-picker.test.ts

* Update property-value-cloner-block-grid.cloner.test.ts

* add custom permission

* make test check for custom permission in specific mock set

* use specific mock set with specific user id

* Update document-user-permission.condition.test.ts

* Update section-user-permission.condition.test.ts

* add mock manager util to internal utils

* add import map to test runner

* Move mock-data-set.types and update imports

* Exclude internal consts in export test

* Rename mock key to userPermissions

* Register mock manifests only in development

* manual merge

* Add labels and alias/label list for mock sets

* Add visibility flag for mock sets

* delete kenn mock set

* Update mock-manager.ts

* fix(mocks): correct document type composition generation in sqlite-to-mock

The compositions were reversed - grouped by parentContentTypeId instead
of childContentTypeId. In cmsContentType2ContentType, the parent is the
composed type and the child is the type using it. Also adds inheritance
vs composition detection based on umbracoNode parentId matching.

* Update src/Umbraco.Web.UI.Client/mocks/msw-handlers/member-type/structure.handlers.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/Umbraco.Web.UI.Client/mocks/tools/sqlite-to-mock/README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/Umbraco.Web.UI.Client/mocks/db/template-detail.manager.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix mock DB slice and add safety checks

* Adds "Kitchen Sink" mock data set

* Updates to sqlite-to-mock tool

* Default mock data set tweaks

Replaces "loremflickr.com" images with local placeholders

* "Kitchen Sink" mock data updates

* feat(mocks): add member support to sqlite-to-mock tool

Add transformers for members, member types, and member groups to replace
the empty placeholder arrays. Queries cmsMember, cmsMemberType, and
cmsMember2MemberGroup tables for auth data, property visibility, and
group relationships.

* Updated "Kitchen Sink" mock data with Members

* fix(mocks): type rawData in composition-mapped files to avoid never[] inference

When all compositions arrays are empty, TypeScript infers the element
type as never, causing a type error on the .map() callback. Adding an
explicit type annotation for rawData resolves this. Also fixed in both
generators so future runs produce correctly typed output.

* feat(mocks): implement imaging resize URLs handler

Extract the umbracoFile src from media items and build resize URLs with
width, height, mode, and format query parameters. Replaces the empty
urlInfos placeholder.

* Updated placeholder images

* fix(mocks): return actual media file URLs and add missing folders endpoint

The /media/urls handler was returning ancestor-based slug paths instead
of the umbracoFile source path, causing the image cropper modal to
render a generic file preview instead of an image preview.

Also adds the missing /item/media-type/folders handler that was causing
a crash when opening the media picker.

* fix(mocks): parse JSON values stored in varcharValue column

Short JSON values like Color Picker data are stored in varcharValue
rather than textValue in SQLite. The transformers only attempted
JSON.parse on textValue, leaving varcharValue as raw strings. Now also
parses varcharValue when it starts with { or [.

Also fixes the kitchen-sink Color Picker mock data to use parsed objects.

* fix(mocks): add missing document audit log handler

Adds a handler for GET /document/{id}/audit-log that returns the shared
audit log data from the mock data set. Prevents crash in the document
workspace info view history component.

* Mock data tweaks

* move logic from msw handlers to mock services

* remove debugger

* introduce an audit log db class

---------

Co-authored-by: Mads Rasmussen <madsr@hey.com>
Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 17:05:38 +02:00
Niels LyngsøandGitHub 67f0eb5e4a Fix: parse hashtag strings for confirm dialog localization (#22490)
just parse hashtag strings for confirm dialog localization
2026-04-17 15:03:04 +00:00
Engiber LozadaandGitHub 6f0007df85 Media Picker: Use UUI breadcrumbs to prevent modal overflow with deep folder paths (closes #22286) (#22375)
Use breadcrumbs for media folder path
2026-04-17 14:59:38 +02:00
Jacob OvergaardandGitHub eb217d671a Update model version in issue-deduplication workflow 2026-04-17 14:34:34 +02:00
Andy ButlandandGitHub 722ca0476a Dependencies: Pin System.Security.Cryptography.Xml to resolve vulnerability warning (#22514)
Add direct reference to transitive dependency on System.Security.Cryptography.Xml to ensure we don't depend on a vulnerable version.
2026-04-17 14:27:48 +02:00
94603b6918 adds same drag styling as when dragging item in the content sectin (#22460)
* adds same drag styling as when dragging item in the content sectin

* remove unused loader css

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
2026-04-17 12:24:00 +00:00
Jacob OvergaardandClaude Opus 4.7 948eefb624 build: allow community-opened issues to trigger dedup workflow
Pass `github_token` and set `allowed_non_write_users: "*"` so the action
bypasses the OIDC actor check, which rejects non-maintainers with
"User does not have write access on this repository". Safe here because
`permissions:` and `--allowedTools` are tightly scoped to issue ops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:03:07 +02:00
64e6a7cf8a Backoffice Mocks: Add Webhook Mock Services + Kitchen sink mock data (#22507)
* upgrade msw, migrate all interceptors, and update backoffice integration

* Fix msw test runner integration

* wip mock sets

* align news mock data

* clean up

* add interface for mock sets

* Refactor mock DBs to use dataSet directly

* export as data

* align exports

* Update index.ts

* simplify

* remove createTemplateScaffold from data set

* remove getGroupByName from mock set

* remove getGroupWithResultsByName from mock set

* remove getIndexByName from mock set

* remove unused getSearchResultsMockData function

* Add kenn mock data set with SQLite transformation scripts

Introduces a new "kenn" mock data set generated from an Umbraco SQLite database export.

Transformation scripts (devops/sqlite-to-mock/):
- Database connection helper using sql.js
- Transform scripts for data-types, document-types, media-types, documents, media, users, templates, languages, and dictionary

Generated kenn data set includes:
- 156 data types
- 48 document types with properties, containers, and compositions
- 11 media types
- 41 documents with property values and variants
- 75 media items
- 4 users and 6 user groups
- 10 templates, 2 languages, 5 dictionary items

Usage: VITE_MOCK_SET=kenn npm run dev

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix Tab/Group container type mapping in document types

The PropertyGroupType enum in Umbraco.Core defines:
- Group = 0
- Tab = 1

The transformer had this inverted. Fixed the mapping and regenerated
document-type.data.ts with correct container types.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix user group fallbackPermissions in transformer

Read permissions from umbracoUserGroup2Permission table instead of
the empty userGroupDefaultPermissions column. Also handles mapping
legacy single-letter permission codes to new Umb.Document.* format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove deprecated createTemplateScaffold from template transformer

This function was removed from the UmbMockDataSet interface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Move sqlite-to-mock script to main package.json

Consolidate the SQLite transformation tooling into the main package by:
- Adding sql.js, @types/sql.js, and tsx as devDependencies
- Adding sqlite-to-mock script that runs transformations and lints output
- Removing the separate devops/sqlite-to-mock/package.json and lock file

This allows the transformation scripts to reuse the main node_modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* add url manager and url handlers

* Add CLI parameters to sqlite-to-mock script

The script now requires db-path and set-alias arguments:
  npm run sqlite-to-mock -- <db-path> <set-alias>

Changes:
- Add configure(), getDatabase(), getOutputDir(), closeDatabase() for lazy init
- Add CLI argument parsing with validation
- Remove direct execution calls from transform scripts
- Move eslint formatting into the script

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Generate complete mock data sets and auto-discover sets

- Add generate-supporting-files.ts to create index.ts and all
  placeholder/static files needed for a complete mock data set
- Use import.meta.glob for dynamic mock set discovery instead
  of hardcoded switch statement

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add mock handler for document type configuration

Introduces a new mock handler to serve document type configuration data, updates the mock database to provide configuration, and integrates the handler into the document type mock handlers.

* make all mock data optional

* Add custom service worker to bypass static asset requests

Introduces umbServiceWorker.js to intercept and bypass static asset requests before reaching MSW, improving startup performance in Vite development.

* fix type errors

* Update template-query.manager.ts

* change to runtime load of mock data

* Update package-lock.json

* wip mock set switcher

* Use umbMockManager.availableSetNames in mock header

* remove the test set

* clean up

* move mock files to the client project root

* rename folder

* move sqllite tool into mocks folder

* clean up

* rename folder

* update the correct tsconfig file

* Update README.md

* fix path

* mock search

* add mocks for tree siblings endpoint

* add mocks for document type and media type allowed parents

* split user permissions mock data into its own mock set

* Initialize localization registry in date test

* don't use consts. Inits too much from the modules

* Update property-editor-ui-user-picker.test.ts

* Update property-value-cloner-block-grid.cloner.test.ts

* add custom permission

* make test check for custom permission in specific mock set

* use specific mock set with specific user id

* Update document-user-permission.condition.test.ts

* Update section-user-permission.condition.test.ts

* add mock manager util to internal utils

* add import map to test runner

* Move mock-data-set.types and update imports

* Exclude internal consts in export test

* Rename mock key to userPermissions

* Register mock manifests only in development

* manual merge

* Add labels and alias/label list for mock sets

* Add visibility flag for mock sets

* delete kenn mock set

* Update mock-manager.ts

* fix(mocks): correct document type composition generation in sqlite-to-mock

The compositions were reversed - grouped by parentContentTypeId instead
of childContentTypeId. In cmsContentType2ContentType, the parent is the
composed type and the child is the type using it. Also adds inheritance
vs composition detection based on umbracoNode parentId matching.

* Update src/Umbraco.Web.UI.Client/mocks/msw-handlers/member-type/structure.handlers.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/Umbraco.Web.UI.Client/mocks/tools/sqlite-to-mock/README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/Umbraco.Web.UI.Client/mocks/db/template-detail.manager.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix mock DB slice and add safety checks

* webhook mock plan

* init webhook mock set + handlers

* Adds "Kitchen Sink" mock data set

* Updates to sqlite-to-mock tool

* Default mock data set tweaks

Replaces "loremflickr.com" images with local placeholders

* "Kitchen Sink" mock data updates

* Add paginated list and remove collection handler

* feat(mocks): add member support to sqlite-to-mock tool

Add transformers for members, member types, and member groups to replace
the empty placeholder arrays. Queries cmsMember, cmsMemberType, and
cmsMember2MemberGroup tables for auth data, property visibility, and
group relationships.

* Updated "Kitchen Sink" mock data with Members

* fix(mocks): type rawData in composition-mapped files to avoid never[] inference

When all compositions arrays are empty, TypeScript infers the element
type as never, causing a type error on the .map() callback. Adding an
explicit type annotation for rawData resolves this. Also fixed in both
generators so future runs produce correctly typed output.

* Add webhook delivery mock data and handlers

* Add webhook event mock data and handlers

* include webhooks in kitchen sink data set

* Add flags to webhook mock; fix item response

* Support pagination in webhook events handler

* Update src/Umbraco.Web.UI.Client/mocks/db/webhook-delivery.db.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update detail.handlers.ts

* Map webhook event aliases to event objects

* remove note about being created from SQL db

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
Co-authored-by: leekelleher <leekelleher@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 13:31:41 +02:00
a06c4aa4c0 Tiptap RTE: Fix Clear Formatting errors when HTML attribute extensions aren't enabled (closes #22502) (#22509)
* TipTap: Declare Clear Formatting toolbar button's extension dependencies

* Reworked to have a loose dependency

on the `class` and `style` attribute extensions

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-04-17 10:46:35 +00:00
Jacob Overgaard 13a20c8189 build: disables debug mode for workflow 2026-04-17 12:28:23 +02:00
Jacob Overgaard 4693546e34 build: enables full output to try and see why we run out of credits 2026-04-17 09:39:20 +02:00
Jacob Overgaard 21cfb0b27d build: enables progress tracking to see if/when the job fails 2026-04-17 09:38:43 +02:00
Jacob Overgaard f2f19ba5a7 build: upgrades the actions/checkout task from v4 to v6 (latest) on all workflows 2026-04-17 09:37:23 +02:00
Jacob Overgaard b7a05c005e build: removes "issues: write" permission as this job doesn't need that 2026-04-17 09:35:47 +02:00
Jacob Overgaard 8f72b079c5 build: adds "reopened" state to the claude PR review 2026-04-17 09:35:12 +02:00
Jacob Overgaard 626a78085f build: removes base_branch parameter that is not needed (has the same value as default) 2026-04-17 09:34:18 +02:00
Jacob Overgaard 80127d4647 build: this is firstly to test out Claude and make sure the flow works, but secondly also to try and reduce the number of active issues 2026-04-17 09:33:48 +02:00
a98a6aa390 Performance: Micro-optimisation in UdiParser (eliminate closure, fix naming & formatting of exceptions) (#22506)
* Eliminate closure, fix naming & formatting of exceptions

* Added unit tests around the changed code.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-17 05:51:36 +00:00
Andy ButlandandGitHub 30977bd0ad Background Jobs: Use ApplicationMainUrl as fallback for absolute URL provision (closes #22420) (#22435)
* Use configured or detected application URL as request URL fallback in background tasks when constructing absolute URLs.

* Addresed code review feedback.
2026-04-17 14:13:27 +09:00
HenrikandGitHub 48a9fb1c38 Code Quality: Use FrozenDictionary and Array instead of Dictionary and List in EntityContainer. (#22505)
Use FrozenDictionary & array instead of Dictionary & List. Fix naming
2026-04-17 07:00:03 +02:00
HenrikandGitHub a4594a3166 Code Quality: Reduce dictionary lookups within lock (#22504)
Reduce dictionary lookups within lock
2026-04-17 06:57:21 +02:00
3b5d95abaa Code Quality/Logging: Fix 'occured' -> 'occurred' typos in log/error/comment strings (#22508)
* Fix occured typo in UserBasedPreviewTokenGenerator.cs

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>

* Fix occured typo in IndexPresentationFactory.cs

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>

* Fix occured typo in app-error.element.ts

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>

* Fix occured typo in UmbracoRouteValueTransformer.cs

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>

* Fix occured typo in DocumentUrlFactory.cs

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>

* Fix occured typo in input-dropzone.element.ts

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>

* Fix occured typo in CollectibleRuntimeViewCompiler.cs

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>

* Fix occured typo in ExamineIndexRebuilder.cs

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>

* Fix occured typo in BaseTestDatabase.cs

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>

---------

Signed-off-by: SAY-5 <SAY-5@users.noreply.github.com>
Co-authored-by: SAY-5 <SAY-5@users.noreply.github.com>
2026-04-17 06:55:33 +02:00
2955911420 Dependencies: Update minor and patch versions (#22498)
* Update dependencies to latest minors and patches.

* Update test sdk

---------

Co-authored-by: Zeegaan <skrivdetud@gmail.com>
2026-04-17 06:37:52 +02:00
760f6454f4 Backoffice Mocks: Introduce Mock Sets (#22493)
* upgrade msw, migrate all interceptors, and update backoffice integration

* Fix msw test runner integration

* wip mock sets

* align news mock data

* clean up

* add interface for mock sets

* Refactor mock DBs to use dataSet directly

* export as data

* align exports

* Update index.ts

* simplify

* remove createTemplateScaffold from data set

* remove getGroupByName from mock set

* remove getGroupWithResultsByName from mock set

* remove getIndexByName from mock set

* remove unused getSearchResultsMockData function

* Add kenn mock data set with SQLite transformation scripts

Introduces a new "kenn" mock data set generated from an Umbraco SQLite database export.

Transformation scripts (devops/sqlite-to-mock/):
- Database connection helper using sql.js
- Transform scripts for data-types, document-types, media-types, documents, media, users, templates, languages, and dictionary

Generated kenn data set includes:
- 156 data types
- 48 document types with properties, containers, and compositions
- 11 media types
- 41 documents with property values and variants
- 75 media items
- 4 users and 6 user groups
- 10 templates, 2 languages, 5 dictionary items

Usage: VITE_MOCK_SET=kenn npm run dev

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix Tab/Group container type mapping in document types

The PropertyGroupType enum in Umbraco.Core defines:
- Group = 0
- Tab = 1

The transformer had this inverted. Fixed the mapping and regenerated
document-type.data.ts with correct container types.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix user group fallbackPermissions in transformer

Read permissions from umbracoUserGroup2Permission table instead of
the empty userGroupDefaultPermissions column. Also handles mapping
legacy single-letter permission codes to new Umb.Document.* format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove deprecated createTemplateScaffold from template transformer

This function was removed from the UmbMockDataSet interface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Move sqlite-to-mock script to main package.json

Consolidate the SQLite transformation tooling into the main package by:
- Adding sql.js, @types/sql.js, and tsx as devDependencies
- Adding sqlite-to-mock script that runs transformations and lints output
- Removing the separate devops/sqlite-to-mock/package.json and lock file

This allows the transformation scripts to reuse the main node_modules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* add url manager and url handlers

* Add CLI parameters to sqlite-to-mock script

The script now requires db-path and set-alias arguments:
  npm run sqlite-to-mock -- <db-path> <set-alias>

Changes:
- Add configure(), getDatabase(), getOutputDir(), closeDatabase() for lazy init
- Add CLI argument parsing with validation
- Remove direct execution calls from transform scripts
- Move eslint formatting into the script

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Generate complete mock data sets and auto-discover sets

- Add generate-supporting-files.ts to create index.ts and all
  placeholder/static files needed for a complete mock data set
- Use import.meta.glob for dynamic mock set discovery instead
  of hardcoded switch statement

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add mock handler for document type configuration

Introduces a new mock handler to serve document type configuration data, updates the mock database to provide configuration, and integrates the handler into the document type mock handlers.

* make all mock data optional

* Add custom service worker to bypass static asset requests

Introduces umbServiceWorker.js to intercept and bypass static asset requests before reaching MSW, improving startup performance in Vite development.

* fix type errors

* Update template-query.manager.ts

* change to runtime load of mock data

* Update package-lock.json

* wip mock set switcher

* Use umbMockManager.availableSetNames in mock header

* remove the test set

* clean up

* move mock files to the client project root

* rename folder

* move sqllite tool into mocks folder

* clean up

* rename folder

* update the correct tsconfig file

* Update README.md

* fix path

* mock search

* add mocks for tree siblings endpoint

* add mocks for document type and media type allowed parents

* split user permissions mock data into its own mock set

* Initialize localization registry in date test

* don't use consts. Inits too much from the modules

* Update property-editor-ui-user-picker.test.ts

* Update property-value-cloner-block-grid.cloner.test.ts

* add custom permission

* make test check for custom permission in specific mock set

* use specific mock set with specific user id

* Update document-user-permission.condition.test.ts

* Update section-user-permission.condition.test.ts

* add mock manager util to internal utils

* add import map to test runner

* Move mock-data-set.types and update imports

* Exclude internal consts in export test

* Rename mock key to userPermissions

* Register mock manifests only in development

* manual merge

* Add labels and alias/label list for mock sets

* Add visibility flag for mock sets

* delete kenn mock set

* Update mock-manager.ts

* fix(mocks): correct document type composition generation in sqlite-to-mock

The compositions were reversed - grouped by parentContentTypeId instead
of childContentTypeId. In cmsContentType2ContentType, the parent is the
composed type and the child is the type using it. Also adds inheritance
vs composition detection based on umbracoNode parentId matching.

* Update src/Umbraco.Web.UI.Client/mocks/msw-handlers/member-type/structure.handlers.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/Umbraco.Web.UI.Client/mocks/tools/sqlite-to-mock/README.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/Umbraco.Web.UI.Client/mocks/db/template-detail.manager.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix mock DB slice and add safety checks

* Adds "Kitchen Sink" mock data set

* Updates to sqlite-to-mock tool

* Default mock data set tweaks

Replaces "loremflickr.com" images with local placeholders

* "Kitchen Sink" mock data updates

* feat(mocks): add member support to sqlite-to-mock tool

Add transformers for members, member types, and member groups to replace
the empty placeholder arrays. Queries cmsMember, cmsMemberType, and
cmsMember2MemberGroup tables for auth data, property visibility, and
group relationships.

* Updated "Kitchen Sink" mock data with Members

* fix(mocks): type rawData in composition-mapped files to avoid never[] inference

When all compositions arrays are empty, TypeScript infers the element
type as never, causing a type error on the .map() callback. Adding an
explicit type annotation for rawData resolves this. Also fixed in both
generators so future runs produce correctly typed output.

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
Co-authored-by: leekelleher <leekelleher@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-16 20:31:11 +02:00
Andreas Lykke BorgandGitHub 0f19dc3716 TipTap Code Editor: Fix horizontal overflow in TipTap source code modal (closes #22287) (#22474)
* Fix horizontal scroll when opening Edit source code modal

* Added word-wrap to umb-code-editor
2026-04-16 18:18:09 +00:00
fbe355fdd0 Parameterise variables in SqliteSyntaxProvider and SqlServerSyntaxProvider (#22492)
* fix(SqliteSyntaxProvider.cs): parameterises the `tableName` variable when passing into `DoesPrimaryKeyExist` method

* fix(SqlServerSyntaxProvider.cs): parameterises the `tableName` variable when passing into the `DoesPrimaryKeyExist` sql statement

* test(DoesPrimaryKeyExist-test): Add test file for DoesPrimaryKeyExist

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

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-16 11:02:45 +00:00
Andy Butland ad9735f5c5 Merge branch 'release/17.3.4' 2026-04-16 12:41:38 +02:00
Andy ButlandandGitHub d603d0c820 Output Caching: Align Delivery API extensibility with website output caching (#22456)
* Align output cache extension points for the delivery API with those for the website.

* Fix issue running output cache on website and delivery API at the same time.

* Updates from testing.

* Align website default implementation with naming used for delivery API equivalents.

* Addressed code review feedback.

* Updates from self-review.
2026-04-16 09:34:30 +02:00
Andreas ZerbstandGitHub c63e2668d6 Build: Extract nbgv version step into shared template (#22480)
extract nbgv version step into shared template
2026-04-16 07:15:29 +00:00
Andy Butland 87e12b9ee2 Migrations: Fix RetrustForeignKeyAndCheckConstraints failing when data violates a constraint (#22488)
* Fix exception handling in RetrustForeignKeyAndCheckConstraints migration step.

* Addressed code review feedback.
2026-04-16 07:20:38 +02:00
Andy Butland 6ad2a17c09 Bumped version to 17.3.4. 2026-04-16 07:20:09 +02:00
Andy ButlandandGitHub 773ce35e6a Migrations: Fix RetrustForeignKeyAndCheckConstraints failing when data violates a constraint (#22488)
* Fix exception handling in RetrustForeignKeyAndCheckConstraints migration step.

* Addressed code review feedback.
2026-04-16 07:17:22 +02:00
742f0b1e2a Document Editing: Allow removal of template from a document and indicate when the selected template is no longer allowed (closes #20929) (#22348)
* Allow removal of template on a document, and indicate when the selected template is no longer allowed.

* Addressed code review feedback.

* remove duplicate inline color style on template icon

---------

Co-authored-by: engjlr <enl@umbraco.dk>
Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-04-15 23:39:17 +02:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>nielslyngsoe
f8da54b79d Add loading indicator to Create menu modals (#20857)
* Initial plan

* Add loading state to document and media create modals

Co-authored-by: nielslyngsoe <6791648+nielslyngsoe@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nielslyngsoe <6791648+nielslyngsoe@users.noreply.github.com>
2026-04-15 20:31:28 +02:00
HenrikandGitHub 3b11b237cb Code Quality: Eliminate closure in AppPolicedCacheDictionary (#22482)
Eliminate closure
2026-04-15 20:05:58 +02:00
ba5ec202f6 Entity Service: Batch GetAllPaths queries to avoid SQL Server parameter limit (closes #22470) (#22471)
* Group get all paths to avoid exceeding SQL Server's max parameter count.

* Move GetAllPaths batching tests to dedicated test class

Move the explicit SQL Server parameter limit tests into their own
class (EntityServiceGetAllPathsTests) with NewSchemaPerTest so the
raw SqlException surfaces instead of being masked by scope disposal.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:55:10 +02:00
37bfe65b77 Umb-icon color setting optimization (#22433)
* use currentColor as color fallback

* clean up necessary prop

* Add test color behavior coverage for umb-icon

---------

Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
Co-authored-by: engjlr <enl@umbraco.dk>
2026-04-15 12:10:10 +02:00
Jacob OvergaardandClaude Opus 4.6 244ea6c34e CI: Skip Claude review on fork PRs
Fork PRs on the `pull_request` event don't have access to repository
secrets, so the action fails and surfaces a red check on the PR. Guard
the job with a head-repo equality check so the workflow simply doesn't
run for fork PRs. Remove once upstream fork support lands
(anthropics/claude-code-action#939) and `pull_request_target` can be
re-enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 11:55:29 +02:00
Jacob OvergaardandClaude Opus 4.6 294b24d20d CI: Revert claude-review trigger to pull_request
pull_request_target fails at OIDC token exchange ("401 Unauthorized -
Invalid OIDC token") against Anthropic's backend, even though the
action itself supports the event (PR #579). Fork PRs will not be
auto-reviewed until the upstream issue is resolved. Kept the
pull_request_target block commented with a pointer to the issues
for when re-enabling becomes viable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 11:44:43 +02:00
Jacob OvergaardandClaude Opus 4.6 ec8f2ccf8e CI: Add actions:read to claude-review workflow permissions
Docs require actions:read at the workflow permissions level in addition
to additional_permissions on the action, so Claude's CI-reading MCP
tools can actually function. See anthropics/claude-code-action
docs/configuration.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 11:41:51 +02:00
Jacob Welander JensenandGitHub 45da578e97 Media Collection: Display upload notifications in rows rather than in columns (closes #21502) (#22467)
swapping from column to row
2026-04-15 10:53:11 +02:00
015df79ef2 Tags Property Editor: Preserve commas in tag values (closes #22413) (#22432)
* Preserve commas when provided in tags.

* Address code review feedback.

* Split commas for CSV storage, preserve for JSON

* Use tagsInput var and lowercase CSV check

---------

Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
Co-authored-by: engjlr <enl@umbraco.dk>
2026-04-15 10:19:02 +02:00
3b1956d35b Member Authentication: Add member sign-in/sign-out notifications (closes #22461) (#22463)
* feat(core): add member sign-in/sign-out notifications

Add MemberLoginSuccessNotification, MemberLoginFailedNotification,
and MemberLogoutSuccessNotification to achieve parity with the
existing backoffice user authentication notifications.

Override HandleSignIn in MemberSignInManager to publish login
success/failure notifications, and override SignOutAsync to publish
logout notifications. This follows the same pattern used by
BackOfficeSignInManager for backoffice users.

Closes #22461

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

* docs(core): add remarks to member auth notification classes

Add <remarks> XML documentation to MemberLoginSuccessNotification,
MemberLoginFailedNotification, and MemberLogoutSuccessNotification
describing intended usage, consistent with the backoffice user
notification equivalents.

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

* feat(web): use IIpResolver for member auth notification IP addresses

Use IIpResolver.GetCurrentRequestIpAddress() for consistent IP
resolution in member auth notifications, matching the pattern used
by BackOfficeUserManager.

Introduces IIpResolver as a new constructor parameter with the
existing constructor marked obsolete (removal in Umbraco 19) using
StaticServiceProvider fallback for backwards compatibility.

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

* Fixed filing unit tests.

* Added tests for new functionality.

* Ensure MemberFailedNotification is fired on invalid credentials as well as member not found.
Add the reason for the failure to the notification.

* Add tests for other failed notification publishing states.

* Clarified comments.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-15 09:52:18 +02:00
HenrikandGitHub e5bd1954a7 Code Quality: Use more appropriate types for private fields of Umbraco.Cms.Core.Enum<T> (#22475)
Use better types for Umbraco.Cms.Core.Enum<T>, array iterates faster & FrozenDictionary gets better over time. Also fixes naming warnings
2026-04-15 06:54:29 +02:00
HenrikandGitHub a62afe418d Code Quality: Use array over dictionary in private collection of PublishedContentType (#22476)
No need to iterate a Dictionary when an array can be used
2026-04-15 06:49:57 +02:00
Jacob OvergaardandClaude Sonnet 4.6 0eb0313db1 Docs: Remove duplicate TS deprecation section from root CLAUDE.md
The client CLAUDE.md's action-to-doc table already maps deprecation
to docs/deprecation.md, and the root's callout directs agents to read
the client CLAUDE.md for backoffice work. Having the pattern in both
places is redundant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:58:44 +02:00
Jacob OvergaardandClaude Sonnet 4.6 5f19354a53 Docs: Add callout to read client CLAUDE.md for backoffice work
Agents working from the repo root now see an explicit instruction to
read the client's CLAUDE.md before touching backoffice code. Prevents
missing project-specific conventions (like UmbDeprecation) that are
documented in the client project but not the root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:55:22 +02:00
Jacob OvergaardandClaude Sonnet 4.6 aa2e0a338f Docs: Add action-to-doc checklist to client CLAUDE.md
Maps specific actions (deprecate, create element, add tests, etc.) to
the docs that MUST be read first. Ensures developers opening only the
client folder see the requirements in their Claude context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:54:46 +02:00
Jacob OvergaardandClaude Sonnet 4.6 81e6d8ec74 Docs: Add TypeScript deprecation pattern to root CLAUDE.md
The backoffice client requires both @deprecated JSDoc AND a runtime
UmbDeprecation warning for every deprecation. This was documented in
the client's docs/deprecation.md but not referenced in the root
CLAUDE.md, causing AI agents to miss the runtime warning requirement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:49:46 +02:00
Jacob OvergaardandClaude Sonnet 4.6 956c4dc830 DevOps: Ignore .claude session artifacts, keep only skills and settings
Broadens the .claude gitignore to ignore everything except skills/
(committed for CI workflows) and settings.json (shared config).
Previously only settings.local.json was ignored, leaving lock files,
worktrees, and scheduled_tasks artifacts untracked but visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 15:47:31 +02:00
cbe7e8a533 Cache: Invalidate published cache entries when content or media is trashed (#22451)
* Cache: Invalidate published cache entries when content or media is trashed

Trashed content and media were remaining in the published cache because
ContentRefreshNotification/MediaRefreshNotification wrote the trashed
entities back into the cache, and ContentCacheRefresher.HandleMemoryCache
could not resolve the branch descendants after HandleNavigation had moved
them to the recycle bin.

- DocumentCacheService.RefreshContentAsync / MediaCacheService.RefreshMediaAsync:
  early-return for trashed entities, deleting from the database cache and
  removing from the local memory cache.
- DocumentCacheService.RefreshMemoryCacheAsync / MediaCacheService.RefreshMemoryCacheAsync:
  added symmetric else branches so memory cache entries are removed when the
  database cache has no corresponding draft or published node (self-healing).
- ContentCacheRefresher.HandleMemoryCache: added a bin fallback to
  TryGetDescendantsKeys so broadcasted RefreshBranch payloads can resolve
  descendants moved to the recycle bin on load-balanced servers.
- Integration tests covering trashed content and media cache invalidation.

* Cache: Add tests for restoring trashed content and media

Verifies that restored content is back in the draft cache (but not the
published cache, since restore does not republish) and that restored
media is back in the cache.

* Address PR review feedback

- Add bin fallback to MediaCacheRefresher.HandleMemoryCache for
  consistency with ContentCacheRefresher.
- Remove redundant [Test] attributes alongside [TestCase].

* Apply suggestions from code review

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

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-14 14:32:01 +02:00
9883fb5b42 Backoffice: Add explicit controller aliases to observe() calls in tree components (#22450)
Backoffice: Add explicit controller aliases to observe() calls in tree item and default tree elements

Without explicit aliases, observe() falls back to hashing the callback's
source string on every invocation. The api setter on tree-item-element-base
and the #observeData() method on default-tree.element are called each time
the api property changes, making the hash cost and implicit deduplication
behaviour visible in hot render paths.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-04-14 11:20:21 +00:00
Andreas ZerbstandGitHub 3250e69444 E2E: QA: add member type acceptance tests (#22379)
* Updated helpers

* Added tests

* Fixed

* Updated smoke

* Fixes

* Fix smokeTest command in package.json

* Fix typo in smokeTest script key
2026-04-14 08:56:01 +00:00
Niels LyngsøandGitHub 2c7e026721 Store: Accept tokens and update MDs for general Context Consumption (#22458)
* accept a Context Token as well as a string

* update MD files
2026-04-14 08:42:34 +00:00
Callum WhyteandGitHub 8c721dcbde dotnet Templates: Remove legacy Umbraco:CMS:Content:MacroErrors from project template development configuration (#22447)
Remove legacy Umbraco:CMS:Content:MacroErrors from project template Development config
2026-04-14 09:39:26 +02:00
1164 changed files with 70829 additions and 13643 deletions
+94
View File
@@ -0,0 +1,94 @@
---
name: umb-bump-version
description: Bump the Umbraco CMS version across all required files. Use when the user asks to bump, update, or set the version number — e.g., "bump version to 17.3.4", "set version to 18.0.0-rc", "update version". Accepts the target version as an argument.
argument-hint: <version> (e.g., 17.3.4, 18.0.0-rc)
---
# Bump Version - Umbraco CMS
Updates the Umbraco CMS version string across all files that track it.
**Do NOT use AskUserQuestion if a version argument is provided. Only ask if `$ARGUMENTS` is empty or cannot be parsed as a version.**
## Arguments
- `$ARGUMENTS` - Required: the target version string (e.g., `17.3.4`, `18.0.0-rc`)
## Files to Update
The following 5 files must be updated with the new version:
| # | File | Field |
|---|------|-------|
| 1 | `version.json` | `"version"` |
| 2 | `src/Umbraco.Web.UI.Client/package.json` | `"version"` |
| 3 | `src/Umbraco.Web.UI.Client/package-lock.json` | top-level `"version"` AND `packages[""].version` |
| 4 | `tests/Umbraco.Tests.AcceptanceTest/package.json` | `"version"` |
| 5 | `tests/Umbraco.Tests.AcceptanceTest/package-lock.json` | top-level `"version"` AND `packages[""].version` |
**Note**: For major version bumps (e.g., 17.x to 18.x), `src/Umbraco.Web.UI.Login/package.json` has a caret-ranged dependency on `@umbraco-cms/backoffice` (e.g., `^17.2.0`) that will need manual updating. This skill does not handle that — major bumps involve many other changes beyond version strings.
## Instructions
### 1. Parse and Validate the Version
Extract the version from `$ARGUMENTS`. It must be a valid semver-like string (e.g., `17.3.4`, `18.0.0-rc`, `17.4.0-preview.1`). If no version is provided or it cannot be parsed, ask the user for the target version.
### 2. Read the Current Version
Read `version.json` and extract the current `"version"` value. If the current version already equals the target version, report that the version is already set and stop — do not edit, stage, or commit anything.
Otherwise, display both versions:
```
Bumping version: {current} -> {target}
```
### 3. Update All Files
Update each of the 5 files listed above, replacing the old version with the new version. For each file:
- **`version.json`**: Replace the `"version"` value.
- **`package.json` files**: Replace the `"version"` value (near the top of the file).
- **`package-lock.json` files**: Replace BOTH the top-level `"version"` value AND the `"version"` inside the `"packages": { "": { ... } }` block. These are always in the first ~10 lines of the file.
Use targeted edits — do NOT rewrite entire files. Be precise to avoid changing version strings in dependency entries.
### 4. Verify
After all edits, grep for the target version value across the 5 files to confirm all updates landed correctly:
```bash
grep -n "\"version\": \"{version}\"" version.json src/Umbraco.Web.UI.Client/package.json src/Umbraco.Web.UI.Client/package-lock.json tests/Umbraco.Tests.AcceptanceTest/package.json tests/Umbraco.Tests.AcceptanceTest/package-lock.json
```
Expect exactly 7 matches (one per `package.json` and `version.json`, two per `package-lock.json`).
### 5. Stage and Commit
Stage only the 5 changed files:
```bash
git add version.json src/Umbraco.Web.UI.Client/package.json src/Umbraco.Web.UI.Client/package-lock.json tests/Umbraco.Tests.AcceptanceTest/package.json tests/Umbraco.Tests.AcceptanceTest/package-lock.json
```
Then commit with the message `Bump version to {version}.` — replacing `{version}` with the target version:
```bash
git commit -m "Bump version to {version}."
```
### 6. Report
Output a summary:
```
Version bumped to {version} in:
- version.json
- src/Umbraco.Web.UI.Client/package.json
- src/Umbraco.Web.UI.Client/package-lock.json
- tests/Umbraco.Tests.AcceptanceTest/package.json
- tests/Umbraco.Tests.AcceptanceTest/package-lock.json
Changes staged and committed.
```
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
name: Build and Deploy Job
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: true
- name: Build And Deploy
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
name: Build and Deploy Job
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Build And Deploy
id: builddeploy
uses: Azure/static-web-apps-deploy@v1
+30 -6
View File
@@ -1,28 +1,52 @@
name: Claude PR Review
on:
pull_request_target:
types: [opened, ready_for_review]
pull_request:
types: [opened, ready_for_review, reopened]
# NOTE: `pull_request_target` would let this workflow review fork PRs
# (with access to secrets), but the action currently fails during OIDC
# token exchange with "401 Unauthorized - Invalid OIDC token" on that
# event. PR #579 added `pull_request_target` routing to the action, but
# Anthropic's `/github-app-token-exchange` endpoint appears not to
# accept the token claims produced by that event. Re-enable once the
# upstream issue is resolved.
# See: https://github.com/anthropics/claude-code-action/issues/347
# https://github.com/anthropics/claude-code-action/issues/621
# pull_request_target:
# types: [opened, ready_for_review]
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
actions: read
jobs:
review:
if: github.event.pull_request.draft == false
# Skip fork PRs: secrets are not exposed on `pull_request` events from
# forks, so the action would fail with a red check. Remove this clause
# once upstream fork support lands (tracked in
# https://github.com/anthropics/claude-code-action/issues/939) and we
# can re-enable the `pull_request_target` trigger above.
if: >-
github.event.pull_request.draft == false
&& github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 1
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_03 }}
base_branch: "main"
# Enable progress tracking
track_progress: true
# Debug (set to true to show full output in logs, false to hide it and only post comments on the PR)
show_full_output: false
additional_permissions: "actions: read"
claude_args: "--model claude-sonnet-4-6 --allowedTools 'Bash(gh:*),Bash(git:*)'"
prompt: |
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 1
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
# We use the setup-dotnet action to set up .NET Core, otherwise the CodeQL CLI will not work with preview versions.
- name: Setup .NET from global.json
+84
View File
@@ -0,0 +1,84 @@
name: Issue Deduplication
on:
issues:
types: [ opened ]
workflow_dispatch:
inputs:
issue_number:
description: 'Issue number to analyze for duplicates'
required: true
type: number
jobs:
deduplicate:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Check for duplicate issues
uses: anthropics/claude-code-action@v1
with:
prompt: |
Analyze this new issue and check if it's a duplicate of existing issues in the repository.
Issue: #${{ github.event.issue.number || inputs.issue_number }}
Repository: ${{ github.repository }}
Your task:
1. Use mcp__github__get_issue to get details of the current issue (#${{ github.event.issue.number || inputs.issue_number }})
2. Search for similar existing issues using mcp__github__search_issues with relevant keywords from the issue title and body
3. Compare the new issue with existing ones to identify potential duplicates
Criteria for duplicates:
- Same bug or error being reported
- Same feature request (even if worded differently)
- Same question being asked
- Issues describing the same root problem
If you find duplicates:
- Add a comment on the new issue linking to the original issue(s)
- Apply the "duplicate" and "state/needs-investigation" labels to the new issue
- Be polite and explain why it's a duplicate
- Suggest the user follow the original issue for updates
If it's NOT a duplicate:
- Don't add any comments
- You may apply appropriate topic labels based on the issue content
Use these tools:
- mcp__github__get_issue: Get issue details
- mcp__github__search_issues: Search for similar issues
- mcp__github__list_issues: List recent issues if needed
- mcp__github__add_issue_comment: Add a comment if duplicate found
- mcp__github__update_issue: Add labels
Be thorough but efficient. Focus on finding true duplicates, not just similar issues.
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_03 }}
# Issues are opened by community members without write access, so the
# default OIDC token exchange fails with "User does not have write
# access on this repository". Pass `github_token` explicitly and set
# `allowed_non_write_users` to bypass that check. Safe here because
# `permissions:` and `--allowedTools` below are tightly scoped to
# issue operations only.
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: "*"
# Surface full SDK output (including tool calls and permission denials)
# to diagnose why Claude sometimes only partially completes (e.g. labels
# an issue but skips the comment). Safe to leave on — no secrets in output.
show_full_output: true
claude_args: |
--model claude-haiku-4-5 --allowedTools "mcp__github__get_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__add_issue_comment,mcp__github__update_issue,mcp__github__get_issue_comments"
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
run:
working-directory: src/Umbraco.Web.UI.Client
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Use Node.js
uses: actions/setup-node@v4
with:
@@ -57,7 +57,7 @@ jobs:
run:
working-directory: src/Umbraco.Web.UI.Client
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Use Node.js
uses: actions/setup-node@v4
with:
+5 -2
View File
@@ -52,7 +52,9 @@ tools/docfx/
/build/csharp-docs/_site/
# Local config
.claude/settings.local.json
.claude/*
!.claude/skills/
!.claude/settings.json
.env.local
# Build
@@ -70,7 +72,8 @@ tools/docfx/
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/assets
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/js
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/lib
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/views
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/views/*
!/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/views/errors
/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/login
# Environment specific data
+5 -1
View File
@@ -227,9 +227,11 @@ Project ownership is distributed across teams. Check individual project director
1. **Layered Architecture with Dependency Inversion**
- Core defines contracts (interfaces)
- Infrastructure implements contracts
- Infrastructure implements contracts that need Infrastructure-owned machinery
- Web/APIs consume implementations via DI
**Where service implementations live**: Services whose dependencies are satisfiable from Core interfaces alone (repositories, scope, config, other Core services) live in `Umbraco.Core/Services/` — this covers the majority of domain services (`MemberService`, `ContentService`, `MediaService`, `ContentTypeService`, `EntityService`, `AuditService`, `ExternalMemberService`, etc.). Service implementations only live in `Umbraco.Infrastructure/Services/Implement/` when they genuinely need Infrastructure concerns — Examine indexes (`ContentSearchService`, `MediaSearchService`, `IndexedEntitySearchService`), log files (`LogViewerRepository`), packaging internals (`PackagingService`), webhook firing (`WebhookFiringService`), distributed-job coordination (`DistributedJobService`). When adding a new service, default to Core and only move to Infrastructure if a concrete dependency forces it.
2. **Interface-First Design**
- All services defined as interfaces in Core
- Enables testing, polymorphism, extensibility
@@ -561,6 +563,8 @@ For detailed information about individual projects, see their CLAUDE.md files:
- **API Infrastructure**: `/src/Umbraco.Cms.Api.Common/CLAUDE.md` - OpenAPI, authentication, serialization
- **Backoffice Frontend**: `/src/Umbraco.Web.UI.Client/CLAUDE.md` - Lit web components, extension system, auth client
**Important**: When working on backoffice client code (anything under `src/Umbraco.Web.UI.Client/`), read `/src/Umbraco.Web.UI.Client/CLAUDE.md` first. It contains action-specific checklists (deprecation, testing, security, etc.) that are not duplicated here.
### Getting Help
- **Official Docs**: https://docs.umbraco.com/
+29 -26
View File
@@ -13,32 +13,32 @@
</ItemGroup>
<!-- Microsoft packages -->
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.4" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.6" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.4" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.4" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.4.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.6" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.5.0" />
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
</ItemGroup>
<!-- Umbraco packages -->
<ItemGroup>
<PackageVersion Include="Umbraco.JsonSchema.Extensions" Version="0.3.0" />
<PackageVersion Include="Umbraco.JsonSchema.Extensions" Version="0.4.0" />
</ItemGroup>
<!-- Third-party packages -->
<ItemGroup>
@@ -50,7 +50,7 @@
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
<PackageVersion Include="MailKit" Version="4.15.1" />
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="Markdig" Version="0.45.0" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
@@ -59,9 +59,9 @@
<PackageVersion Include="ncrontab" Version="3.4.0" />
<PackageVersion Include="NPoco" Version="6.2.0" />
<PackageVersion Include="NPoco.SqlServer" Version="6.2.0" />
<PackageVersion Include="OpenIddict.Abstractions" Version="7.2.0" />
<PackageVersion Include="OpenIddict.AspNetCore" Version="7.2.0" />
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="7.2.0" />
<PackageVersion Include="OpenIddict.Abstractions" Version="7.4.0" />
<PackageVersion Include="OpenIddict.AspNetCore" Version="7.4.0" />
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="7.4.0" />
<PackageVersion Include="Serilog" Version="4.3.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Enrichers.Process" Version="3.0.0" />
@@ -77,7 +77,7 @@
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.2.0" />
<!-- When updating this version, also update templates/UmbracoExtension/Umbraco.Extension.csproj -->
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.4" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<!-- Transitive pinned versions (only required because our direct dependencies have vulnerable versions of transitive dependencies) -->
<ItemGroup>
@@ -88,5 +88,8 @@
<!-- Markdown references vulnerable version of the following: -->
<!-- TODO (V19): Remove these pinned dependencies when the Markdown dependency is removed. -->
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
<!-- Examine (via Microsoft.AspNetCore.DataProtection 8.0.4) references a vulnerable version of the following: -->
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
</ItemGroup>
</Project>
+29 -15
View File
@@ -188,16 +188,9 @@ stages:
parameters:
nodeVersion: ${{ variables.nodeVersion }}
npm_config_cache: ${{ variables.npm_config_cache }}
- bash: |
echo "##[command]Install nbgv"
dotnet tool install --tool-path . nbgv
echo "##[command]Running nbgv get-version"
PACKAGE_VERSION=$(nbgv get-version -v NpmPackageVersion)
echo "##[command]Running npm version"
echo "##[debug]Version: $PACKAGE_VERSION"
cd tests/Umbraco.Tests.AcceptanceTest
npm version $PACKAGE_VERSION --allow-same-version --no-git-tag-version
displayName: Set NPM Version
- template: templates/set-npm-version.yml
parameters:
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
- bash: |
echo "##[command]Running npm pack"
mkdir $(Build.ArtifactStagingDirectory)/npm-testhelpers
@@ -904,12 +897,27 @@ stages:
- stage: Deploy_NuGet
displayName: NuGet release
dependsOn: Deploy_MyGet
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.nuGetDeploy}}))
# Run only when Deploy_MyGet actually ran (succeeded or failed) — not when it was skipped due to an upstream test failure.
# Inspect Deploy_MyGet's direct result rather than succeeded()/failed(), which are transitive across the full ancestor graph.
# Approval is required every run via the WaitForApproval job below.
condition: and(in(dependencies.Deploy_MyGet.result, 'Succeeded', 'SucceededWithIssues', 'Failed'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.nuGetDeploy}}))
jobs:
- job:
- job: WaitForApproval
displayName: Wait for manual approval
pool: server
timeoutInMinutes: 4320 # 3 days
steps:
- task: ManualValidation@0
displayName: Manual approval to push to NuGet
inputs:
notifyUsers: ''
instructions: 'Approve to push the NuGet release.'
onTimeout: 'reject'
- job: Push
displayName: Push to NuGet
dependsOn: WaitForApproval
pool:
vmImage: "windows-latest" # NuGetCommand@2 is no longer supported on Ubuntu 24.04 so we'll use windows until an alternative is available.
displayName: Push to NuGet
steps:
- checkout: none
- task: DownloadPipelineArtifact@2
@@ -927,7 +935,10 @@ stages:
- stage: Deploy_Npm
displayName: Npm release
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
# Inspect Deploy_NuGet.result directly so a MyGet failure (which is in the transitive ancestor graph)
# doesn't cascade-skip this stage via succeeded(). Deploy_NuGet must itself have succeeded — a NuGet
# failure deliberately blocks the npm release.
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
dependsOn:
- Deploy_NuGet
jobs:
@@ -988,7 +999,10 @@ stages:
- Build
- Build_Docs
- Deploy_NuGet
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.uploadApiDocs}}))
# Build_Docs must have produced artifacts (we won't upload anything otherwise) and Deploy_NuGet must
# have succeeded — a NuGet failure deliberately blocks the docs upload. Direct result checks avoid
# transitive succeeded()/failed() which would cascade-skip on a MyGet failure.
condition: and(in(dependencies.Build_Docs.result, 'Succeeded', 'SucceededWithIssues'), in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.uploadApiDocs}}))
jobs:
- job:
displayName: Upload C# Docs
+5 -5
View File
@@ -117,7 +117,7 @@ stages:
- stage: Integration
displayName: Integration Tests
dependsOn: Build
condition: ${{ eq(parameters.skipIntegrationTests, false) }}
condition: and(succeeded(), ${{ eq(parameters.skipIntegrationTests, false) }})
jobs:
# Integration Tests (SQLite)
- job:
@@ -319,8 +319,8 @@ stages:
- stage: DefaultConfigE2E
displayName: Default Config E2E Tests
dependsOn: Integration
condition: always()
dependsOn: [Build, Integration]
condition: in(dependencies.Build.result, 'Succeeded', 'SucceededWithIssues')
variables:
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
# Enable console logging in Release mode
@@ -500,8 +500,8 @@ stages:
- stage: AdditionalConfigE2E
displayName: Additional Config E2E Tests
dependsOn: DefaultConfigE2E
condition: always()
dependsOn: [Build, DefaultConfigE2E]
condition: in(dependencies.Build.result, 'Succeeded', 'SucceededWithIssues')
variables:
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
ASPNETCORE_URLS: https://localhost:44331
+3 -10
View File
@@ -6,16 +6,9 @@ steps:
versionSource: 'fromFile'
versionFilePath: src/Umbraco.Web.UI.Client/.nvmrc
- bash: |
echo "##[command]Install nbgv"
dotnet tool install --tool-path . nbgv
echo "##[command]Running nbgv get-version"
PACKAGE_VERSION=$(nbgv get-version -v NpmPackageVersion)
echo "##[command]Running npm version"
echo "##[debug]Version: $PACKAGE_VERSION"
cd src/Umbraco.Web.UI.Client
npm version $PACKAGE_VERSION --allow-same-version --no-git-tag-version
displayName: Set NPM Version
- template: set-npm-version.yml
parameters:
workingDirectory: src/Umbraco.Web.UI.Client
- task: Cache@2
displayName: Cache node_modules
+15
View File
@@ -0,0 +1,15 @@
parameters:
- name: workingDirectory
type: string
steps:
- bash: |
echo "##[command]Install nbgv"
dotnet tool install --tool-path . nbgv
echo "##[command]Running nbgv get-version"
PACKAGE_VERSION=$(nbgv get-version -v NpmPackageVersion)
echo "##[command]Running npm version"
echo "##[debug]Version: $PACKAGE_VERSION"
cd ${{ parameters.workingDirectory }}
npm version $PACKAGE_VERSION --allow-same-version --no-git-tag-version
displayName: Set NPM Version
+244
View File
@@ -0,0 +1,244 @@
# Research: IDistributedBackgroundJob Write Lock Timeout in Load-Balanced Setup
**Issue**: [#22113](https://github.com/umbraco/Umbraco-CMS/issues/22113)
**Error**: `Failed to acquire write lock for id: -347`
**Lock -347**: `Constants.Locks.DistributedJobs` (all distributed background jobs)
---
## Summary
The root cause is most likely **SQL Server page-level lock contention** on the `umbracoLock` table, caused by long-running content operations (inside the user's distributed job) holding REPEATABLEREAD locks on one row (e.g., `-333` ContentTree) which block write access to *all other rows on the same data page* (including `-347` DistributedJobs).
This is exacerbated by:
1. **Nested scope transaction sharing** - the user's outer scope holds the transaction (and all locks) open for the entire job duration
2. **Small table, single page** - all ~18 lock rows fit on one 8KB SQL Server data page
3. **5-second write lock timeout** - the default is too short when contention exists
4. **Backoffice activity** adding further lock pressure on the same table
---
## Detailed Analysis
### The Lock Table Problem
The `umbracoLock` table has approximately 18 rows (IDs -331 through -348). In SQL Server, a standard data page is 8KB. These 18 small rows (each just `id INT`, `name NVARCHAR`, `value INT`) **all fit on a single data page**.
SQL Server's lock granularity decisions:
- For small tables, the query optimizer may choose **page-level locks** instead of row-level locks
- The `WITH (REPEATABLEREAD)` table hint in the locking SQL means locks are held until the **end of the transaction**
- Without an explicit `ROWLOCK` hint, SQL Server decides the granularity
**Read lock SQL** (from `SqlServerDistributedLockingMechanism.cs:147`):
```sql
SELECT value FROM umbracoLock WITH (REPEATABLEREAD) WHERE id=@id
```
**Write lock SQL** (from `SqlServerDistributedLockingMechanism.cs:182-183`):
```sql
UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = (CASE WHEN (value=1) THEN -1 ELSE 1 END) WHERE id=@id
```
Neither uses a `ROWLOCK` hint, so SQL Server is free to use page-level locking.
### The Reproduction Scenario
Here's the exact sequence that causes the error:
**Server A** (running the user's distributed job):
1. `DistributedBackgroundJobHostedService` calls `TryTakeRunnableAsync()`
2. `TryTakeRunnableAsync` acquires `EagerWriteLock(-347)`, marks the "Clean Up Your Room" job as running, commits scope, **releases lock -347** -- this is fine
3. The user's `ExecuteAsync()` runs:
```csharp
using ICoreScope scope = _scopeProvider.CreateCoreScope(); // ROOT scope, starts transaction
_contentService.CountChildren(...) // Creates NESTED scope, acquires ReadLock(-333)
_contentService.RecycleBinSmells() // Creates NESTED scope, acquires ReadLock(-333)
_contentService.EmptyRecycleBin(...) // Creates NESTED scope, acquires WriteLock(-333)
scope.Complete(); // Transaction commits HERE, all locks released HERE
```
4. **Critical**: All nested scopes share the root scope's database/transaction (confirmed in `Scope.cs:350-360`). The `ReadLock(-333)` acquired by `CountChildren` is held until the ROOT scope disposes. If `EmptyRecycleBin` takes 30+ seconds (many items), the locks on row -333 are held for 30+ seconds.
5. With page-level locking, the shared (S) lock on row -333's **page** also covers row -347. This S lock blocks any exclusive (X) lock requests on the same page.
**Server B** (polling for jobs every 5 seconds):
6. `TryTakeRunnableAsync()` tries `EagerWriteLock(-347)`:
```sql
SET LOCK_TIMEOUT 5000;
UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = ... WHERE id=-347
```
7. This UPDATE needs an exclusive (X) lock on row -347. But the page containing -347 has a shared (S) lock held by Server A's long-running transaction.
8. Server B **blocks for 5 seconds**, then gets SQL error 1222 (lock timeout)
9. This becomes: `DistributedWriteLockTimeoutException` → **"Failed to acquire write lock for id: -347"**
### Why Backoffice Login Triggers It
When users log into the backoffice and interact with content:
- **Listing content**: `ContentService.GetById/GetChildren` → `ReadLock(-333)`
- **Saving content**: `ContentService.Save` → `WriteLock(-333)`
- **Deleting content**: `ContentService.Delete/MoveToRecycleBin` → `WriteLock(-333)`
- **Publishing**: `ContentService.Publish` → `WriteLock(-333)`
Each of these acquires locks on the `umbracoLock` table. In load-balanced setups, backoffice web requests on *any server* add page-level lock contention on the same data page as -347. The more backoffice activity, the higher the probability that some transaction is holding a page lock that blocks -347 acquisition.
### Why It "Disables the Server Until Restart"
The `DistributedBackgroundJobHostedService` catches exceptions and continues (line 80). However:
1. Every 5 seconds, `TryTakeRunnableAsync` fails with the lock timeout
2. The error is logged each time, creating a flood of error logs
3. **No distributed jobs run on the affected server** because `TryTakeRunnableAsync` always times out
4. The user's custom job that's causing the contention (on the other server) eventually finishes, but by then the pattern of contention from backoffice operations may sustain the problem
5. The server appears "disabled" because its distributed job processing is effectively blocked
The server doesn't truly need a restart to recover, but the sustained contention from backoffice operations can make it *appear* permanently broken. A restart clears all in-flight transactions and ambient scopes, resolving the immediate contention.
---
## Contributing Factors
### 1. No `ROWLOCK` Hint
The distributed locking SQL uses `WITH (REPEATABLEREAD)` but not `WITH (ROWLOCK, REPEATABLEREAD)`. Adding `ROWLOCK` would force SQL Server to use row-level locks, preventing cross-row contention on the same page.
**File**: `src/Umbraco.Cms.Persistence.SqlServer/Services/SqlServerDistributedLockingMechanism.cs`
- Line 147 (read lock): `SELECT value FROM umbracoLock WITH (REPEATABLEREAD) WHERE id=@id`
- Line 182-183 (write lock): `UPDATE umbracoLock WITH (REPEATABLEREAD) SET value = ... WHERE id=@id`
### 2. Short Default Write Lock Timeout
**File**: `src/Umbraco.Core/Configuration/Models/GlobalSettings.cs`
The default write lock timeout is **5 seconds** (`DistributedLockingWriteLockDefaultTimeout`). In a load-balanced setup with active backoffice use, this is easily exceeded during page-level lock contention.
### 3. User's Outer Scope Prolongs Lock Duration
The user's code wraps multiple ContentService calls in a single scope:
```csharp
using ICoreScope scope = _scopeProvider.CreateCoreScope();
_contentService.CountChildren(...); // ReadLock(-333) acquired, held by root transaction
_contentService.RecycleBinSmells(); // ReadLock(-333)
_contentService.EmptyRecycleBin(...); // WriteLock(-333), potentially slow
scope.Complete(); // ALL locks released here
```
The nested scopes created by ContentService methods all share the root scope's transaction (`Scope.cs:350-360`). This means the ReadLock from `CountChildren` is held for the entire duration of `EmptyRecycleBin`.
### 4. `Task.Run` in User Code
The user wraps their code in `Task.Run()`:
```csharp
public Task ExecuteAsync()
{
return Task.Run(() => { ... });
}
```
While this doesn't directly cause the lock issue, `Task.Run` moves execution to a thread pool thread. This is unnecessary (the hosted service already runs on a background thread) and could cause issues with scope ambient context if the async context doesn't flow properly.
---
## Potential Fixes
### Fix 1: Add `ROWLOCK` Hint (Framework Fix - Recommended)
Add `ROWLOCK` to the SQL statements in `SqlServerDistributedLockingMechanism`:
```sql
-- Read lock
SELECT value FROM umbracoLock WITH (ROWLOCK, REPEATABLEREAD) WHERE id=@id
-- Write lock
UPDATE umbracoLock WITH (ROWLOCK, REPEATABLEREAD) SET value = ... WHERE id=@id
```
This forces SQL Server to use row-level locks, preventing cross-row contention within the same page. Row-level locks on id=-333 would NOT block row-level locks on id=-347.
**Impact**: Minimal. Row-level locks are slightly more expensive in memory (lock manager overhead) but the umbracoLock table is tiny. This is the standard best practice for small lookup tables where row independence is required.
The same fix should also be applied to the EF Core SQL Server locking mechanism:
- `src/Umbraco.Cms.Persistence.EFCore/Locking/SqlServerEFCoreDistributedLockingMechanism.cs`
### Fix 2: Separate Lock Tables (Framework Fix - More Invasive)
Move distributed job locks to a separate table (`umbracoDistributedJobLock`) so they can never share a page with content tree locks. This is more invasive but eliminates the problem entirely regardless of SQL Server lock granularity decisions.
### Fix 3: Increase Write Lock Timeout (User Workaround)
```json
{
"Umbraco": {
"CMS": {
"Global": {
"DistributedLockingWriteLockDefaultTimeout": "00:00:30"
}
}
}
}
```
Increasing to 30 seconds gives more time for the contending transaction to complete. This is a workaround, not a fix - it trades timeout frequency for longer blocking delays.
### Fix 4: User Code Improvement (User Workaround)
The user should avoid wrapping multiple ContentService calls in a single outer scope. Each ContentService method already manages its own scope:
```csharp
public Task ExecuteAsync()
{
// NO outer scope needed - each ContentService method creates its own scope
int numberOfThingsInBin = _contentService.CountChildren(Constants.System.RecycleBinContent);
_logger.LogInformation("You have {Count} items to clean", numberOfThingsInBin);
if (_contentService.RecycleBinSmells())
{
_contentService.EmptyRecycleBin(userId: -1);
}
return Task.CompletedTask;
}
```
This reduces lock hold duration because each ContentService call acquires and releases its locks independently. The `CountChildren` ReadLock(-333) is released before `EmptyRecycleBin` starts.
Also: remove the `Task.Run` wrapper - it's unnecessary since the hosted service already runs on a background thread.
---
## Key Code References
| File | Purpose |
|------|---------|
| `src/Umbraco.Infrastructure/BackgroundJobs/DistributedBackgroundJobHostedService.cs` | Timer loop, calls TryTake → Execute → Finish |
| `src/Umbraco.Infrastructure/Services/Implement/DistributedJobService.cs` | Acquires WriteLock(-347) in TryTakeRunnableAsync (line 68) and FinishAsync (line 105) |
| `src/Umbraco.Cms.Persistence.SqlServer/Services/SqlServerDistributedLockingMechanism.cs` | SQL Server lock SQL (lines 147, 182-183) - missing ROWLOCK hint |
| `src/Umbraco.Core/Persistence/Constants-Locks.cs` | Lock ID definitions (-331 through -348) |
| `src/Umbraco.Infrastructure/Scoping/Scope.cs:350-360` | Nested scopes share parent's Database/transaction |
| `src/Umbraco.Core/Services/ContentService.cs` | EmptyRecycleBin acquires WriteLock(-333), CountChildren/RecycleBinSmells acquire ReadLock(-333) |
| `src/Umbraco.Core/Configuration/Models/GlobalSettings.cs` | Default lock timeout: 5 seconds for writes |
---
## Verification Steps
To confirm this hypothesis:
1. **SQL Server Activity Monitor**: During reproduction, check for page-level locks on the `umbracoLock` table using `sys.dm_tran_locks`:
```sql
SELECT * FROM sys.dm_tran_locks
WHERE resource_database_id = DB_ID()
AND resource_associated_entity_id = OBJECT_ID('umbracoLock')
ORDER BY request_mode, resource_type
```
2. **Check lock granularity**: Look for `resource_type = 'PAGE'` entries, which would confirm page-level locking.
3. **Test with ROWLOCK**: Temporarily modify the SQL to include `ROWLOCK` hint and verify the issue disappears.
4. **Test without outer scope**: Have the user remove the wrapping `CreateCoreScope()` call and verify the issue is mitigated (shorter individual lock durations).
+271
View File
@@ -0,0 +1,271 @@
# Memory Leak Analysis — Umbraco CMS v17
**Date**: 2026-03-03
**Branch**: `main`
**Scope**: All production projects under `src/`
**Methodology**: Static analysis — grep-based pattern matching across ~1,000 C# source files
---
## Executive Summary
Seven potential memory management issues were identified. None represent an unbounded memory growth path that would cause noticeable degradation or an `OutOfMemoryException` on a typical site running for days or weeks. The most accurate characterisation of the meaningful findings is **reduced `ArrayPool` efficiency** rather than classical memory leaks — the GC reclaims all affected memory eventually, but pooled buffers are not returned promptly.
The single highest-value fix is a one-line addition to `DatabaseServerMessenger.Dispose()`. Two findings around `JsonDocument` disposal are worth addressing for correctness, particularly on multi-server deployments. The remaining findings have negligible practical impact.
---
## Findings
### Finding 1 — `CancellationTokenSource` Not Disposed
| | |
|---|---|
| **File** | `src/Umbraco.Infrastructure/Sync/DatabaseServerMessenger.cs` |
| **Lines** | 24 (creation), 339349 (Dispose) |
| **Confidence** | High |
| **Practical Impact** | Negligible |
`DatabaseServerMessenger` implements `IDisposable`, but its `Dispose(bool)` method omits disposal of `_cancellationTokenSource`:
```csharp
// Line 24 — created
private readonly CancellationTokenSource _cancellationTokenSource = new();
// Lines 339349 — _syncIdle is disposed; _cancellationTokenSource is not
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
_syncIdle.Dispose();
// ← _cancellationTokenSource.Dispose() is missing
}
_disposedValue = true;
}
}
```
`CancellationTokenSource` internally holds a native `SafeWaitHandle` (a Win32 event object) that should be released via `Dispose()`. Because this class is a singleton, exactly **one** handle is leaked for the lifetime of the process — the GC finaliser will never reclaim it. The practical memory cost is a few hundred bytes and one OS handle, which is immeasurable in a normal server process.
**Real-world impact over several days**: None observable. This is a correctness issue rather than a practical one.
**Recommended fix**: Add `_cancellationTokenSource.Dispose();` inside the `if (disposing)` block at line 345. This is a single-line change.
---
### Finding 2 — `JsonDocument` Not Disposed in Cache Sync Loop
| | |
|---|---|
| **File** | `src/Umbraco.Infrastructure/Services/CacheInstructionService.cs` |
| **Lines** | 287, 293, 315334 |
| **Confidence** | High |
| **Practical Impact** | Low (single server) / LowMedium (multi-server) |
`TryDeserializeInstructions` allocates a `JsonDocument` — which rents a buffer from `ArrayPool<byte>` — and returns it via an `out` parameter. The caller uses the document's `RootElement` once, then allows the variable to go out of scope without calling `Dispose()`:
```csharp
// Line 287 — JsonDocument created inside TryDeserializeInstructions
if (TryDeserializeInstructions(instruction, out JsonDocument? jsonInstructions) is false
&& jsonInstructions is null)
{
lastId = instruction.Id;
continue;
}
// Line 293 — last use; jsonInstructions goes out of scope without Dispose()
List<RefreshInstruction> instructionBatch = GetAllInstructions(jsonInstructions?.RootElement);
```
`JsonDocument` has no finaliser. When the GC collects an un-disposed instance, the rented `ArrayPool` buffer is collected as ordinary heap memory rather than being returned to the pool. This reduces pool hit rates and increases allocation pressure.
This codepath runs inside the multi-server cache instruction sync loop. On a **single-server** deployment the loop processes only local (skipped) instructions and almost never reaches `TryDeserializeInstructions`. On a **multi-server load-balanced** deployment with active content publishing, this can fire many times per minute.
**Real-world impact over several days**: Negligible on single-server. On a busy multi-server site, slightly elevated Gen 0 GC frequency from reduced `ArrayPool` reuse. Memory does not grow unboundedly.
**Recommended fix**: Wrap the `JsonDocument` in a `using` declaration at the call site:
```csharp
using JsonDocument? jsonInstructions = TryDeserializeInstructions(instruction);
if (jsonInstructions is null) { lastId = instruction.Id; continue; }
```
---
### Finding 3 — `JsonDocument` Cached Without Disposal on Eviction
| | |
|---|---|
| **File** | `src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/JsonValueConverter.cs` |
| **Lines** | 5268 |
| **Confidence** | Medium |
| **Practical Impact** | Low |
`ConvertSourceToIntermediate` returns a `JsonDocument` that the published content cache stores at `PropertyCacheLevel.Element` (cached per content element, per variant):
```csharp
public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType)
=> PropertyCacheLevel.Element;
public override object? ConvertSourceToIntermediate(...)
{
// ...
return JsonDocument.Parse(sourceString); // rented ArrayPool buffer not returned on eviction
}
```
The cache holds values as `object?` and evicts them by releasing references. Because there is no eviction callback that calls `Dispose()`, the rented buffer for each `JsonDocument` is abandoned rather than returned to the pool.
This affects every content node with a JSON property type (block lists, media pickers, nested content, etc.). On a site with mostly-static content the cached `JsonDocument` population is bounded and stable. On a site with frequent content changes causing cache churn, pool hit rates are lower and allocation pressure is higher.
**Real-world impact over several days**: Low. Memory does not grow unboundedly — the GC collects evicted documents. The observable effect, if any, would be marginally higher Gen 0 collection frequency on high-churn sites. This is unlikely to be measurable on a typical site.
**Recommended fix**: This requires a non-trivial design change — either wrapping returned values in a disposable owner type with cache eviction callbacks, or switching the internal representation away from the pooled `JsonDocument` type.
---
### Finding 4 — `CryptoStream` and `ICryptoTransform` Not Disposed
| | |
|---|---|
| **File** | `src/Umbraco.Infrastructure/Security/MemberPasswordHasher.cs` |
| **Lines** | 161171 |
| **Confidence** | Medium |
| **Practical Impact** | Negligible |
In a legacy password decryption helper, `MemoryStream` is correctly wrapped in `using`, but `CryptoStream` and `ICryptoTransform` are not:
```csharp
private static string DecryptLegacyPassword(string encryptedPassword, SymmetricAlgorithm algorithm)
{
using var memoryStream = new MemoryStream();
ICryptoTransform cryptoTransform = algorithm.CreateDecryptor(); // not disposed
var cryptoStream = new CryptoStream(memoryStream, cryptoTransform, CryptoStreamMode.Write); // not disposed
var buf = Convert.FromBase64String(encryptedPassword);
cryptoStream.Write(buf, 0, 32);
cryptoStream.FlushFinalBlock();
return Encoding.Unicode.GetString(memoryStream.ToArray());
}
```
Both types implement `IDisposable` and hold internal transform state buffers. However, this method is only invoked for accounts with Umbraco ≤ 8 encrypted password hashes — a codepath that is exercised only during migrations from legacy installations and is effectively never called on a v17 site.
**Real-world impact over several days**: None observable. The objects are small and collected promptly by the GC.
**Recommended fix**: Add `using` declarations for both `cryptoTransform` and `cryptoStream` for correctness.
---
### Finding 5 — Static Event Subscription Without Unsubscription (Development Mode Only)
| | |
|---|---|
| **File** | `src/Umbraco.Cms.DevelopmentMode.Backoffice/InMemoryAuto/InMemoryAssemblyLoadContextManager.cs` |
| **Lines** | 1011 |
| **Confidence** | High (pattern) |
| **Practical Impact** | None in production |
The class subscribes to a static event in its constructor but implements no `IDisposable` to unsubscribe:
```csharp
public InMemoryAssemblyLoadContextManager() =>
AssemblyLoadContext.Default.Resolving += OnResolvingDefaultAssemblyLoadContext;
// No corresponding -= and no IDisposable
```
The class is registered as a singleton (`AddSingleton<InMemoryAssemblyLoadContextManager>()`), so its lifetime matches the process and the omission is benign in normal operation. The static event would prevent GC if the DI container released its reference (e.g. during repeated host rebuilding in integration tests). This component is only active when `ModelsMode` is `InMemoryAuto` and `RuntimeMode` is `BackofficeDevelopment` — it is never loaded in production.
**Real-world impact over several days**: None in production. Negligible in development.
**Recommended fix**: Implement `IDisposable` and unsubscribe in `Dispose()` for correctness and test isolation.
---
### Finding 6 — Static `HttpClient` Bypasses `IHttpClientFactory`
| | |
|---|---|
| **File** | `src/Umbraco.Core/Media/EmbedProviders/OEmbedProviderBase.cs` |
| **Lines** | 13, 8892 |
| **Confidence** | Low (not a true memory leak) |
| **Practical Impact** | Negligible (memory); Low (DNS staleness) |
A static `HttpClient?` field is lazily initialised without using `IHttpClientFactory`:
```csharp
private static HttpClient? _httpClient;
if (_httpClient == null)
{
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(...);
}
```
`HttpClient` is designed to be long-lived and reused, so the static pattern does not cause a memory leak. The practical concern is that DNS changes are not respected (no `PooledConnectionLifetime` on the underlying handler), which could cause stale connections on sites where OEmbed providers change their infrastructure. This is not a memory concern.
**Real-world impact over several days**: No memory impact. Potential for stale DNS on OEmbed requests after several days if a provider changes their IP.
**Recommended fix**: Inject `IHttpClientFactory` and use a named or typed client.
---
### Finding 7 — Unbounded Static Regex Cache
| | |
|---|---|
| **File** | `src/Umbraco.Core/Services/OEmbedService.cs` |
| **Lines** | 15, 6869 |
| **Confidence** | Low |
| **Practical Impact** | Negligible |
Compiled `Regex` objects are cached in a static `ConcurrentDictionary` with no eviction:
```csharp
private static readonly ConcurrentDictionary<string, Regex> RegexCache = new();
private static Regex GetOrCreateRegex(string pattern)
=> RegexCache.GetOrAdd(pattern, p => new Regex(p, RegexOptions.IgnoreCase | RegexOptions.Compiled));
```
The dictionary is bounded by the number of unique URL scheme patterns across registered OEmbed providers, which is typically around 1520 entries. Compiled `Regex` objects are intentionally long-lived. This is not a memory leak under normal usage; it would only become one if patterns were generated dynamically from user input at runtime (which they are not).
**Real-world impact over several days**: None observable.
**Recommended fix**: No action needed under current usage patterns. Add a size cap if the pattern set ever becomes dynamic.
---
## Items Investigated and Cleared
The following patterns were examined and found to be correctly implemented:
| Class / Area | Pattern Checked | Result |
|---|---|---|
| `DatabaseServerMessenger._syncIdle` | `ManualResetEvent` disposal | ✓ Disposed at line 345 |
| `RecurringHostedServiceBase._timer` | `System.Threading.Timer` disposal | ✓ Disposed via `_timer?.Dispose()` |
| `DistributedBackgroundJobHostedService` | `PeriodicTimer` disposal | ✓ Wrapped in `using` |
| `RetryDbConnection` | `StateChange` event handler | ✓ Unsubscribed in `Dispose(bool)` |
| `UmbracoIdentityUser` | `ObservableCollection.CollectionChanged` | ✓ Cleaned up in property setters |
| `Content` / `ContentBase` / `ContentTypeBase` | `CollectionChanged` handlers | ✓ Use `ClearCollectionChangedEvents()` before reassignment |
| `FileRepository` / `PartialViewRepository` | `MemoryStream` returned from `GetContentStream` | ✓ All call sites wrap result in `using` |
| `JsonConfigManipulator` | `FileStream` disposal | ✓ Wrapped in `await using` |
| `QueuedHostedService` | `ExecutionContext.SuppressFlow()` | ✓ Wrapped in `using` |
| Background job DI registrations | Captive dependency (scoped-in-singleton) | ✓ No violations found |
---
## Priority and Effort Summary
| Priority | Finding | Fix Effort |
|---|---|---|
| **Fix** | Finding 1: `CancellationTokenSource` not disposed | 1 line |
| **Fix** | Finding 2: `JsonDocument` not disposed in sync loop | ~3 lines |
| **Fix** | Finding 4: `CryptoStream` not disposed | 2 lines |
| **Fix** | Finding 5: Static event leak (dev-only) | `IDisposable` implementation |
| **Consider** | Finding 3: `JsonDocument` cached without disposal | Design change required |
| **Consider** | Finding 6: Static `HttpClient` | Inject `IHttpClientFactory` |
| **Monitor** | Finding 7: Static `Regex` cache | No action unless patterns become dynamic |
Findings 1, 2, and 4 are low-effort correctness fixes that follow established .NET resource management idioms. Finding 3 is a legitimate design smell that warrants a separate investigation into how the published content cache handles disposable cached values.
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Default implementation of <see cref="IDeliveryApiOutputCacheRequestFilter"/> that prevents caching
/// for preview mode requests and requests without public access.
/// </summary>
public class DefaultDeliveryApiOutputCacheRequestFilter : IDeliveryApiOutputCacheRequestFilter
{
private readonly IRequestPreviewService _requestPreviewService;
private readonly IApiAccessService _apiAccessService;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultDeliveryApiOutputCacheRequestFilter"/> class.
/// </summary>
/// <param name="requestPreviewService">The preview service.</param>
/// <param name="apiAccessService">The API access service.</param>
public DefaultDeliveryApiOutputCacheRequestFilter(IRequestPreviewService requestPreviewService, IApiAccessService apiAccessService)
{
_requestPreviewService = requestPreviewService;
_apiAccessService = apiAccessService;
}
/// <inheritdoc />
public virtual bool IsCacheable(HttpContext context)
=> IsPreview() is false && HasPublicAccess();
/// <inheritdoc />
public virtual bool IsCacheable(HttpContext context, IPublishedContent content) => true;
/// <summary>
/// Returns <c>true</c> if the current request is a preview request; <c>false</c> if the request
/// is not a preview and may be cached.
/// </summary>
protected virtual bool IsPreview()
=> _requestPreviewService.IsPreview();
/// <summary>
/// Returns <c>true</c> if the current request has public access; <c>false</c> if the request
/// is not publicly accessible and should not be cached.
/// </summary>
protected virtual bool HasPublicAccess()
=> _apiAccessService.HasPublicAccess();
}
@@ -0,0 +1,17 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Tags cached pages for delivery API output caching with their content type alias, enabling eviction by content type.
/// </summary>
internal sealed class DeliveryApiContentTypeOutputCacheTagProvider : IDeliveryApiOutputCacheTagProvider
{
/// <inheritdoc />
public IEnumerable<string> GetTags(IPublishedContent content)
{
yield return Constants.DeliveryApi.OutputCache.ContentTypeTagPrefix + content.ContentType.Alias;
}
}
@@ -0,0 +1,135 @@
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Web.Common.Caching;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Handles <see cref="ContentCacheRefresherNotification"/> to evict Delivery API output cache entries
/// when content is published, unpublished, moved, or deleted. Also evicts responses for content
/// that references the changed content via picker properties (umbDocument relations).
/// </summary>
internal sealed class DeliveryApiDocumentOutputCacheEvictionHandler
: RelationOutputCacheEvictionHandlerBase, INotificationAsyncHandler<ContentCacheRefresherNotification>
{
private readonly IEnumerable<IDeliveryApiOutputCacheEvictionProvider> _evictionProviders;
private readonly ILogger<DeliveryApiDocumentOutputCacheEvictionHandler> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DeliveryApiDocumentOutputCacheEvictionHandler"/> class.
/// </summary>
/// <param name="outputCacheStore">The output cache store for evicting cached responses.</param>
/// <param name="relationService">The relation service for querying entity references.</param>
/// <param name="idKeyMap">The ID/key mapping service for converting between integer IDs and GUIDs.</param>
/// <param name="evictionProviders">Custom eviction providers for additional tag-based eviction.</param>
/// <param name="logger">The logger.</param>
public DeliveryApiDocumentOutputCacheEvictionHandler(
IOutputCacheStore outputCacheStore,
IRelationService relationService,
IIdKeyMap idKeyMap,
IEnumerable<IDeliveryApiOutputCacheEvictionProvider> evictionProviders,
ILogger<DeliveryApiDocumentOutputCacheEvictionHandler> logger)
: base(outputCacheStore, relationService, idKeyMap)
{
_evictionProviders = evictionProviders;
_logger = logger;
}
/// <inheritdoc />
public async Task HandleAsync(ContentCacheRefresherNotification notification, CancellationToken cancellationToken)
{
if (notification.MessageType != MessageType.RefreshByPayload
|| notification.MessageObject is not ContentCacheRefresher.JsonPayload[] payloads)
{
return;
}
var changedEntityIds = new List<int>();
foreach (ContentCacheRefresher.JsonPayload payload in payloads)
{
if (payload.Blueprint)
{
continue;
}
await EvictForPayloadAsync(payload, cancellationToken);
changedEntityIds.Add(payload.Id);
}
// Evict content that references the changed content via picker properties.
await EvictRelatedContentAsync(
changedEntityIds,
Constants.Conventions.RelationTypes.RelatedDocumentAlias,
Constants.DeliveryApi.OutputCache.ContentTagPrefix,
_logger,
cancellationToken);
}
private async Task EvictForPayloadAsync(ContentCacheRefresher.JsonPayload payload, CancellationToken cancellationToken)
{
if (payload.ChangeTypes.HasFlag(TreeChangeTypes.RefreshAll))
{
// Evict all Delivery API responses — media responses may reference content via picker properties.
_logger.LogDebug("Content refresh all — evicting all Delivery API output cache entries.");
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllTag, cancellationToken);
return;
}
if (payload.Key.HasValue is false)
{
return;
}
Guid contentKey = payload.Key.Value;
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Evicting Delivery API output cache for content {ContentKey}.", contentKey);
}
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.ContentTagPrefix + contentKey, cancellationToken);
if (payload.ChangeTypes.HasFlag(TreeChangeTypes.RefreshBranch))
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Evicting Delivery API output cache for descendants of {ContentKey}.", contentKey);
}
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AncestorTagPrefix + contentKey, cancellationToken);
}
await InvokeCustomEvictionProvidersAsync(payload, contentKey, cancellationToken);
}
private async Task InvokeCustomEvictionProvidersAsync(ContentCacheRefresher.JsonPayload payload, Guid contentKey, CancellationToken cancellationToken)
{
var context = new OutputCacheContentChangedContext(
payload.Id,
contentKey,
payload.PublishedCultures ?? [],
payload.UnpublishedCultures ?? []);
foreach (IDeliveryApiOutputCacheEvictionProvider provider in _evictionProviders)
{
IEnumerable<string> additionalTags = await provider.GetAdditionalEvictionTagsAsync(context, cancellationToken);
foreach (var tag in additionalTags)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Evicting Delivery API output cache tag {Tag} via custom provider.", tag);
}
await OutputCacheStore.EvictByTagAsync(tag, cancellationToken);
}
}
}
}
@@ -0,0 +1,80 @@
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Web.Common.Caching;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Handles <see cref="MediaCacheRefresherNotification"/> to evict Delivery API output cache entries
/// when media is created, updated, or deleted. Also evicts content responses that reference
/// the changed media via picker properties (umbMedia relations).
/// </summary>
internal sealed class DeliveryApiMediaOutputCacheEvictionHandler
: RelationOutputCacheEvictionHandlerBase, INotificationAsyncHandler<MediaCacheRefresherNotification>
{
private readonly ILogger<DeliveryApiMediaOutputCacheEvictionHandler> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DeliveryApiMediaOutputCacheEvictionHandler"/> class.
/// </summary>
/// <param name="outputCacheStore">The output cache store for evicting cached responses.</param>
/// <param name="relationService">The relation service for querying entity references.</param>
/// <param name="idKeyMap">The ID/key mapping service for converting between integer IDs and GUIDs.</param>
/// <param name="logger">The logger.</param>
public DeliveryApiMediaOutputCacheEvictionHandler(
IOutputCacheStore outputCacheStore,
IRelationService relationService,
IIdKeyMap idKeyMap,
ILogger<DeliveryApiMediaOutputCacheEvictionHandler> logger)
: base(outputCacheStore, relationService, idKeyMap)
=> _logger = logger;
/// <inheritdoc />
public async Task HandleAsync(MediaCacheRefresherNotification notification, CancellationToken cancellationToken)
{
if (notification.MessageType != MessageType.RefreshByPayload
|| notification.MessageObject is not MediaCacheRefresher.JsonPayload[] payloads)
{
return;
}
foreach (MediaCacheRefresher.JsonPayload payload in payloads)
{
if (payload.ChangeTypes.HasFlag(TreeChangeTypes.RefreshAll))
{
// Evict all Delivery API responses — content responses may include referenced media,
// so evicting only media entries would leave stale media references in content responses.
_logger.LogDebug("Media refresh all — evicting all Delivery API output cache entries.");
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllTag, cancellationToken);
return;
}
if (payload.Key.HasValue is false)
{
continue;
}
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("Evicting Delivery API output cache for media {MediaKey}.", payload.Key.Value);
}
await OutputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.MediaTagPrefix + payload.Key.Value, cancellationToken);
}
// Evict content that references the changed media via picker properties.
await EvictRelatedContentAsync(
payloads.Select(p => p.Id),
Constants.Conventions.RelationTypes.RelatedMediaAlias,
Constants.DeliveryApi.OutputCache.ContentTagPrefix,
_logger,
cancellationToken);
}
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Web.Common.Caching;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Handles <see cref="MemberCacheRefresherNotification"/> to evict Delivery API output cache entries
/// for content that references the changed member via picker properties (umbMember relations).
/// </summary>
internal sealed class DeliveryApiMemberOutputCacheEvictionHandler
: RelationOutputCacheEvictionHandlerBase, INotificationAsyncHandler<MemberCacheRefresherNotification>
{
private readonly ILogger<DeliveryApiMemberOutputCacheEvictionHandler> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DeliveryApiMemberOutputCacheEvictionHandler"/> class.
/// </summary>
/// <param name="outputCacheStore">The output cache store for evicting cached responses.</param>
/// <param name="relationService">The relation service for querying entity references.</param>
/// <param name="idKeyMap">The ID/key mapping service for converting between integer IDs and GUIDs.</param>
/// <param name="logger">The logger.</param>
public DeliveryApiMemberOutputCacheEvictionHandler(
IOutputCacheStore outputCacheStore,
IRelationService relationService,
IIdKeyMap idKeyMap,
ILogger<DeliveryApiMemberOutputCacheEvictionHandler> logger)
: base(outputCacheStore, relationService, idKeyMap)
=> _logger = logger;
/// <inheritdoc />
public async Task HandleAsync(MemberCacheRefresherNotification notification, CancellationToken cancellationToken)
{
if (notification.MessageType != MessageType.RefreshByPayload
|| notification.MessageObject is not MemberCacheRefresher.JsonPayload[] payloads)
{
return;
}
// Evict content that references the changed members via picker properties.
await EvictRelatedContentAsync(
payloads.Select(p => p.Id),
Constants.Conventions.RelationTypes.RelatedMemberAlias,
Constants.DeliveryApi.OutputCache.ContentTagPrefix,
_logger,
cancellationToken);
}
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Services.Navigation;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Output cache policy for Delivery API content endpoints.
/// </summary>
internal sealed class DeliveryApiOutputCacheContentPolicy : DeliveryApiOutputCachePolicyBase
{
/// <summary>
/// Initializes a new instance of the <see cref="DeliveryApiOutputCacheContentPolicy"/> class.
/// </summary>
/// <param name="defaultDuration">The default cache duration from configuration.</param>
/// <param name="defaultVaryByHeaders">The default vary-by headers for content requests.</param>
public DeliveryApiOutputCacheContentPolicy(TimeSpan defaultDuration, StringValues defaultVaryByHeaders)
: base(defaultDuration, defaultVaryByHeaders)
{
}
/// <inheritdoc />
protected override string ResolvedItemsKey => DeliveryApiOutputCacheKeys.ResolvedContentItemsKey;
/// <inheritdoc />
protected override string ItemTagPrefix => Constants.DeliveryApi.OutputCache.ContentTagPrefix;
/// <inheritdoc />
protected override string AllItemsTag => Constants.DeliveryApi.OutputCache.AllContentTag;
/// <inheritdoc />
protected override void AddItemTags(OutputCacheContext context, IPublishedContent item, IServiceProvider services)
{
// Tag with ancestor keys for branch eviction.
IDocumentNavigationQueryService navigationService = services.GetRequiredService<IDocumentNavigationQueryService>();
if (navigationService.TryGetAncestorsKeys(item.Key, out IEnumerable<Guid> ancestorKeys))
{
foreach (Guid ancestorKey in ancestorKeys)
{
context.Tags.Add(Constants.DeliveryApi.OutputCache.AncestorTagPrefix + ancestorKey);
}
}
}
}
@@ -0,0 +1,18 @@
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Keys used to pass resolved content and media items from controllers to the output cache policy
/// via <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
/// </summary>
internal static class DeliveryApiOutputCacheKeys
{
/// <summary>
/// Key for storing resolved content items in <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
/// </summary>
public const string ResolvedContentItemsKey = "Umbraco.DeliveryApi.OutputCache.ResolvedContentItems";
/// <summary>
/// Key for storing resolved media items in <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/>.
/// </summary>
public const string ResolvedMediaItemsKey = "Umbraco.DeliveryApi.OutputCache.ResolvedMediaItems";
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.OutputCaching;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Default implementation of <see cref="IDeliveryApiOutputCacheManager"/> that delegates
/// to the ASP.NET Core <see cref="IOutputCacheStore"/>.
/// </summary>
internal sealed class DeliveryApiOutputCacheManager : IDeliveryApiOutputCacheManager
{
private readonly IOutputCacheStore _outputCacheStore;
/// <summary>
/// Initializes a new instance of the <see cref="DeliveryApiOutputCacheManager"/> class.
/// </summary>
/// <param name="outputCacheStore">The ASP.NET Core output cache store.</param>
public DeliveryApiOutputCacheManager(IOutputCacheStore outputCacheStore)
=> _outputCacheStore = outputCacheStore;
/// <inheritdoc />
public async Task EvictContentAsync(Guid contentKey, CancellationToken cancellationToken = default)
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.ContentTagPrefix + contentKey, cancellationToken);
/// <inheritdoc />
public async Task EvictMediaAsync(Guid mediaKey, CancellationToken cancellationToken = default)
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.MediaTagPrefix + mediaKey, cancellationToken);
/// <inheritdoc />
public async Task EvictByTagAsync(string tag, CancellationToken cancellationToken = default)
=> await _outputCacheStore.EvictByTagAsync(tag, cancellationToken);
/// <inheritdoc />
public async Task EvictAllContentAsync(CancellationToken cancellationToken = default)
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllContentTag, cancellationToken);
/// <inheritdoc />
public async Task EvictAllMediaAsync(CancellationToken cancellationToken = default)
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllMediaTag, cancellationToken);
/// <inheritdoc />
public async Task EvictAllAsync(CancellationToken cancellationToken = default)
=> await _outputCacheStore.EvictByTagAsync(Constants.DeliveryApi.OutputCache.AllTag, cancellationToken);
}
@@ -0,0 +1,29 @@
using Microsoft.Extensions.Primitives;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Output cache policy for Delivery API media endpoints.
/// </summary>
internal sealed class DeliveryApiOutputCacheMediaPolicy : DeliveryApiOutputCachePolicyBase
{
/// <summary>
/// Initializes a new instance of the <see cref="DeliveryApiOutputCacheMediaPolicy"/> class.
/// </summary>
/// <param name="defaultDuration">The default cache duration from configuration.</param>
/// <param name="defaultVaryByHeaders">The default vary-by headers for media requests.</param>
public DeliveryApiOutputCacheMediaPolicy(TimeSpan defaultDuration, StringValues defaultVaryByHeaders)
: base(defaultDuration, defaultVaryByHeaders)
{
}
/// <inheritdoc />
protected override string ResolvedItemsKey => DeliveryApiOutputCacheKeys.ResolvedMediaItemsKey;
/// <inheritdoc />
protected override string ItemTagPrefix => Constants.DeliveryApi.OutputCache.MediaTagPrefix;
/// <inheritdoc />
protected override string AllItemsTag => Constants.DeliveryApi.OutputCache.AllMediaTag;
}
@@ -1,43 +0,0 @@
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using Umbraco.Cms.Core.DeliveryApi;
namespace Umbraco.Cms.Api.Delivery.Caching;
internal sealed class DeliveryApiOutputCachePolicy : IOutputCachePolicy
{
private readonly TimeSpan _duration;
private readonly StringValues _varyByHeaderNames;
public DeliveryApiOutputCachePolicy(TimeSpan duration, StringValues varyByHeaderNames)
{
_duration = duration;
_varyByHeaderNames = varyByHeaderNames;
}
ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
IRequestPreviewService requestPreviewService = context
.HttpContext
.RequestServices
.GetRequiredService<IRequestPreviewService>();
IApiAccessService apiAccessService = context
.HttpContext
.RequestServices
.GetRequiredService<IApiAccessService>();
context.EnableOutputCaching = requestPreviewService.IsPreview() is false && apiAccessService.HasPublicAccess();
context.ResponseExpirationTimeSpan = _duration;
context.CacheVaryByRules.HeaderNames = _varyByHeaderNames;
return ValueTask.CompletedTask;
}
ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
@@ -0,0 +1,154 @@
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Base output cache policy for Delivery API endpoints. Handles request filtering, vary-by rules,
/// and tagging. Subclasses specify the resolved-items key, tag prefix, and "all" tag that
/// distinguish content from media.
/// </summary>
internal abstract class DeliveryApiOutputCachePolicyBase : IOutputCachePolicy
{
private readonly TimeSpan _defaultDuration;
private readonly StringValues _defaultVaryByHeaders;
/// <summary>
/// Initializes a new instance of the <see cref="DeliveryApiOutputCachePolicyBase"/> class.
/// </summary>
/// <param name="defaultDuration">The default cache duration from configuration.</param>
/// <param name="defaultVaryByHeaders">The default vary-by headers for this endpoint type.</param>
protected DeliveryApiOutputCachePolicyBase(TimeSpan defaultDuration, StringValues defaultVaryByHeaders)
{
_defaultDuration = defaultDuration;
_defaultVaryByHeaders = defaultVaryByHeaders;
}
/// <summary>
/// Gets the <see cref="Microsoft.AspNetCore.Http.HttpContext.Items"/> key used to retrieve
/// resolved <see cref="IPublishedContent"/> items stashed by the controller.
/// </summary>
protected abstract string ResolvedItemsKey { get; }
/// <summary>
/// Gets the tag prefix for individual item eviction (e.g. <c>umb-dapi-content-</c>).
/// </summary>
protected abstract string ItemTagPrefix { get; }
/// <summary>
/// Gets the "all items" tag for bulk eviction (e.g. <c>umb-dapi-content-all</c>).
/// </summary>
protected abstract string AllItemsTag { get; }
/// <summary>
/// Adds additional per-item tags to the output cache context. Called once per resolved item
/// during <c>ServeResponseAsync</c>. The default implementation does nothing.
/// </summary>
/// <param name="context">The output cache context.</param>
/// <param name="item">The published content or media item.</param>
/// <param name="services">The request service provider.</param>
protected virtual void AddItemTags(OutputCacheContext context, IPublishedContent item, IServiceProvider services)
{
}
/// <inheritdoc />
ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
IServiceProvider services = context.HttpContext.RequestServices;
ILogger logger = services.GetRequiredService<ILoggerFactory>().CreateLogger(GetType());
IDeliveryApiOutputCacheRequestFilter requestFilter = services.GetRequiredService<IDeliveryApiOutputCacheRequestFilter>();
if (requestFilter.IsCacheable(context.HttpContext) is false)
{
context.EnableOutputCaching = false;
logger.LogDebug("Request filter returned not cacheable — skipping output cache.");
return ValueTask.CompletedTask;
}
context.EnableOutputCaching = true;
context.AllowCacheLookup = true;
context.AllowCacheStorage = true;
context.AllowLocking = true;
context.ResponseExpirationTimeSpan = _defaultDuration;
// Set default vary-by headers.
context.CacheVaryByRules.HeaderNames = _defaultVaryByHeaders;
// Invoke custom vary-by providers (additive, runs after defaults).
IEnumerable<IDeliveryApiOutputCacheVaryByProvider> varyByProviders = services.GetServices<IDeliveryApiOutputCacheVaryByProvider>();
foreach (IDeliveryApiOutputCacheVaryByProvider varyByProvider in varyByProviders)
{
varyByProvider.ConfigureVaryBy(context.HttpContext, context.CacheVaryByRules);
}
// Add base tags for bulk eviction.
context.Tags.Add(AllItemsTag);
context.Tags.Add(Constants.DeliveryApi.OutputCache.AllTag);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
ValueTask IOutputCachePolicy.ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
/// <inheritdoc />
ValueTask IOutputCachePolicy.ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
if (context.HttpContext.Items[ResolvedItemsKey]
is not IPublishedContent[] items || items.Length == 0)
{
return ValueTask.CompletedTask;
}
IServiceProvider services = context.HttpContext.RequestServices;
ILogger logger = services.GetRequiredService<ILoggerFactory>().CreateLogger(GetType());
IDeliveryApiOutputCacheRequestFilter requestFilter = services.GetRequiredService<IDeliveryApiOutputCacheRequestFilter>();
IEnumerable<IDeliveryApiOutputCacheTagProvider> tagProviders = services.GetServices<IDeliveryApiOutputCacheTagProvider>();
foreach (IPublishedContent item in items)
{
// Check content-aware cacheability.
if (requestFilter.IsCacheable(context.HttpContext, item) is false)
{
context.AllowCacheStorage = false;
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("Request filter returned not cacheable for item {ItemKey} — disabling cache storage.", item.Key);
}
return ValueTask.CompletedTask;
}
// Tag with specific item key for targeted eviction.
context.Tags.Add(ItemTagPrefix + item.Key);
// Allow subclasses to add additional per-item tags (e.g. ancestor tags for content).
AddItemTags(context, item, services);
// Invoke custom tag providers.
foreach (IDeliveryApiOutputCacheTagProvider tagProvider in tagProviders)
{
foreach (var tag in tagProvider.GetTags(item))
{
context.Tags.Add(tag);
}
}
}
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug(
"Caching Delivery API response with {TagCount} tags, duration {Duration}",
context.Tags.Count,
context.ResponseExpirationTimeSpan);
}
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Determines whether a Delivery API request is eligible for output caching.
/// </summary>
/// <remarks>
/// <para>
/// This interface provides two levels of cacheability checks:
/// </para>
/// <list type="bullet">
/// <item><see cref="IsCacheable(HttpContext)"/> — called before the controller runs, for
/// request-level decisions (e.g. preview mode, access control).</item>
/// <item><see cref="IsCacheable(HttpContext, IPublishedContent)"/> — called after the controller
/// resolves content, for content-aware decisions (e.g. exclude specific content types).</item>
/// </list>
/// </remarks>
public interface IDeliveryApiOutputCacheRequestFilter
{
/// <summary>
/// Gets a value indicating whether the request is eligible for output caching.
/// Called before the controller runs.
/// </summary>
/// <param name="context">The HTTP context for the current request.</param>
/// <returns><c>true</c> if the response may be cached; <c>false</c> to skip caching.</returns>
bool IsCacheable(HttpContext context);
/// <summary>
/// Gets a value indicating whether the response for the given content or media item is eligible
/// for output caching. Called after the controller resolves content.
/// </summary>
/// <param name="context">The HTTP context for the current request.</param>
/// <param name="content">The resolved published content or media item.</param>
/// <returns><c>true</c> if the response may be cached; <c>false</c> to skip caching.</returns>
bool IsCacheable(HttpContext context, IPublishedContent content);
}
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.OutputCaching;
namespace Umbraco.Cms.Api.Delivery.Caching;
/// <summary>
/// Configures additional vary-by rules for Delivery API output caching.
/// </summary>
/// <remarks>
/// <para>
/// Multiple implementations can be registered; the output cache policy invokes all of them
/// to configure vary-by rules at cache-write time, after the default vary-by headers have been set.
/// </para>
/// <para>
/// Providers have direct access to <see cref="CacheVaryByRules"/> and can configure any aspect
/// including <see cref="CacheVaryByRules.QueryKeys"/>, <see cref="CacheVaryByRules.HeaderNames"/>,
/// and <see cref="CacheVaryByRules.VaryByValues"/>.
/// </para>
/// </remarks>
public interface IDeliveryApiOutputCacheVaryByProvider
{
/// <summary>
/// Configures vary-by rules for the given request.
/// </summary>
/// <param name="context">The HTTP context for the current request.</param>
/// <param name="rules">The vary-by rules to configure.</param>
void ConfigureVaryBy(HttpContext context, CacheVaryByRules rules);
}
@@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Umbraco.Cms.Web.Common.ApplicationBuilder;
namespace Umbraco.Cms.Api.Delivery.Caching;
internal sealed class OutputCachePipelineFilter : UmbracoPipelineFilter
{
public OutputCachePipelineFilter(string name)
: base(name)
=> PostPipeline = PostPipelineAction;
private void PostPipelineAction(IApplicationBuilder applicationBuilder)
=> applicationBuilder.UseOutputCache();
}
@@ -41,17 +41,20 @@ public class ByIdContentApiController : ContentApiItemControllerBase
{
return NotFound();
}
IActionResult? deniedAccessResult = await HandleMemberAccessAsync(contentItem, _requestMemberAccessService).ConfigureAwait(false);
if (deniedAccessResult is not null)
{
return deniedAccessResult;
}
IApiContentResponse? apiContentResponse = ApiContentResponseBuilder.Build(contentItem);
if (apiContentResponse is null)
{
return NotFound();
}
SetOutputCacheContent(contentItem);
return Ok(apiContentResponse);
}
}
@@ -48,6 +48,7 @@ public class ByIdsContentApiController : ContentApiItemControllerBase
.WhereNotNull()
.ToArray();
SetOutputCacheContent(contentItems);
return Ok(apiContentItems);
}
}
@@ -64,6 +64,7 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
return deniedAccessResult;
}
SetOutputCacheContent(contentItem);
return Ok(ApiContentResponseBuilder.Build(contentItem));
}
@@ -2,10 +2,12 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Umbraco.Cms.Api.Common.Builders;
using Umbraco.Cms.Api.Delivery.Caching;
using Umbraco.Cms.Api.Delivery.Filters;
using Umbraco.Cms.Api.Delivery.Routing;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Delivery.Controllers.Content;
@@ -50,6 +52,13 @@ public abstract class ContentApiControllerBase : DeliveryApiControllerBase
.Build()),
};
/// <summary>
/// Stores the resolved content items in the HTTP context for use by the output cache policy.
/// </summary>
/// <param name="items">The resolved published content items.</param>
protected void SetOutputCacheContent(params IPublishedContent[] items)
=> HttpContext.Items[DeliveryApiOutputCacheKeys.ResolvedContentItemsKey] = items;
/// <summary>
/// Creates a 403 Forbidden result.
/// </summary>
@@ -62,9 +62,11 @@ public class QueryContentApiController : ContentApiControllerBase
}
PagedModel<Guid> pagedResult = queryAttempt.Result;
IEnumerable<IPublishedContent> contentItems = ApiPublishedContentCache.GetByIds(pagedResult.Items);
IPublishedContent[] contentItems = ApiPublishedContentCache.GetByIds(pagedResult.Items).ToArray();
IApiContentResponse[] apiContentItems = contentItems.Select(ApiContentResponseBuilder.Build).WhereNotNull().ToArray();
SetOutputCacheContent(contentItems);
var model = new PagedViewModel<IApiContentResponse>
{
Total = pagedResult.Total,
@@ -39,6 +39,7 @@ public class ByIdMediaApiController : MediaApiControllerBase
return NotFound();
}
SetOutputCacheMedia(media);
return Ok(BuildApiMediaWithCrops(media));
}
}
@@ -39,6 +39,7 @@ public class ByIdsMediaApiController : MediaApiControllerBase
.Select(BuildApiMediaWithCrops)
.ToArray();
SetOutputCacheMedia(mediaItems);
return Ok(apiMediaItems);
}
}
@@ -43,6 +43,7 @@ public class ByPathMediaApiController : MediaApiControllerBase
return NotFound();
}
SetOutputCacheMedia(media);
return Ok(BuildApiMediaWithCrops(media));
}
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Umbraco.Cms.Api.Common.Builders;
using Umbraco.Cms.Api.Delivery.Caching;
using Umbraco.Cms.Api.Delivery.Filters;
using Umbraco.Cms.Api.Delivery.Routing;
using Umbraco.Cms.Core;
@@ -33,6 +34,13 @@ public abstract class MediaApiControllerBase : DeliveryApiControllerBase
protected IApiMediaWithCropsResponse BuildApiMediaWithCrops(IPublishedContent media)
=> _apiMediaWithCropsResponseBuilder.Build(media);
/// <summary>
/// Stores the resolved media items in the HTTP context for use by the output cache policy.
/// </summary>
/// <param name="items">The resolved published media items.</param>
protected void SetOutputCacheMedia(params IPublishedContent[] items)
=> HttpContext.Items[DeliveryApiOutputCacheKeys.ResolvedMediaItemsKey] = items;
protected IActionResult ApiMediaQueryOperationStatusResult(ApiMediaQueryOperationStatus status) =>
status switch
{
@@ -59,6 +59,8 @@ public class QueryMediaApiController : MediaApiControllerBase
PagedModel<Guid> pagedResult = queryAttempt.Result;
IPublishedContent[] mediaItems = pagedResult.Items.Select(PublishedMediaCache.GetById).WhereNotNull().ToArray();
SetOutputCacheMedia(mediaItems);
var model = new PagedViewModel<IApiMediaWithCropsResponse>
{
Total = pagedResult.Total,
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Primitives;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Api.Delivery.Accessors;
@@ -18,6 +19,7 @@ using Umbraco.Cms.Api.Delivery.Security;
using Umbraco.Cms.Api.Delivery.Services;
using Umbraco.Cms.Api.Delivery.Services.QueryBuilders;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.DependencyInjection;
@@ -105,6 +107,10 @@ public static class UmbracoBuilderExtensions
builder.AddNotificationAsyncHandler<MemberDeletedNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
builder.AddNotificationAsyncHandler<AssignedMemberRolesNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
builder.AddNotificationAsyncHandler<RemovedMemberRolesNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
builder.AddNotificationAsyncHandler<ExternalMemberSavedNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
builder.AddNotificationAsyncHandler<ExternalMemberDeletedNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
builder.AddNotificationAsyncHandler<AssignedExternalMemberRolesNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
builder.AddNotificationAsyncHandler<RemovedExternalMemberRolesNotification, RevokeMemberAuthenticationTokensNotificationHandler>();
// FIXME: remove this when Delivery API V1 is removed
builder.Services.AddSingleton<MatcherPolicy, DeliveryApiItemsEndpointsMatcherPolicy>();
@@ -132,7 +138,7 @@ public static class UmbracoBuilderExtensions
{
options.AddPolicy(
Constants.DeliveryApi.OutputCache.ContentCachePolicy,
new DeliveryApiOutputCachePolicy(
new DeliveryApiOutputCacheContentPolicy(
outputCacheSettings.ContentDuration,
new StringValues([Constants.DeliveryApi.HeaderNames.AcceptLanguage, Constants.DeliveryApi.HeaderNames.AcceptSegment, Constants.DeliveryApi.HeaderNames.StartItem])));
}
@@ -141,13 +147,28 @@ public static class UmbracoBuilderExtensions
{
options.AddPolicy(
Constants.DeliveryApi.OutputCache.MediaCachePolicy,
new DeliveryApiOutputCachePolicy(
new DeliveryApiOutputCacheMediaPolicy(
outputCacheSettings.MediaDuration,
Constants.DeliveryApi.HeaderNames.StartItem));
}
});
builder.Services.Configure<UmbracoPipelineOptions>(options => options.AddFilter(new OutputCachePipelineFilter("UmbracoDeliveryApiOutputCache")));
// Register eviction handlers.
builder.AddNotificationAsyncHandler<ContentCacheRefresherNotification, DeliveryApiDocumentOutputCacheEvictionHandler>();
builder.AddNotificationAsyncHandler<MediaCacheRefresherNotification, DeliveryApiMediaOutputCacheEvictionHandler>();
builder.AddNotificationAsyncHandler<MemberCacheRefresherNotification, DeliveryApiMemberOutputCacheEvictionHandler>();
// Register extension point default implementations.
builder.Services.AddSingleton<IDeliveryApiOutputCacheTagProvider, DeliveryApiContentTypeOutputCacheTagProvider>();
builder.Services.AddUnique<IDeliveryApiOutputCacheRequestFilter, DefaultDeliveryApiOutputCacheRequestFilter>();
builder.Services.AddUnique<IDeliveryApiOutputCacheManager, DeliveryApiOutputCacheManager>();
// Signal that Umbraco has enabled output caching so the application builder registers
// the output cache middleware. Gated via a marker rather than IOutputCacheStore so that
// applications calling services.AddOutputCache(...) for their own purposes are not
// affected by Umbraco's automatic middleware registration.
builder.Services.TryAddSingleton<IUmbracoManagedOutputCacheMarker, UmbracoManagedOutputCacheMarker>();
return builder;
}
}
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Delivery.Handlers;
@@ -13,7 +14,11 @@ internal sealed class RevokeMemberAuthenticationTokensNotificationHandler
: INotificationAsyncHandler<MemberSavedNotification>,
INotificationAsyncHandler<MemberDeletedNotification>,
INotificationAsyncHandler<AssignedMemberRolesNotification>,
INotificationAsyncHandler<RemovedMemberRolesNotification>
INotificationAsyncHandler<RemovedMemberRolesNotification>,
INotificationAsyncHandler<ExternalMemberSavedNotification>,
INotificationAsyncHandler<ExternalMemberDeletedNotification>,
INotificationAsyncHandler<AssignedExternalMemberRolesNotification>,
INotificationAsyncHandler<RemovedExternalMemberRolesNotification>
{
private readonly IMemberService _memberService;
private readonly IOpenIddictTokenManager _tokenManager;
@@ -80,6 +85,38 @@ internal sealed class RevokeMemberAuthenticationTokensNotificationHandler
}
}
public async Task HandleAsync(ExternalMemberSavedNotification notification, CancellationToken cancellationToken)
{
if (_enabled is false)
{
return;
}
foreach (ExternalMemberIdentity member in notification.SavedEntities.Where(m => m.IsLockedOut || m.IsApproved is false))
{
await RevokeTokensByKeyAsync(member.Key);
}
}
public async Task HandleAsync(ExternalMemberDeletedNotification notification, CancellationToken cancellationToken)
{
if (_enabled is false)
{
return;
}
foreach (ExternalMemberIdentity member in notification.DeletedEntities)
{
await RevokeTokensByKeyAsync(member.Key);
}
}
public async Task HandleAsync(AssignedExternalMemberRolesNotification notification, CancellationToken cancellationToken)
=> await ExternalMemberRolesChangedAsync(notification);
public async Task HandleAsync(RemovedExternalMemberRolesNotification notification, CancellationToken cancellationToken)
=> await ExternalMemberRolesChangedAsync(notification);
private async Task MemberRolesChangedAsync(MemberRolesNotification notification)
{
if (_enabled is false)
@@ -99,4 +136,32 @@ internal sealed class RevokeMemberAuthenticationTokensNotificationHandler
await RevokeTokensAsync(member);
}
}
private async Task ExternalMemberRolesChangedAsync(ExternalMemberRolesNotification notification)
{
if (_enabled is false)
{
return;
}
foreach (Guid memberKey in notification.MemberKeys)
{
await RevokeTokensByKeyAsync(memberKey);
}
}
private async Task RevokeTokensByKeyAsync(Guid memberKey)
{
var tokens = await _tokenManager.FindBySubjectAsync(memberKey.ToString()).ToArrayAsync();
if (tokens.Any() is false)
{
return;
}
_logger.LogInformation("Revoking {count} active tokens for external member with key {key}", tokens.Length, memberKey);
foreach (var token in tokens)
{
await _tokenManager.DeleteAsync(token);
}
}
}
@@ -1,3 +1,4 @@
using System.ComponentModel;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -19,6 +20,68 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
[ApiVersion("1.0")]
public class AncestorsDocumentTreeController : DocumentTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="AncestorsDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[ActivatorUtilitiesConstructor]
public AncestorsDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IDocumentStartNodeTreeFilterService treeFilterService,
IPublicAccessService publicAccessService,
IDocumentPresentationFactory documentPresentationFactory,
IDocumentPermissionFilterService documentPermissionFilterService)
: base(
entityService,
flagProviders,
treeFilterService,
publicAccessService,
documentPresentationFactory,
documentPermissionFilterService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AncestorsDocumentTreeController"/> class.
/// </summary>
/// <remarks>
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
/// constructor are ignored.
/// </remarks>
/// <param name="entityService">Service for managing and retrieving entities within Umbraco.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public AncestorsDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IDocumentStartNodeTreeFilterService treeFilterService,
IPublicAccessService publicAccessService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IDocumentPresentationFactory documentPresentationFactory,
IDocumentPermissionFilterService documentPermissionFilterService)
: this(entityService, flagProviders, treeFilterService, publicAccessService, documentPresentationFactory, documentPermissionFilterService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Document.Tree.AncestorsDocumentTreeController"/> class.
/// </summary>
@@ -60,7 +123,7 @@ public class AncestorsDocumentTreeController : DocumentTreeControllerBase
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public AncestorsDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -94,7 +157,7 @@ public class AncestorsDocumentTreeController : DocumentTreeControllerBase
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and authentication.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[ActivatorUtilitiesConstructor]
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public AncestorsDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -1,3 +1,4 @@
using System.ComponentModel;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -20,6 +21,68 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
[ApiVersion("1.0")]
public class ChildrenDocumentTreeController : DocumentTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[ActivatorUtilitiesConstructor]
public ChildrenDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IDocumentStartNodeTreeFilterService treeFilterService,
IPublicAccessService publicAccessService,
IDocumentPresentationFactory documentPresentationFactory,
IDocumentPermissionFilterService documentPermissionFilterService)
: base(
entityService,
flagProviders,
treeFilterService,
publicAccessService,
documentPresentationFactory,
documentPermissionFilterService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenDocumentTreeController"/> class.
/// </summary>
/// <remarks>
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
/// constructor are ignored.
/// </remarks>
/// <param name="entityService">Service for managing and retrieving entities within Umbraco.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public ChildrenDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IDocumentStartNodeTreeFilterService treeFilterService,
IPublicAccessService publicAccessService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IDocumentPresentationFactory documentPresentationFactory,
IDocumentPermissionFilterService documentPermissionFilterService)
: this(entityService, flagProviders, treeFilterService, publicAccessService, documentPresentationFactory, documentPermissionFilterService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenDocumentTreeController"/> class.
/// </summary>
@@ -61,7 +124,7 @@ public class ChildrenDocumentTreeController : DocumentTreeControllerBase
/// <param name="appCaches">Provides application-level caching functionality.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and authentication.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models for the API.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public ChildrenDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -95,7 +158,7 @@ public class ChildrenDocumentTreeController : DocumentTreeControllerBase
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on permissions.</param>
[ActivatorUtilitiesConstructor]
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public ChildrenDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -29,11 +29,14 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBase<DocumentTreeItemResponseModel>
{
private readonly IPublicAccessService _publicAccessService;
private readonly AppCaches _appCaches;
private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor;
private readonly IDocumentPresentationFactory _documentPresentationFactory;
private readonly IDocumentPermissionFilterService _documentPermissionFilterService;
// Only populated by the obsolete constructor path; used solely by the obsolete
// GetUserStartNodeIds / GetUserStartNodePaths overrides below.
private readonly AppCaches? _appCaches;
private readonly IBackOfficeSecurityAccessor? _backofficeSecurityAccessor;
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
protected DocumentTreeControllerBase(
IEntityService entityService,
@@ -55,7 +58,7 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
{
}
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
protected DocumentTreeControllerBase(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -78,7 +81,7 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
{
}
[ActivatorUtilitiesConstructor]
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
protected DocumentTreeControllerBase(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -98,6 +101,30 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
_documentPermissionFilterService = documentPermissionFilterService;
}
/// <summary>
/// Initializes a new instance of the <see cref="DocumentTreeControllerBase"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[ActivatorUtilitiesConstructor]
protected DocumentTreeControllerBase(
IEntityService entityService,
FlagProviderCollection flagProviders,
IDocumentStartNodeTreeFilterService treeFilterService,
IPublicAccessService publicAccessService,
IDocumentPresentationFactory documentPresentationFactory,
IDocumentPermissionFilterService documentPermissionFilterService)
: base(entityService, flagProviders, treeFilterService)
{
_publicAccessService = publicAccessService;
_documentPresentationFactory = documentPresentationFactory;
_documentPermissionFilterService = documentPermissionFilterService;
}
protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Document;
protected override Ordering ItemOrdering => Ordering.By(Infrastructure.Persistence.Dtos.NodeDto.SortOrderColumnName);
@@ -122,21 +149,27 @@ public abstract class DocumentTreeControllerBase : UserStartNodeTreeControllerBa
return responseModel;
}
// Only invoked via the CallbackStartNodeTreeFilterService wired up by the obsolete
// UserStartNodeTreeControllerBase constructor. The non-obsolete constructor path
// routes start node resolution through IDocumentStartNodeTreeFilterService and
// never calls these overrides; hence the null-forgiving operator on _appCaches.
/// <inheritdoc/>
[Obsolete("No longer used. Register a custom IDocumentStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
protected override int[] GetUserStartNodeIds()
=> _backofficeSecurityAccessor
=> _backofficeSecurityAccessor?
.BackOfficeSecurity?
.CurrentUser?
.CalculateContentStartNodeIds(EntityService, _appCaches)
?? Array.Empty<int>();
.CalculateContentStartNodeIds(EntityService, _appCaches!)
?? [];
/// <inheritdoc/>
[Obsolete("No longer used. Register a custom IDocumentStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
protected override string[] GetUserStartNodePaths()
=> _backofficeSecurityAccessor
=> _backofficeSecurityAccessor?
.BackOfficeSecurity?
.CurrentUser?
.GetContentStartNodePaths(EntityService, _appCaches)
?? Array.Empty<string>();
.GetContentStartNodePaths(EntityService, _appCaches!)
?? [];
/// <inheritdoc/>
protected override Task<(IEntitySlim[] Entities, long TotalItems)> FilterTreeEntities(IEntitySlim[] entities, long totalItems)
@@ -1,3 +1,4 @@
using System.ComponentModel;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -20,6 +21,68 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
[ApiVersion("1.0")]
public class RootDocumentTreeController : DocumentTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="RootDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[ActivatorUtilitiesConstructor]
public RootDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IDocumentStartNodeTreeFilterService treeFilterService,
IPublicAccessService publicAccessService,
IDocumentPresentationFactory documentPresentationFactory,
IDocumentPermissionFilterService documentPermissionFilterService)
: base(
entityService,
flagProviders,
treeFilterService,
publicAccessService,
documentPresentationFactory,
documentPermissionFilterService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RootDocumentTreeController"/> class.
/// </summary>
/// <remarks>
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
/// constructor are ignored.
/// </remarks>
/// <param name="entityService">Service for managing and retrieving entities within Umbraco.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public RootDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IDocumentStartNodeTreeFilterService treeFilterService,
IPublicAccessService publicAccessService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IDocumentPresentationFactory documentPresentationFactory,
IDocumentPermissionFilterService documentPermissionFilterService)
: this(entityService, flagProviders, treeFilterService, publicAccessService, documentPresentationFactory, documentPermissionFilterService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RootDocumentTreeController"/> class, which manages the root nodes of the document tree in the Umbraco backoffice.
/// </summary>
@@ -61,7 +124,7 @@ public class RootDocumentTreeController : DocumentTreeControllerBase
/// <param name="appCaches">Provides application-level caching mechanisms.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public RootDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -95,7 +158,7 @@ public class RootDocumentTreeController : DocumentTreeControllerBase
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[ActivatorUtilitiesConstructor]
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public RootDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -1,3 +1,4 @@
using System.ComponentModel;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -20,6 +21,68 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
[ApiVersion("1.0")]
public class SiblingsDocumentTreeController : DocumentTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="SiblingsDocumentTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[ActivatorUtilitiesConstructor]
public SiblingsDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IDocumentStartNodeTreeFilterService treeFilterService,
IPublicAccessService publicAccessService,
IDocumentPresentationFactory documentPresentationFactory,
IDocumentPermissionFilterService documentPermissionFilterService)
: base(
entityService,
flagProviders,
treeFilterService,
publicAccessService,
documentPresentationFactory,
documentPermissionFilterService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SiblingsDocumentTreeController"/> class.
/// </summary>
/// <remarks>
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
/// constructor are ignored.
/// </remarks>
/// <param name="entityService">Service for managing and retrieving entities within Umbraco.</param>
/// <param name="flagProviders">A collection of providers that supply flags for document tree nodes.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="treeFilterService">Service for filtering document tree entities based on user start nodes.</param>
/// <param name="publicAccessService">Service for handling public access permissions on documents.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public SiblingsDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IDocumentStartNodeTreeFilterService treeFilterService,
IPublicAccessService publicAccessService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IDocumentPresentationFactory documentPresentationFactory,
IDocumentPermissionFilterService documentPermissionFilterService)
: this(entityService, flagProviders, treeFilterService, publicAccessService, documentPresentationFactory, documentPermissionFilterService)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SiblingsDocumentTreeController"/> class.
/// </summary>
@@ -61,7 +124,7 @@ public class SiblingsDocumentTreeController : DocumentTreeControllerBase
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public SiblingsDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -95,7 +158,7 @@ public class SiblingsDocumentTreeController : DocumentTreeControllerBase
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and user information.</param>
/// <param name="documentPresentationFactory">Factory for creating document presentation models.</param>
/// <param name="documentPermissionFilterService">Service for filtering documents based on user permissions.</param>
[ActivatorUtilitiesConstructor]
[Obsolete("Please use the constructor accepting IDocumentStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public SiblingsDocumentTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -1,3 +1,4 @@
using System.ComponentModel;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -18,6 +19,54 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
[ApiVersion("1.0")]
public class AncestorsMediaTreeController : MediaTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="AncestorsMediaTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities within the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[ActivatorUtilitiesConstructor]
public AncestorsMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IMediaStartNodeTreeFilterService treeFilterService,
IMediaPresentationFactory mediaPresentationFactory)
: base(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AncestorsMediaTreeController"/> class.
/// </summary>
/// <remarks>
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
/// constructor are ignored.
/// </remarks>
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public AncestorsMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IMediaStartNodeTreeFilterService treeFilterService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IMediaPresentationFactory mediaPresentationFactory)
: this(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AncestorsMediaTreeController"/> class.
/// </summary>
@@ -49,7 +98,7 @@ public class AncestorsMediaTreeController : MediaTreeControllerBase
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[ActivatorUtilitiesConstructor]
[Obsolete("Please use the constructor accepting IMediaStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public AncestorsMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -1,3 +1,4 @@
using System.ComponentModel;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -19,6 +20,54 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
[ApiVersion("1.0")]
public class ChildrenMediaTreeController : MediaTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenMediaTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities within the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[ActivatorUtilitiesConstructor]
public ChildrenMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IMediaStartNodeTreeFilterService treeFilterService,
IMediaPresentationFactory mediaPresentationFactory)
: base(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenMediaTreeController"/> class.
/// </summary>
/// <remarks>
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
/// constructor are ignored.
/// </remarks>
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public ChildrenMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IMediaStartNodeTreeFilterService treeFilterService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IMediaPresentationFactory mediaPresentationFactory)
: this(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ChildrenMediaTreeController"/> class, responsible for handling API requests related to child media items in the media tree.
/// </summary>
@@ -50,7 +99,7 @@ public class ChildrenMediaTreeController : MediaTreeControllerBase
/// <param name="appCaches">Provides access to application-level caches for performance optimization.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context, used for authorization and user information.</param>
/// <param name="mediaPresentationFactory">Factory for creating presentation models for media entities.</param>
[ActivatorUtilitiesConstructor]
[Obsolete("Please use the constructor accepting IMediaStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public ChildrenMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -1,3 +1,4 @@
using System.ComponentModel;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
@@ -26,10 +27,13 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
[Authorize(Policy = AuthorizationPolicies.SectionAccessForMediaTree)]
public class MediaTreeControllerBase : UserStartNodeTreeControllerBase<MediaTreeItemResponseModel>
{
private readonly AppCaches _appCaches;
private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor;
private readonly IMediaPresentationFactory _mediaPresentationFactory;
// Only populated by the obsolete constructor path; used solely by the obsolete
// GetUserStartNodeIds / GetUserStartNodePaths overrides below.
private readonly AppCaches? _appCaches;
private readonly IBackOfficeSecurityAccessor? _backofficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Media.Tree.MediaTreeControllerBase"/> class.
/// </summary>
@@ -68,7 +72,7 @@ public class MediaTreeControllerBase : UserStartNodeTreeControllerBase<MediaTree
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[ActivatorUtilitiesConstructor]
[Obsolete("Please use the constructor accepting IMediaStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public MediaTreeControllerBase(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -77,11 +81,64 @@ public class MediaTreeControllerBase : UserStartNodeTreeControllerBase<MediaTree
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IMediaPresentationFactory mediaPresentationFactory)
: base(entityService, flagProviders, userStartNodeEntitiesService, dataTypeService)
: base(
entityService,
flagProviders,
userStartNodeEntitiesService,
dataTypeService)
{
_mediaPresentationFactory = mediaPresentationFactory;
_appCaches = appCaches;
_backofficeSecurityAccessor = backofficeSecurityAccessor;
}
/// <summary>
/// Initializes a new instance of the <see cref="MediaTreeControllerBase"/> class.
/// </summary>
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[ActivatorUtilitiesConstructor]
public MediaTreeControllerBase(
IEntityService entityService,
FlagProviderCollection flagProviders,
IMediaStartNodeTreeFilterService treeFilterService,
IMediaPresentationFactory mediaPresentationFactory)
: base(entityService, flagProviders, treeFilterService) =>
_mediaPresentationFactory = mediaPresentationFactory;
/// <summary>
/// Initializes a new instance of the <see cref="MediaTreeControllerBase"/> class.
/// </summary>
/// <remarks>
/// This constructor is a parameter superset of the new and existing obsolete constructors. It exists
/// solely because <see cref="ActivatorUtilitiesConstructorAttribute"/> is not honoured by the DI
/// <c>CallSiteFactory</c> at <c>ServiceProvider</c> <c>ValidateOnBuild</c> time, which requires an
/// unambiguous single best-match constructor; all parameters except those forwarded to the non-obsolete
/// constructor are ignored.
/// </remarks>
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public MediaTreeControllerBase(
IEntityService entityService,
FlagProviderCollection flagProviders,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IMediaStartNodeTreeFilterService treeFilterService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IMediaPresentationFactory mediaPresentationFactory)
: this(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
{
}
protected override UmbracoObjectTypes ItemObjectType => UmbracoObjectTypes.Media;
@@ -105,17 +162,23 @@ public class MediaTreeControllerBase : UserStartNodeTreeControllerBase<MediaTree
return responseModel;
}
// Only invoked via the CallbackStartNodeTreeFilterService wired up by the obsolete
// UserStartNodeTreeControllerBase constructor. The non-obsolete constructor path
// routes start node resolution through IMediaStartNodeTreeFilterService and
// never calls these overrides; hence the null-forgiving operator on _appCaches.
[Obsolete("No longer used. Register a custom IMediaStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
protected override int[] GetUserStartNodeIds()
=> _backofficeSecurityAccessor
=> _backofficeSecurityAccessor?
.BackOfficeSecurity?
.CurrentUser?
.CalculateMediaStartNodeIds(EntityService, _appCaches)
?? Array.Empty<int>();
.CalculateMediaStartNodeIds(EntityService, _appCaches!)
?? [];
[Obsolete("No longer used. Register a custom IMediaStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
protected override string[] GetUserStartNodePaths()
=> _backofficeSecurityAccessor
=> _backofficeSecurityAccessor?
.BackOfficeSecurity?
.CurrentUser?
.GetMediaStartNodePaths(EntityService, _appCaches)
?? Array.Empty<string>();
.GetMediaStartNodePaths(EntityService, _appCaches!)
?? [];
}
@@ -1,3 +1,4 @@
using System.ComponentModel;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -19,6 +20,54 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
[ApiVersion("1.0")]
public class RootMediaTreeController : MediaTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="RootMediaTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for managing and retrieving entities in the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[ActivatorUtilitiesConstructor]
public RootMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IMediaStartNodeTreeFilterService treeFilterService,
IMediaPresentationFactory mediaPresentationFactory)
: base(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RootMediaTreeController"/> class.
/// </summary>
/// <remarks>
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
/// constructor are ignored.
/// </remarks>
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public RootMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IMediaStartNodeTreeFilterService treeFilterService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IMediaPresentationFactory mediaPresentationFactory)
: this(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RootMediaTreeController"/> class, which manages the root of the media tree in the Umbraco backoffice API.
/// </summary>
@@ -50,7 +99,7 @@ public class RootMediaTreeController : MediaTreeControllerBase
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[ActivatorUtilitiesConstructor]
[Obsolete("Please use the constructor accepting IMediaStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public RootMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -1,3 +1,4 @@
using System.ComponentModel;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
@@ -18,6 +19,54 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
/// </summary>
public class SiblingsMediaTreeController : MediaTreeControllerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="SiblingsMediaTreeController"/> class.
/// </summary>
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[ActivatorUtilitiesConstructor]
public SiblingsMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IMediaStartNodeTreeFilterService treeFilterService,
IMediaPresentationFactory mediaPresentationFactory)
: base(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SiblingsMediaTreeController"/> class.
/// </summary>
/// <remarks>
/// This constructor exists solely to disambiguate DI container constructor resolution between the new
/// and the existing obsolete constructors; all parameters except those forwarded to the non-obsolete
/// constructor are ignored.
/// </remarks>
/// <param name="entityService">Service for accessing and managing entities within the system.</param>
/// <param name="flagProviders">A collection of providers that supply flags for entities.</param>
/// <param name="userStartNodeEntitiesService">Service for resolving user start nodes for entities.</param>
/// <param name="dataTypeService">Service for accessing and managing data types.</param>
/// <param name="treeFilterService">Service for filtering media tree entities based on user start nodes.</param>
/// <param name="appCaches">Provides access to application-level caches.</param>
/// <param name="backofficeSecurityAccessor">Accessor for backoffice security context and operations.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models.</param>
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 19.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public SiblingsMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IMediaStartNodeTreeFilterService treeFilterService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IMediaPresentationFactory mediaPresentationFactory)
: this(entityService, flagProviders, treeFilterService, mediaPresentationFactory)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SiblingsMediaTreeController"/> class, responsible for handling API requests related to sibling media items in the media tree.
/// </summary>
@@ -49,7 +98,7 @@ public class SiblingsMediaTreeController : MediaTreeControllerBase
/// <param name="appCaches">Provides access to application-level caches for performance optimization.</param>
/// <param name="backofficeSecurityAccessor">Accessor for back office security context, used for authorization and user information.</param>
/// <param name="mediaPresentationFactory">Factory for creating media presentation models for API responses.</param>
[ActivatorUtilitiesConstructor]
[Obsolete("Please use the constructor accepting IMediaStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
public SiblingsMediaTreeController(
IEntityService entityService,
FlagProviderCollection flagProviders,
@@ -1,9 +1,11 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services;
using Umbraco.Cms.Api.Management.ViewModels.Member;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
@@ -16,24 +18,43 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member;
[ApiVersion("1.0")]
public class ByKeyMemberController : MemberControllerBase
{
private readonly IMemberEditingService _memberEditingService;
private readonly IMemberPresentationFactory _memberPresentationFactory;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IMemberPresentationService _memberPresentationService;
// TODO (V19): Remove the unnecessary parameters provided to the constructor.
/// <summary>
/// Initializes a new instance of the <see cref="ByKeyMemberController"/> class, which handles member management operations by member key.
/// Initializes a new instance of the <see cref="ByKeyMemberController"/> class.
/// </summary>
/// <param name="memberEditingService">Service used to perform editing operations on members.</param>
/// <param name="memberPresentationFactory">Factory for creating member presentation models.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
/// <param name="memberPresentationService">Service for resolving members across both content and external stores.</param>
[ActivatorUtilitiesConstructor]
public ByKeyMemberController(
IMemberEditingService memberEditingService,
IMemberPresentationFactory memberPresentationFactory,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IMemberPresentationService memberPresentationService)
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_memberPresentationService = memberPresentationService;
}
/// <summary>
/// Initializes a new instance of the <see cref="ByKeyMemberController"/> class.
/// </summary>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public ByKeyMemberController(
IMemberEditingService memberEditingService,
IMemberPresentationFactory memberPresentationFactory,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
: this(
memberEditingService,
memberPresentationFactory,
backOfficeSecurityAccessor,
StaticServiceProvider.Instance.GetRequiredService<IMemberPresentationService>())
{
_memberEditingService = memberEditingService;
_memberPresentationFactory = memberPresentationFactory;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
@@ -52,13 +73,7 @@ public class ByKeyMemberController : MemberControllerBase
[EndpointDescription("Gets a member identified by the provided Id.")]
public async Task<IActionResult> ByKey(CancellationToken cancellationToken, Guid id)
{
IMember? member = await _memberEditingService.GetAsync(id);
if (member == null)
{
return MemberNotFound();
}
MemberResponseModel model = await _memberPresentationFactory.CreateResponseModelAsync(member, CurrentUser(_backOfficeSecurityAccessor));
return Ok(model);
MemberResponseModel? model = await _memberPresentationService.CreateResponseModelByKeyAsync(id, CurrentUser(_backOfficeSecurityAccessor));
return model is not null ? Ok(model) : MemberNotFound();
}
}
@@ -19,11 +19,13 @@ public class DeleteMemberController : MemberControllerBase
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="DeleteMemberController"/> class, which handles member deletion operations.
/// Initializes a new instance of the <see cref="DeleteMemberController"/> class.
/// </summary>
/// <param name="memberEditingService">Service used to perform member editing and deletion operations.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context and authorization.</param>
public DeleteMemberController(IMemberEditingService memberEditingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
public DeleteMemberController(
IMemberEditingService memberEditingService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_memberEditingService = memberEditingService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
@@ -1,10 +1,15 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Member;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
@@ -19,40 +24,48 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member.Filter;
[ApiVersion("1.0")]
public class FilterMemberFilterController : MemberFilterControllerBase
{
private readonly IMemberService _memberService;
private readonly IMemberFilterService _memberFilterService;
private readonly IMemberPresentationFactory _memberPresentationFactory;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="FilterMemberFilterController"/> class.
/// </summary>
/// <param name="memberService">Service used for member management operations.</param>
/// <param name="memberService">Service used for member management operations (unused, retained for DI compatibility).</param>
/// <param name="memberPresentationFactory">Factory responsible for creating member presentation models.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context and authentication.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context (unused, retained for DI compatibility).</param>
/// <param name="memberFilterService">Service for combined member filtering across content and external stores.</param>
// TODO (V19): Remove unused parameters which are only here to avoid ambiguous constructor errors.
[ActivatorUtilitiesConstructor]
public FilterMemberFilterController(
IMemberService memberService,
IMemberPresentationFactory memberPresentationFactory,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IMemberFilterService memberFilterService)
{
_memberFilterService = memberFilterService;
_memberPresentationFactory = memberPresentationFactory;
}
/// <summary>
/// Initializes a new instance of the <see cref="FilterMemberFilterController"/> class.
/// </summary>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public FilterMemberFilterController(
IMemberService memberService,
IMemberPresentationFactory memberPresentationFactory,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
: this(
memberService,
memberPresentationFactory,
backOfficeSecurityAccessor,
StaticServiceProvider.Instance.GetRequiredService<IMemberFilterService>())
{
_memberService = memberService;
_memberPresentationFactory = memberPresentationFactory;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Retrieves a paged, filtered collection of members based on the specified criteria.
/// Returns both content-based and external-only members in a unified, correctly paginated result.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="memberTypeId">An optional member type identifier to filter the results.</param>
/// <param name="memberGroupName">An optional member group name to filter the results.</param>
/// <param name="isApproved">An optional value to filter by member approval status.</param>
/// <param name="isLockedOut">An optional value to filter by member lockout status.</param>
/// <param name="orderBy">The field by which to order the results. The default is <c>"username"</c>.</param>
/// <param name="orderDirection">The direction in which to order the results. The default is <see cref="Direction.Ascending"/>.</param>
/// <param name="filter">An optional filter string to search for members.</param>
/// <param name="skip">The number of items to skip for pagination. The default is 0.</param>
/// <param name="take">The number of items to return for pagination. The default is 100.</param>
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedViewModel{MemberResponseModel}"/> representing the filtered members.</returns>
[HttpGet]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<MemberResponseModel>), StatusCodes.Status200OK)]
@@ -71,7 +84,7 @@ public class FilterMemberFilterController : MemberFilterControllerBase
int skip = 0,
int take = 100)
{
var memberFilter = new MemberFilter()
var memberFilter = new MemberFilter
{
MemberTypeId = memberTypeId,
MemberGroupName = memberGroupName,
@@ -80,14 +93,14 @@ public class FilterMemberFilterController : MemberFilterControllerBase
Filter = filter,
};
PagedModel<IMember> members = await _memberService.FilterAsync(memberFilter, orderBy, orderDirection, skip, take);
PagedModel<MemberFilterItem> result = await _memberFilterService.FilterAsync(memberFilter, orderBy, orderDirection, skip, take);
var pageViewModel = new PagedViewModel<MemberResponseModel>
var responseModels = result.Items.Select(_memberPresentationFactory.CreateFilterItemResponseModel).ToList();
return Ok(new PagedViewModel<MemberResponseModel>
{
Items = await _memberPresentationFactory.CreateMultipleAsync(members.Items, CurrentUser(_backOfficeSecurityAccessor)),
Total = members.Total,
};
return Ok(pageViewModel);
Items = responseModels,
Total = result.Total,
});
}
}
@@ -1,11 +1,11 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services;
using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.Member.Item;
@@ -17,18 +17,37 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member.Item;
[ApiVersion("1.0")]
public class ItemMemberItemController : MemberItemControllerBase
{
private readonly IEntityService _entityService;
private readonly IMemberPresentationFactory _memberPresentationFactory;
private readonly IMemberPresentationService _memberPresentationService;
// TODO (V19): Remove the unnecessary parameters provided to the constructor.
/// <summary>
/// Initializes a new instance of the <see cref="ItemMemberItemController"/> class, which manages member item operations in the API.
/// Initializes a new instance of the <see cref="ItemMemberItemController"/> class.
/// </summary>
/// <param name="entityService">Service used for entity operations and retrieval.</param>
/// <param name="memberPresentationFactory">Factory responsible for creating member presentation models.</param>
public ItemMemberItemController(IEntityService entityService, IMemberPresentationFactory memberPresentationFactory)
/// <param name="memberPresentationService">Service for resolving members across both content and external stores.</param>
[ActivatorUtilitiesConstructor]
public ItemMemberItemController(
IEntityService entityService,
IMemberPresentationFactory memberPresentationFactory,
IMemberPresentationService memberPresentationService)
{
_memberPresentationService = memberPresentationService;
}
/// <summary>
/// Initializes a new instance of the <see cref="ItemMemberItemController"/> class.
/// </summary>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public ItemMemberItemController(
IEntityService entityService,
IMemberPresentationFactory memberPresentationFactory)
: this(
entityService,
memberPresentationFactory,
StaticServiceProvider.Instance.GetRequiredService<IMemberPresentationService>())
{
_entityService = entityService;
_memberPresentationFactory = memberPresentationFactory;
}
[HttpGet]
@@ -36,20 +55,16 @@ public class ItemMemberItemController : MemberItemControllerBase
[ProducesResponseType(typeof(IEnumerable<MemberItemResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets a collection of member items.")]
[EndpointDescription("Gets a collection of member items identified by the provided Ids.")]
public Task<IActionResult> Item(
public async Task<IActionResult> Item(
CancellationToken cancellationToken,
[FromQuery(Name = "id")] HashSet<Guid> ids)
{
if (ids.Count is 0)
{
return Task.FromResult<IActionResult>(Ok(Enumerable.Empty<MemberItemResponseModel>()));
return Ok(Enumerable.Empty<MemberItemResponseModel>());
}
IEnumerable<IMemberEntitySlim> members = _entityService
.GetAll(UmbracoObjectTypes.Member, ids.ToArray())
.OfType<IMemberEntitySlim>();
IEnumerable<MemberItemResponseModel> responseModels = members.Select(_memberPresentationFactory.CreateItemResponseModel);
return Task.FromResult<IActionResult>(Ok(responseModels));
IEnumerable<MemberItemResponseModel> responseModels = await _memberPresentationService.CreateItemResponseModelsAsync(ids);
return Ok(responseModels);
}
}
@@ -96,6 +96,15 @@ public class MemberControllerBase : ContentControllerBase
where TContentModelBase : ContentModelBase<MemberValueModel, MemberVariantRequestModel>
=> ContentEditingOperationStatusResult<TContentModelBase, MemberValueModel, MemberVariantRequestModel>(status, requestModel, validationResult);
/// <summary>
/// Returns a 400 Bad Request indicating that external-only members cannot be modified through the Management API.
/// </summary>
protected IActionResult ExternalMemberCannotBeModified()
=> BadRequest(new ProblemDetailsBuilder()
.WithTitle("External member cannot be modified")
.WithDetail("This member is managed by an external provider. Content operations such as create, update, and property editing are not available for external-only members.")
.Build());
private IActionResult MemberNotFound(ProblemDetailsBuilder problemDetailsBuilder) => NotFound(problemDetailsBuilder
.WithTitle("The requested member could not be found")
.Build());
@@ -1,10 +1,13 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services;
using Umbraco.Cms.Api.Management.ViewModels.TrackedReferences;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -17,20 +20,39 @@ namespace Umbraco.Cms.Api.Management.Controllers.Member.References;
[ApiVersion("1.0")]
public class ReferencedByMemberController : MemberControllerBase
{
private readonly ITrackedReferencesService _trackedReferencesService;
private readonly IRelationTypePresentationFactory _relationTypePresentationFactory;
private readonly IMemberReferenceService _memberReferenceService;
// TODO (V19): Remove the unnecessary parameters provided to the constructor.
/// <summary>
/// Initializes a new instance of the <see cref="ReferencedByMemberController"/> class.
/// </summary>
/// <param name="trackedReferencesService">An implementation of <see cref="ITrackedReferencesService"/> used to manage tracked references.</param>
/// <param name="relationTypePresentationFactory">An implementation of <see cref="IRelationTypePresentationFactory"/> used to create relation type presentations.</param>
/// <param name="memberReferenceService">Service for retrieving paged references to a member.</param>
[ActivatorUtilitiesConstructor]
public ReferencedByMemberController(
ITrackedReferencesService trackedReferencesService,
IRelationTypePresentationFactory relationTypePresentationFactory,
IMemberReferenceService memberReferenceService)
{
_relationTypePresentationFactory = relationTypePresentationFactory;
_memberReferenceService = memberReferenceService;
}
/// <summary>
/// Initializes a new instance of the <see cref="ReferencedByMemberController"/> class.
/// </summary>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public ReferencedByMemberController(
ITrackedReferencesService trackedReferencesService,
IRelationTypePresentationFactory relationTypePresentationFactory)
: this(
trackedReferencesService,
relationTypePresentationFactory,
StaticServiceProvider.Instance.GetRequiredService<IMemberReferenceService>())
{
_trackedReferencesService = trackedReferencesService;
_relationTypePresentationFactory = relationTypePresentationFactory;
}
/// <summary>
@@ -52,12 +74,12 @@ public class ReferencedByMemberController : MemberControllerBase
int skip = 0,
int take = 20)
{
PagedModel<RelationItemModel> relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true);
Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus> result = await _memberReferenceService.GetPagedReferencesAsync(id, skip, take);
var pagedViewModel = new PagedViewModel<IReferenceResponseModel>
{
Total = relationItems.Total,
Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItems.Items),
Total = result.Result.Total,
Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(result.Result.Items),
};
return pagedViewModel;
@@ -87,17 +109,17 @@ public class ReferencedByMemberController : MemberControllerBase
int skip = 0,
int take = 20)
{
Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus> relationItemsAttempt = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, skip, take, true);
Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus> result = await _memberReferenceService.GetPagedReferencesAsync(id, skip, take);
if (relationItemsAttempt.Success is false)
if (result.Success is false)
{
return GetReferencesOperationStatusResult(relationItemsAttempt.Status);
return GetReferencesOperationStatusResult(result.Status);
}
var pagedViewModel = new PagedViewModel<IReferenceResponseModel>
{
Total = relationItemsAttempt.Result.Total,
Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(relationItemsAttempt.Result.Items),
Total = result.Result.Total,
Items = await _relationTypePresentationFactory.CreateReferenceResponseModelsAsync(result.Result.Items),
};
return Ok(pagedViewModel);
@@ -22,7 +22,7 @@ public class UpdateMemberController : MemberControllerBase
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="UpdateMemberController"/> class, responsible for handling member update operations in the management API.
/// Initializes a new instance of the <see cref="UpdateMemberController"/> class.
/// </summary>
/// <param name="memberEditingService">Service used to perform member editing operations.</param>
/// <param name="memberEditingPresentationFactory">Factory for creating presentation models related to member editing.</param>
@@ -49,6 +49,13 @@ public class UpdateMemberController : MemberControllerBase
Guid id,
UpdateMemberRequestModel updateRequestModel)
{
// External-only members cannot be updated through this endpoint.
// Their identity data is managed by the external provider.
if (await _memberEditingService.IsExternalMemberAsync(id))
{
return ExternalMemberCannotBeModified();
}
MemberUpdateModel model = _memberEditingPresentationFactory.MapUpdateModel(updateRequestModel);
Attempt<MemberUpdateResult, MemberEditingStatus> result = await _memberEditingService.UpdateAsync(id, model, CurrentUser(_backOfficeSecurityAccessor));
@@ -20,7 +20,7 @@ public class ValidateUpdateMemberController : MemberControllerBase
private readonly IMemberEditingPresentationFactory _memberEditingPresentationFactory;
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Member.ValidateUpdateMemberController"/> class.
/// Initializes a new instance of the <see cref="ValidateUpdateMemberController"/> class.
/// </summary>
/// <param name="memberEditingService">The <see cref="IMemberEditingService"/> used for member editing operations.</param>
/// <param name="memberEditingPresentationFactory">The <see cref="IMemberEditingPresentationFactory"/> used to create member editing presentations.</param>
@@ -44,6 +44,12 @@ public class ValidateUpdateMemberController : MemberControllerBase
Guid id,
UpdateMemberRequestModel requestModel)
{
// External-only members cannot be updated through this endpoint.
if (await _memberEditingService.IsExternalMemberAsync(id))
{
return ExternalMemberCannotBeModified();
}
MemberUpdateModel model = _memberEditingPresentationFactory.MapUpdateModel(requestModel);
Attempt<ContentValidationResult, ContentEditingOperationStatus> result = await _memberEditingService.ValidateUpdateAsync(id, model);
@@ -5,9 +5,9 @@ using Umbraco.Cms.Api.Management.Services.Flags;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Tree;
@@ -18,11 +18,8 @@ namespace Umbraco.Cms.Api.Management.Controllers.Tree;
public abstract class UserStartNodeTreeControllerBase<TItem> : EntityTreeControllerBase<TItem>
where TItem : ContentTreeItemResponseModel, new()
{
private readonly IUserStartNodeEntitiesService _userStartNodeEntitiesService;
private readonly IDataTypeService _dataTypeService;
private readonly IUserStartNodeTreeFilterService _treeFilterService;
private int[]? _userStartNodeIds;
private string[]? _userStartNodePaths;
private Dictionary<Guid, bool> _accessMap = new();
private Guid? _dataTypeKey;
@@ -39,117 +36,87 @@ public abstract class UserStartNodeTreeControllerBase<TItem> : EntityTreeControl
{
}
#pragma warning disable CS0618 // Type or member is obsolete
[Obsolete("Please use the constructor accepting IUserStartNodeTreeFilterService. Scheduled for removal in Umbraco 19.")]
protected UserStartNodeTreeControllerBase(
IEntityService entityService,
FlagProviderCollection flagProviders,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService)
: base(entityService, flagProviders)
{
_userStartNodeEntitiesService = userStartNodeEntitiesService;
_dataTypeService = dataTypeService;
}
=> _treeFilterService = new CallbackStartNodeTreeFilterService(
userStartNodeEntitiesService,
dataTypeService,
GetUserStartNodeIds,
GetUserStartNodePaths,
() => ItemObjectType);
#pragma warning restore CS0618 // Type or member is obsolete
protected abstract int[] GetUserStartNodeIds();
/// <summary>
/// Initializes a new instance of the <see cref="UserStartNodeTreeControllerBase{TItem}"/> class.
/// </summary>
/// <param name="entityService">The entity service.</param>
/// <param name="flagProviders">The flag provider collection.</param>
/// <param name="treeFilterService">The user start node tree filter service.</param>
protected UserStartNodeTreeControllerBase(
IEntityService entityService,
FlagProviderCollection flagProviders,
IUserStartNodeTreeFilterService treeFilterService)
: base(entityService, flagProviders) =>
_treeFilterService = treeFilterService;
protected abstract string[] GetUserStartNodePaths();
/// <summary>
/// Gets the calculated start node IDs for the current user.
/// </summary>
/// <returns>An array of start node IDs.</returns>
[Obsolete("No longer used. Register a custom IUserStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
protected virtual int[] GetUserStartNodeIds() => [];
/// <summary>
/// Gets the calculated start node paths for the current user.
/// </summary>
/// <returns>An array of start node paths.</returns>
[Obsolete("No longer used. Register a custom IUserStartNodeTreeFilterService instead. Scheduled for removal in Umbraco 19.")]
protected virtual string[] GetUserStartNodePaths() => [];
/// <summary>
/// Configures the controller to ignore user start nodes for a specific data type.
/// </summary>
/// <param name="dataTypeKey">The data type key, or <c>null</c> to disable.</param>
protected void IgnoreUserStartNodesForDataType(Guid? dataTypeKey) => _dataTypeKey = dataTypeKey;
/// <inheritdoc />
protected override IEntitySlim[] GetPagedRootEntities(int skip, int take, out long totalItems)
=> UserHasRootAccess() || IgnoreUserStartNodes()
=> ShouldBypassStartNodeFiltering()
? base.GetPagedRootEntities(skip, take, out totalItems)
: CalculateAccessMap(() => _userStartNodeEntitiesService.RootUserAccessEntities(ItemObjectType, UserStartNodeIds), out totalItems);
: MapAccessEntities(_treeFilterService.GetFilteredRootEntities(out totalItems));
/// <inheritdoc />
protected override IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems)
{
if (UserHasRootAccess() || IgnoreUserStartNodes())
{
return base.GetPagedChildEntities(parentKey, skip, take, out totalItems);
}
IEnumerable<UserAccessEntity> userAccessEntities = _userStartNodeEntitiesService.ChildUserAccessEntities(
ItemObjectType,
UserStartNodePaths,
parentKey,
skip,
take,
ItemOrdering,
out totalItems);
return CalculateAccessMap(() => userAccessEntities, out _);
}
=> ShouldBypassStartNodeFiltering()
? base.GetPagedChildEntities(parentKey, skip, take, out totalItems)
: MapAccessEntities(_treeFilterService.GetFilteredChildEntities(parentKey, skip, take, ItemOrdering, out totalItems));
/// <inheritdoc />
protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter)
{
if (UserHasRootAccess() || IgnoreUserStartNodes())
{
return base.GetSiblingEntities(target, before, after, out totalBefore, out totalAfter);
}
IEnumerable<UserAccessEntity> userAccessEntities = _userStartNodeEntitiesService.SiblingUserAccessEntities(
ItemObjectType,
UserStartNodePaths,
target,
before,
after,
ItemOrdering,
out totalBefore,
out totalAfter);
return CalculateAccessMap(() => userAccessEntities, out _);
}
=> ShouldBypassStartNodeFiltering()
? base.GetSiblingEntities(target, before, after, out totalBefore, out totalAfter)
: MapAccessEntities(_treeFilterService.GetFilteredSiblingEntities(target, before, after, ItemOrdering, out totalBefore, out totalAfter));
/// <inheritdoc />
protected override TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities)
=> ShouldBypassStartNodeFiltering()
? base.MapTreeItemViewModels(parentKey, entities)
: _treeFilterService.MapWithAccessFiltering(
entities,
_accessMap,
entity => MapTreeItemViewModel(parentKey, entity),
entity => MapTreeItemViewModelAsNoAccess(parentKey, entity));
private IEntitySlim[] MapAccessEntities(UserAccessEntity[] userAccessEntities)
{
if (UserHasRootAccess() || IgnoreUserStartNodes())
{
return base.MapTreeItemViewModels(parentKey, entities);
}
// for users with no root access, only add items for the entities contained within the calculated access map.
// the access map may contain entities that the user does not have direct access to, but need still to see,
// because it has descendants that the user *does* have access to. these entities are added as "no access" items.
TItem[] contentTreeItemViewModels = entities.Select(entity =>
{
if (_accessMap.TryGetValue(entity.Key, out var hasAccess) == false)
{
// entity is not a part of the calculated access map
return null;
}
// direct access => return a regular item
// no direct access => return a "no access" item
return hasAccess
? MapTreeItemViewModel(parentKey, entity)
: MapTreeItemViewModelAsNoAccess(parentKey, entity);
})
.WhereNotNull()
.ToArray();
return contentTreeItemViewModels;
}
private int[] UserStartNodeIds => _userStartNodeIds ??= GetUserStartNodeIds();
private string[] UserStartNodePaths => _userStartNodePaths ??= GetUserStartNodePaths();
private bool UserHasRootAccess() => UserStartNodeIds.Contains(Constants.System.Root);
private bool IgnoreUserStartNodes()
=> _dataTypeKey.HasValue
&& _dataTypeService.IsDataTypeIgnoringUserStartNodes(_dataTypeKey.Value);
private IEntitySlim[] CalculateAccessMap(Func<IEnumerable<UserAccessEntity>> getUserAccessEntities, out long totalItems)
{
UserAccessEntity[] userAccessEntities = getUserAccessEntities().ToArray();
_accessMap = userAccessEntities.ToDictionary(uae => uae.Entity.Key, uae => uae.HasAccess);
IEntitySlim[] entities = userAccessEntities.Select(uae => uae.Entity).ToArray();
totalItems = entities.Length;
return entities;
return userAccessEntities.Select(uae => uae.Entity).ToArray();
}
private TItem MapTreeItemViewModelAsNoAccess(Guid? parentKey, IEntitySlim entity)
@@ -158,4 +125,45 @@ public abstract class UserStartNodeTreeControllerBase<TItem> : EntityTreeControl
viewModel.NoAccess = true;
return viewModel;
}
private bool ShouldBypassStartNodeFiltering()
=> _treeFilterService.ShouldBypassStartNodeFiltering(_dataTypeKey);
/// <summary>
/// A backward-compatible adapter that implements <see cref="UserStartNodeTreeFilterService"/>
/// by delegating start node resolution to callback functions.
/// </summary>
/// <remarks>
/// Used by the obsolete constructor to bridge the old abstract-method-based
/// start node resolution to the new service-based approach.
/// </remarks>
[Obsolete("Only used by the obsolete constructor. Scheduled for removal in Umbraco 19.")]
private sealed class CallbackStartNodeTreeFilterService : UserStartNodeTreeFilterService
{
private readonly Func<int[]> _getStartNodeIds;
private readonly Func<string[]> _getStartNodePaths;
private readonly Func<UmbracoObjectTypes> _getTreeObjectType;
public CallbackStartNodeTreeFilterService(
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
Func<int[]> getStartNodeIds,
Func<string[]> getStartNodePaths,
Func<UmbracoObjectTypes> getTreeObjectType)
: base(userStartNodeEntitiesService, dataTypeService)
{
_getStartNodeIds = getStartNodeIds;
_getStartNodePaths = getStartNodePaths;
_getTreeObjectType = getTreeObjectType;
}
/// <inheritdoc />
protected override UmbracoObjectTypes TreeObjectType => _getTreeObjectType();
/// <inheritdoc />
protected override int[] CalculateUserStartNodeIds() => _getStartNodeIds();
/// <inheritdoc />
protected override string[] CalculateUserStartNodePaths() => _getStartNodePaths();
}
}
@@ -59,7 +59,7 @@ public class GetCurrentUserController : CurrentUserControllerBase
[EndpointDescription("Gets the currently authenticated back office user's information and permissions.")]
public async Task<IActionResult> GetCurrentUser(CancellationToken cancellationToken)
{
var currentUserKey = CurrentUserKey(_backOfficeSecurityAccessor);
Guid currentUserKey = CurrentUserKey(_backOfficeSecurityAccessor);
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
@@ -78,7 +78,7 @@ public class GetCurrentUserController : CurrentUserControllerBase
return Unauthorized();
}
var responseModel = await _userPresentationFactory.CreateCurrentUserResponseModelAsync(user);
CurrentUserResponseModel responseModel = await _userPresentationFactory.CreateCurrentUserResponseModelAsync(user);
return Ok(responseModel);
}
}
@@ -1,10 +1,12 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels.User.Current;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -18,23 +20,46 @@ namespace Umbraco.Cms.Api.Management.Controllers.User.Current;
public class GetDocumentPermissionsCurrentUserController : CurrentUserControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IUserService _userService;
private readonly IUmbracoMapper _mapper;
private readonly IContentPermissionService _contentPermissionService;
// TODO (V19): Remove the IUserService parameter from the constructor as it is not used in the current implementation.
/// <summary>
/// Initializes a new instance of the <see cref="GetDocumentPermissionsCurrentUserController"/> class, which handles requests related to retrieving document permissions for the current user.
/// </summary>
/// <param name="backOfficeSecurityAccessor">Provides access to back office security information for the current user.</param>
/// <param name="mapper">The Umbraco object mapper used for mapping between models.</param>
/// <param name="contentPermissionService">Service for managing content permissions.</param>
[ActivatorUtilitiesConstructor]
public GetDocumentPermissionsCurrentUserController(
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IUserService userService,
IUmbracoMapper mapper,
IContentPermissionService contentPermissionService)
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_mapper = mapper;
_contentPermissionService = contentPermissionService;
}
/// <summary>
/// Initializes a new instance of the <see cref="GetDocumentPermissionsCurrentUserController"/> class.
/// </summary>
/// <param name="backOfficeSecurityAccessor">Provides access to back office security information for the current user.</param>
/// <param name="userService">Service for managing and retrieving user information.</param>
/// <param name="mapper">The Umbraco object mapper used for mapping between models.</param>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public GetDocumentPermissionsCurrentUserController(
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IUserService userService,
IUmbracoMapper mapper)
: this(
backOfficeSecurityAccessor,
userService,
mapper,
StaticServiceProvider.Instance.GetRequiredService<IContentPermissionService>())
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_userService = userService;
_mapper = mapper;
}
/// <summary>
@@ -42,10 +67,10 @@ public class GetDocumentPermissionsCurrentUserController : CurrentUserController
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="ids">A set of document IDs for which to retrieve permissions.</param>
/// <returns>An <see cref="IActionResult"/> containing a <see cref="UserPermissionsResponseModel"/> with the permissions for each requested document, or a <see cref="ProblemDetails"/> if not found.</returns>
/// <returns>An <see cref="IActionResult"/> containing a <see cref="UserPermissionsResponseModel"/> with the permissions for each requested document.</returns>
[MapToApiVersion("1.0")]
[HttpGet("permissions/document")]
[ProducesResponseType(typeof(IEnumerable<UserPermissionsResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(UserPermissionsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Gets document permissions for the current user.")]
[EndpointDescription("Gets the document permissions for the currently authenticated user.")]
@@ -53,14 +78,16 @@ public class GetDocumentPermissionsCurrentUserController : CurrentUserController
CancellationToken cancellationToken,
[FromQuery(Name = "id")] HashSet<Guid> ids)
{
Attempt<IEnumerable<NodePermissions>, UserOperationStatus> permissionsAttempt = await _userService.GetDocumentPermissionsAsync(CurrentUserKey(_backOfficeSecurityAccessor), ids);
IUser currentUser = CurrentUser(_backOfficeSecurityAccessor);
NodePermissions[] permissions = (await _contentPermissionService.GetPermissionsAsync(currentUser, ids)).ToArray();
if (permissionsAttempt.Success is false)
// Preserve 404 behavior: if any requested ID was not found, return ContentNodeNotFound.
if (ids.Count > 0 && permissions.Length < ids.Count)
{
return UserOperationStatusResult(permissionsAttempt.Status);
return UserOperationStatusResult(UserOperationStatus.ContentNodeNotFound);
}
List<UserPermissionViewModel> viewModels = _mapper.MapEnumerable<NodePermissions, UserPermissionViewModel>(permissionsAttempt.Result);
List<UserPermissionViewModel> viewModels = _mapper.MapEnumerable<NodePermissions, UserPermissionViewModel>(permissions);
return Ok(new UserPermissionsResponseModel { Permissions = viewModels });
}
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Mapping.Member;
using Umbraco.Cms.Api.Management.Services;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
@@ -12,6 +13,8 @@ internal static class MemberBuilderExtensions
{
builder.Services.AddSingleton<IMemberPresentationFactory, MemberPresentationFactory>();
builder.Services.AddTransient<IMemberEditingPresentationFactory, MemberEditingPresentationFactory>();
builder.Services.AddTransient<IMemberPresentationService, MemberPresentationService>();
builder.Services.AddTransient<IMemberReferenceService, MemberReferenceService>();
builder.WithCollectionBuilder<MapDefinitionCollectionBuilder>().Add<MemberMapDefinition>();
@@ -11,6 +11,8 @@ internal static class TreeBuilderExtensions
internal static IUmbracoBuilder AddTrees(this IUmbracoBuilder builder)
{
builder.Services.AddTransient<IUserStartNodeEntitiesService, UserStartNodeEntitiesService>();
builder.Services.AddTransient<IDocumentStartNodeTreeFilterService, DocumentStartNodeTreeFilterService>();
builder.Services.AddTransient<IMediaStartNodeTreeFilterService, MediaStartNodeTreeFilterService>();
builder.Services.AddUnique<IPartialViewTreeService, PartialViewTreeService>();
builder.Services.AddUnique<IScriptTreeService, ScriptTreeService>();
@@ -54,7 +54,8 @@ public static partial class UmbracoBuilderExtensions
factory.GetRequiredService<IUserRepository>(),
factory.GetRequiredService<IRuntimeState>(),
factory.GetRequiredService<IEventMessagesFactory>(),
factory.GetRequiredService<ILogger<BackOfficeUserStore>>()))
factory.GetRequiredService<ILogger<BackOfficeUserStore>>(),
factory.GetRequiredService<IBackOfficeUserReader>()))
.AddUserManager<IBackOfficeUserManager, BackOfficeUserManager>()
.AddSignInManager<IBackOfficeSignInManager, BackOfficeSignInManager>()
.AddClaimsPrincipalFactory<BackOfficeClaimsPrincipalFactory>()
@@ -102,7 +102,7 @@ public class DocumentUrlFactory : IDocumentUrlFactory
if (await _previewService.TryEnterPreviewAsync(currentUser) is false)
{
_logger.LogError("A server error occured, could not initiate an authenticated preview state for the current user.");
_logger.LogError("A server error occurred, could not initiate an authenticated preview state for the current user.");
return null;
}
}
@@ -3,6 +3,7 @@ using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Api.Management.Factories;
@@ -40,4 +41,31 @@ public interface IMemberPresentationFactory
/// <param name="entity">The member entity to create the response model from.</param>
/// <returns>A MemberItemResponseModel representing the member entity.</returns>
MemberItemResponseModel CreateItemResponseModel(IMember entity);
/// <summary>
/// Creates a response model for an external-only member.
/// </summary>
/// <param name="member">The external member identity to create the response model from.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="MemberResponseModel"/>.</returns>
// TODO (V19): Remove the default implementation.
Task<MemberResponseModel> CreateExternalMemberResponseModelAsync(ExternalMemberIdentity member)
=> Task.FromResult(new MemberResponseModel { Id = member.Key, Kind = MemberKind.ExternalOnly });
/// <summary>
/// Creates an item response model for an external-only member.
/// </summary>
/// <param name="member">The external member identity to create the item response model from.</param>
/// <returns>A <see cref="MemberItemResponseModel"/> representing the external member.</returns>
// TODO (V19): Remove the default implementation.
MemberItemResponseModel CreateExternalMemberItemResponseModel(ExternalMemberIdentity member)
=> new() { Id = member.Key, Kind = MemberKind.ExternalOnly };
/// <summary>
/// Creates a response model from a <see cref="MemberFilterItem"/> returned by the combined filter query.
/// </summary>
/// <param name="item">The filter item to create the response model from.</param>
/// <returns>A <see cref="MemberResponseModel"/> representing the filter item.</returns>
// TODO (V19): Remove the default implementation.
MemberResponseModel CreateFilterItemResponseModel(MemberFilterItem item)
=> new() { Id = item.Key, Kind = item.Kind };
}
@@ -124,7 +124,7 @@ public class IndexPresentationFactory : IIndexPresentationFactory
}
catch (Exception e)
{
_logger.LogError(e, "An error occured trying to get the searcher name of index {IndexName}", index.Name);
_logger.LogError(e, "An error occurred trying to get the searcher name of index {IndexName}", index.Name);
name = "Could not determine searcher name because of error.";
return false;
}
@@ -139,7 +139,7 @@ public class IndexPresentationFactory : IIndexPresentationFactory
}
catch (Exception e)
{
_logger.LogError(e, "An error occured trying to get the document count of index {IndexName}", index.Name);
_logger.LogError(e, "An error occurred trying to get the document count of index {IndexName}", index.Name);
documentCount = 0;
return false;
}
@@ -154,7 +154,7 @@ public class IndexPresentationFactory : IIndexPresentationFactory
}
catch (Exception e)
{
_logger.LogError(e, "An error occured trying to get the field name count of index {IndexName}", index.Name);
_logger.LogError(e, "An error occurred trying to get the field name count of index {IndexName}", index.Name);
fieldNameCount = 0;
return false;
}
@@ -9,11 +9,13 @@ using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Factories;
/// <inheritdoc/>
internal sealed class MemberPresentationFactory : IMemberPresentationFactory
{
private readonly IUmbracoMapper _umbracoMapper;
@@ -22,6 +24,7 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
private readonly ITwoFactorLoginService _twoFactorLoginService;
private readonly IMemberGroupService _memberGroupService;
private readonly DeliveryApiSettings _deliveryApiSettings;
private readonly IExternalMemberService _externalMemberService;
private IEnumerable<Guid>? _clientCredentialsMemberKeys;
/// <summary>
@@ -33,13 +36,15 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
/// <param name="twoFactorLoginService">Service for handling two-factor authentication for members.</param>
/// <param name="memberGroupService">Service for managing member groups.</param>
/// <param name="deliveryApiSettings">The configuration options for the Delivery API.</param>
/// <param name="externalMemberService">Service for managing external-only members.</param>
public MemberPresentationFactory(
IUmbracoMapper umbracoMapper,
IMemberService memberService,
IMemberTypeService memberTypeService,
ITwoFactorLoginService twoFactorLoginService,
IMemberGroupService memberGroupService,
IOptions<DeliveryApiSettings> deliveryApiSettings)
IOptions<DeliveryApiSettings> deliveryApiSettings,
IExternalMemberService externalMemberService)
{
_umbracoMapper = umbracoMapper;
_memberService = memberService;
@@ -47,14 +52,10 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
_twoFactorLoginService = twoFactorLoginService;
_memberGroupService = memberGroupService;
_deliveryApiSettings = deliveryApiSettings.Value;
_externalMemberService = externalMemberService;
}
/// <summary>
/// Asynchronously creates a <see cref="MemberResponseModel"/> for the specified <see cref="IMember"/>, including or excluding sensitive data based on the current user's permissions.
/// </summary>
/// <param name="member">The member entity to map to a response model.</param>
/// <param name="currentUser">The user requesting the data, used to determine access to sensitive information.</param>
/// <returns>A task representing the asynchronous operation, with a <see cref="MemberResponseModel"/> as the result.</returns>
/// <inheritdoc/>
public async Task<MemberResponseModel> CreateResponseModelAsync(IMember member, IUser currentUser)
{
MemberResponseModel responseModel = _umbracoMapper.Map<MemberResponseModel>(member)!;
@@ -70,6 +71,7 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
: await RemoveSensitiveDataAsync(member, responseModel);
}
/// <inheritdoc/>
public async Task<IEnumerable<MemberResponseModel>> CreateMultipleAsync(IEnumerable<IMember> members, IUser currentUser)
{
var memberResponseModels = new List<MemberResponseModel>();
@@ -81,41 +83,101 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
return memberResponseModels;
}
/// <summary>
/// Creates a response model for a member item from the given entity.
/// </summary>
/// <param name="entity">The member entity to create the response model from.</param>
/// <returns>A <see cref="MemberItemResponseModel"/> representing the member.</returns>
/// <inheritdoc/>
public MemberItemResponseModel CreateItemResponseModel(IMemberEntitySlim entity)
=> CreateItemResponseModel<IMemberEntitySlim>(entity);
/// <summary>
/// Creates a response model for a member item based on the given member entity.
/// </summary>
/// <param name="entity">The member entity to create the response model from.</param>
/// <returns>A <see cref="MemberItemResponseModel"/> representing the member.</returns>
/// <inheritdoc/>
public MemberItemResponseModel CreateItemResponseModel(IMember entity)
=> CreateItemResponseModel<IMember>(entity);
/// <inheritdoc/>
public async Task<MemberResponseModel> CreateExternalMemberResponseModelAsync(ExternalMemberIdentity member)
{
IEnumerable<string> roles = await _externalMemberService.GetRolesAsync(member.Key);
IEnumerable<Guid> groupKeys = roles
.Select(x => _memberGroupService.GetByName(x))
.WhereNotNull()
.Select(x => x.Key)
.ToArray();
return new MemberResponseModel
{
Id = member.Key,
Email = member.Email,
Username = member.UserName,
IsApproved = member.IsApproved,
IsLockedOut = member.IsLockedOut,
IsTwoFactorEnabled = false,
FailedPasswordAttempts = 0,
LastLoginDate = member.LastLoginDate.HasValue ? new DateTimeOffset(member.LastLoginDate.Value, TimeSpan.Zero) : null,
LastLockoutDate = member.LastLockoutDate.HasValue ? new DateTimeOffset(member.LastLockoutDate.Value, TimeSpan.Zero) : null,
LastPasswordChangeDate = null,
Kind = MemberKind.ExternalOnly,
Variants = [new MemberVariantResponseModel
{
Name = member.Name ?? string.Empty,
CreateDate = new DateTimeOffset(member.CreateDate, TimeSpan.Zero),
UpdateDate = new DateTimeOffset(member.UpdateDate, TimeSpan.Zero),
}],
Values = Enumerable.Empty<MemberValueResponseModel>(),
MemberType = new MemberTypeReferenceResponseModel(),
Groups = groupKeys,
ProfileData = member.ProfileData,
};
}
/// <inheritdoc/>
public MemberItemResponseModel CreateExternalMemberItemResponseModel(ExternalMemberIdentity member) =>
new()
{
Id = member.Key,
MemberType = new MemberTypeReferenceResponseModel(),
Variants = [new VariantItemResponseModel { Name = member.Name ?? string.Empty, Culture = null }],
Kind = MemberKind.ExternalOnly,
};
/// <inheritdoc/>
public MemberResponseModel CreateFilterItemResponseModel(MemberFilterItem item) =>
new()
{
Id = item.Key,
Email = item.Email,
Username = item.UserName,
IsApproved = item.IsApproved,
IsLockedOut = item.IsLockedOut,
LastLoginDate = item.LastLoginDate.HasValue ? new DateTimeOffset(item.LastLoginDate.Value, TimeSpan.Zero) : null,
LastLockoutDate = item.LastLockoutDate.HasValue ? new DateTimeOffset(item.LastLockoutDate.Value, TimeSpan.Zero) : null,
LastPasswordChangeDate = item.LastPasswordChangeDate.HasValue ? new DateTimeOffset(item.LastPasswordChangeDate.Value, TimeSpan.Zero) : null,
Kind = item.Kind,
Variants = [new MemberVariantResponseModel { Name = item.Name ?? string.Empty }],
Values = [],
MemberType = new MemberTypeReferenceResponseModel
{
Id = item.MemberTypeKey ?? Guid.Empty,
Icon = item.MemberTypeIcon ?? string.Empty,
},
};
private MemberItemResponseModel CreateItemResponseModel<T>(T entity)
where T : ITreeEntity
=> new MemberItemResponseModel
=> new()
{
Id = entity.Key,
MemberType = _umbracoMapper.Map<MemberTypeReferenceResponseModel>(entity)!,
Variants = CreateVariantsItemResponseModels(entity),
Kind = GetMemberKind(entity.Key)
Kind = GetMemberKind(entity.Key),
};
private static IEnumerable<VariantItemResponseModel> CreateVariantsItemResponseModels(ITreeEntity entity)
=> new[]
{
=>
[
new VariantItemResponseModel
{
Name = entity.Name ?? string.Empty,
Culture = null
Culture = null,
}
};
];
private async Task<MemberResponseModel> RemoveSensitiveDataAsync(IMember member, MemberResponseModel responseModel)
{
@@ -39,6 +39,7 @@ public class UserPresentationFactory : IUserPresentationFactory
private readonly IBackOfficeExternalLoginProviders _externalLoginProviders;
private readonly SecuritySettings _securitySettings;
private readonly Dictionary<Type, IPermissionPresentationMapper> _permissionPresentationMappersByType;
private readonly IContentPermissionService _contentPermissionService;
/// <summary>
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
@@ -54,6 +55,7 @@ public class UserPresentationFactory : IUserPresentationFactory
/// <param name="securitySettings">Provides access to security-related configuration settings.</param>
/// <param name="externalLoginProviders">Manages back office external login providers.</param>
/// <param name="permissionPresentationMappers">Collection of mappers for permission presentation models.</param>
/// <param name="contentPermissionService">Service for managing content permissions.</param>
public UserPresentationFactory(
IEntityService entityService,
AppCaches appCaches,
@@ -65,7 +67,8 @@ public class UserPresentationFactory : IUserPresentationFactory
IPasswordConfigurationPresentationFactory passwordConfigurationPresentationFactory,
IOptionsSnapshot<SecuritySettings> securitySettings,
IBackOfficeExternalLoginProviders externalLoginProviders,
IEnumerable<IPermissionPresentationMapper> permissionPresentationMappers)
IEnumerable<IPermissionPresentationMapper> permissionPresentationMappers,
IContentPermissionService contentPermissionService)
{
_entityService = entityService;
_appCaches = appCaches;
@@ -78,6 +81,50 @@ public class UserPresentationFactory : IUserPresentationFactory
_securitySettings = securitySettings.Value;
_absoluteUrlBuilder = absoluteUrlBuilder;
_permissionPresentationMappersByType = permissionPresentationMappers.ToDictionary(x => x.PresentationModelToHandle);
_contentPermissionService = contentPermissionService;
}
/// <summary>
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
/// </summary>
/// <param name="entityService">Service for accessing and managing entities.</param>
/// <param name="appCaches">Provides application-level caching functionality.</param>
/// <param name="mediaFileManager">Manages media file storage and retrieval.</param>
/// <param name="imageUrlGenerator">Generates URLs for images.</param>
/// <param name="userGroupPresentationFactory">Factory for creating user group presentation models.</param>
/// <param name="absoluteUrlBuilder">Builds absolute URLs for resources.</param>
/// <param name="emailSender">Handles sending emails.</param>
/// <param name="passwordConfigurationPresentationFactory">Factory for password configuration presentation models.</param>
/// <param name="securitySettings">Provides access to security-related configuration settings.</param>
/// <param name="externalLoginProviders">Manages back office external login providers.</param>
/// <param name="permissionPresentationMappers">Collection of mappers for permission presentation models.</param>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public UserPresentationFactory(
IEntityService entityService,
AppCaches appCaches,
MediaFileManager mediaFileManager,
IImageUrlGenerator imageUrlGenerator,
IUserGroupPresentationFactory userGroupPresentationFactory,
IAbsoluteUrlBuilder absoluteUrlBuilder,
IEmailSender emailSender,
IPasswordConfigurationPresentationFactory passwordConfigurationPresentationFactory,
IOptionsSnapshot<SecuritySettings> securitySettings,
IBackOfficeExternalLoginProviders externalLoginProviders,
IEnumerable<IPermissionPresentationMapper> permissionPresentationMappers)
: this(
entityService,
appCaches,
mediaFileManager,
imageUrlGenerator,
userGroupPresentationFactory,
absoluteUrlBuilder,
emailSender,
passwordConfigurationPresentationFactory,
securitySettings,
externalLoginProviders,
permissionPresentationMappers,
StaticServiceProvider.Instance.GetRequiredService<IContentPermissionService>())
{
}
/// <inheritdoc/>
@@ -227,7 +274,9 @@ public class UserPresentationFactory : IUserPresentationFactory
ISet<ReferenceByIdModel> documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
HashSet<IPermissionPresentationModel> permissions = GetAggregatedGranularPermissions(user, presentationGroups);
var fallbackPermissions = presentationGroups.SelectMany(x => x.FallbackPermissions).ToHashSet();
ISet<string> fallbackPermissions = await _contentPermissionService.FilterFallbackPermissionsAsync(
user,
presentationGroups.SelectMany(x => x.FallbackPermissions).ToHashSet());
var hasAccessToAllLanguages = presentationGroups.Any(x => x.HasAccessToAllLanguages);
@@ -48,7 +48,7 @@ public class MemberMapDefinition : ContentMapDefinition<IMember, MemberValueResp
public void DefineMaps(IUmbracoMapper mapper)
=> mapper.Define<IMember, MemberResponseModel>((_, _) => new MemberResponseModel(), Map);
// Umbraco.Code.MapAll -IsTwoFactorEnabled -Groups -Kind -Flags
// Umbraco.Code.MapAll -IsTwoFactorEnabled -Groups -Kind -Flags -ProfileData
private void Map(IMember source, MemberResponseModel target, MapperContext context)
{
target.Id = source.Key;
@@ -19,16 +19,30 @@ namespace Umbraco.Cms.Api.Management.Mapping.Permissions;
/// </remarks>
public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissionMapper
{
private readonly Lazy<IEntityService> _entityService;
private readonly Lazy<IUserService> _userService;
private readonly Lazy<IContentPermissionService> _contentPermissionService;
/// <summary>
/// Initializes a new instance of the <see cref="DocumentPermissionMapper"/> class.
/// </summary>
/// <param name="entityService">The entity service.</param>
/// <param name="userService">The user service.</param>
/// <param name="contentPermissionService">The content permission service.</param>
// TODO (V19): Remove the entityService and userService parameters as they are not used in the current implementation.
public DocumentPermissionMapper(
Lazy<IEntityService> entityService,
Lazy<IUserService> userService,
Lazy<IContentPermissionService> contentPermissionService) => _contentPermissionService = contentPermissionService;
/// <summary>
/// Initializes a new instance of the <see cref="DocumentPermissionMapper"/> class.
/// </summary>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public DocumentPermissionMapper(Lazy<IEntityService> entityService, Lazy<IUserService> userService)
: this(
entityService,
userService,
new Lazy<IContentPermissionService>(StaticServiceProvider.Instance.GetRequiredService<IContentPermissionService>))
{
_entityService = entityService;
_userService = userService;
}
/// <inheritdoc/>
@@ -110,25 +124,18 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
.Distinct()
.ToArray();
// Batch retrieve all documents by their keys.
var documents = _entityService.Value.GetAll<IContent>(documentKeysWithGranularPermissions)
.ToDictionary(doc => doc.Key, doc => doc.Path);
// Resolve permissions through IContentPermissionService so custom implementations are respected.
IEnumerable<NodePermissions> permissions = _contentPermissionService.Value
.GetPermissionsAsync(user, documentKeysWithGranularPermissions)
.GetAwaiter()
.GetResult();
// Iterate through each document key that has granular permissions.
foreach (Guid documentKey in documentKeysWithGranularPermissions)
foreach (NodePermissions nodePermission in permissions)
{
// Retrieve the path from the pre-fetched documents.
if (!documents.TryGetValue(documentKey, out var path) || string.IsNullOrEmpty(path))
{
continue;
}
// With the path we can call the same logic as used server-side for authorizing access to resources.
EntityPermissionSet permissionsForPath = _userService.Value.GetPermissionsForPath(user, path);
yield return new DocumentPermissionPresentationModel
{
Document = new ReferenceByIdModel(documentKey),
Verbs = permissionsForPath.GetAllPermissions(),
Document = new ReferenceByIdModel(nodePermission.NodeKey),
Verbs = nodePermission.Permissions,
};
}
}
+224 -10
View File
@@ -9632,6 +9632,157 @@
]
}
},
"/umbraco/management/api/v1/document/{id}/patch": {
"patch": {
"tags": [
"Document"
],
"summary": "Make partial updates to a document. For more information, see the documentation at https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-guide or https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-spec",
"operationId": "PatchDocumentByIdPatch",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/PatchDocumentRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"404": {
"description": "Not Found",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"422": {
"description": "Unprocessable Content",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/document/{id}/preview-url": {
"get": {
"tags": [
@@ -36708,6 +36859,9 @@
"oneOf": [
{
"$ref": "#/components/schemas/NoopSetupTwoFactorModel"
},
{
"$ref": "#/components/schemas/TwoFactorAuthInfo"
}
]
}
@@ -36802,6 +36956,9 @@
"oneOf": [
{
"$ref": "#/components/schemas/NoopSetupTwoFactorModel"
},
{
"$ref": "#/components/schemas/TwoFactorAuthInfo"
}
]
}
@@ -37195,14 +37352,11 @@
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/UserPermissionsResponseModel"
}
]
}
"oneOf": [
{
"$ref": "#/components/schemas/UserPermissionsResponseModel"
}
]
}
}
}
@@ -45917,7 +46071,8 @@
"MemberKindModel": {
"enum": [
"Default",
"Api"
"Api",
"ExternalOnly"
],
"type": "string"
},
@@ -46058,6 +46213,10 @@
},
"kind": {
"$ref": "#/components/schemas/MemberKindModel"
},
"profileData": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
@@ -48761,6 +48920,47 @@
},
"additionalProperties": false
},
"PatchDocumentRequestModel": {
"required": [
"operations"
],
"type": "object",
"properties": {
"operations": {
"minItems": 1,
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/PatchOperationRequestModel"
}
]
}
}
},
"additionalProperties": false
},
"PatchOperationRequestModel": {
"required": [
"op",
"path"
],
"type": "object",
"properties": {
"op": {
"minLength": 1,
"type": "string"
},
"path": {
"minLength": 1,
"type": "string"
},
"value": {
"nullable": true
}
},
"additionalProperties": false
},
"ProblemDetails": {
"type": "object",
"properties": {
@@ -50695,6 +50895,20 @@
],
"type": "string"
},
"TwoFactorAuthInfo": {
"type": "object",
"properties": {
"qrCodeSetupImageUrl": {
"type": "string",
"nullable": true
},
"secret": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"UnknownTypePermissionPresentationModel": {
"required": [
"$type",
@@ -53245,4 +53459,4 @@
"name": "Webhook"
}
]
}
}
@@ -0,0 +1,49 @@
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Services.Entities;
/// <summary>
/// User start node tree filter service for document (content) trees.
/// Resolves the current user's content start nodes.
/// </summary>
internal sealed class DocumentStartNodeTreeFilterService : UserStartNodeTreeFilterService, IDocumentStartNodeTreeFilterService
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IEntityService _entityService;
private readonly AppCaches _appCaches;
public DocumentStartNodeTreeFilterService(
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IEntityService entityService,
AppCaches appCaches)
: base(userStartNodeEntitiesService, dataTypeService)
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_entityService = entityService;
_appCaches = appCaches;
}
/// <inheritdoc />
protected override UmbracoObjectTypes TreeObjectType => UmbracoObjectTypes.Document;
/// <inheritdoc />
protected override int[] CalculateUserStartNodeIds()
=> _backOfficeSecurityAccessor
.BackOfficeSecurity?
.CurrentUser?
.CalculateContentStartNodeIds(_entityService, _appCaches)
?? [];
/// <inheritdoc />
protected override string[] CalculateUserStartNodePaths()
=> _backOfficeSecurityAccessor
.BackOfficeSecurity?
.CurrentUser?
.GetContentStartNodePaths(_entityService, _appCaches)
?? [];
}
@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.Services.Entities;
/// <summary>
/// User start node tree filter service for document (content) trees.
/// </summary>
public interface IDocumentStartNodeTreeFilterService : IUserStartNodeTreeFilterService;
@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.Services.Entities;
/// <summary>
/// User start node tree filter service for media trees.
/// </summary>
public interface IMediaStartNodeTreeFilterService : IUserStartNodeTreeFilterService;
@@ -0,0 +1,85 @@
using Umbraco.Cms.Api.Management.Models.Entities;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Services.Entities;
/// <summary>
/// Provides user start node filtering for tree controllers.
/// </summary>
/// <remarks>
/// Implementations resolve the current user's start node configuration and apply
/// access filtering to tree queries, ensuring users only see entities within their
/// permitted start nodes.
/// </remarks>
public interface IUserStartNodeTreeFilterService
{
/// <summary>
/// Determines whether start node filtering should be bypassed for the current user.
/// </summary>
/// <param name="dataTypeKey">An optional data type key; if the data type is configured to ignore user start nodes,
/// filtering is bypassed.</param>
/// <returns><c>true</c> if the user has root access or the data type ignores start nodes; otherwise, <c>false</c>.
/// </returns>
bool ShouldBypassStartNodeFiltering(Guid? dataTypeKey = null);
/// <summary>
/// Gets the root entities filtered by user start node access.
/// </summary>
/// <param name="totalItems">The total number of items returned.</param>
/// <returns>An array of user access entities at the root level, each indicating whether the user has direct access
/// or if the entity is an ancestor navigation item.</returns>
UserAccessEntity[] GetFilteredRootEntities(out long totalItems);
/// <summary>
/// Gets the child entities of a parent filtered by user start node access.
/// </summary>
/// <param name="parentKey">The key of the parent entity.</param>
/// <param name="skip">The number of items to skip.</param>
/// <param name="take">The number of items to take.</param>
/// <param name="ordering">The ordering to apply.</param>
/// <param name="totalItems">The total number of items available.</param>
/// <returns>An array of child user access entities filtered by user start node access.</returns>
UserAccessEntity[] GetFilteredChildEntities(
Guid parentKey,
int skip,
int take,
Ordering ordering,
out long totalItems);
/// <summary>
/// Gets the sibling entities of a target filtered by user start node access.
/// </summary>
/// <param name="target">The key of the target entity.</param>
/// <param name="before">The number of siblings to retrieve before the target.</param>
/// <param name="after">The number of siblings to retrieve after the target.</param>
/// <param name="ordering">The ordering to apply.</param>
/// <param name="totalBefore">The total number of siblings before the target.</param>
/// <param name="totalAfter">The total number of siblings after the target.</param>
/// <returns>An array of sibling user access entities filtered by user start node access.</returns>
UserAccessEntity[] GetFilteredSiblingEntities(
Guid target,
int before,
int after,
Ordering ordering,
out long totalBefore,
out long totalAfter);
/// <summary>
/// Maps entities to tree item view models, applying access filtering using the provided access map.
/// </summary>
/// <typeparam name="TItem">The type of tree item view model.</typeparam>
/// <param name="entities">The entities to map.</param>
/// <param name="accessMap">A dictionary mapping entity keys to their access status, as obtained from a prior call
/// to one of the <c>GetFiltered*Entities</c> methods.</param>
/// <param name="mapEntity">A function to map an entity the user has access to.</param>
/// <param name="mapEntityAsNoAccess">A function to map an entity the user does not have direct access to (ancestor
/// navigation items).</param>
/// <returns>An array of mapped tree item view models, excluding entities not in the access map.</returns>
TItem[] MapWithAccessFiltering<TItem>(
IEntitySlim[] entities,
Dictionary<Guid, bool> accessMap,
Func<IEntitySlim, TItem> mapEntity,
Func<IEntitySlim, TItem> mapEntityAsNoAccess)
where TItem : class;
}
@@ -0,0 +1,49 @@
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Services.Entities;
/// <summary>
/// User start node tree filter service for media trees.
/// Resolves the current user's media start nodes.
/// </summary>
internal sealed class MediaStartNodeTreeFilterService : UserStartNodeTreeFilterService, IMediaStartNodeTreeFilterService
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IEntityService _entityService;
private readonly AppCaches _appCaches;
public MediaStartNodeTreeFilterService(
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IEntityService entityService,
AppCaches appCaches)
: base(userStartNodeEntitiesService, dataTypeService)
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_entityService = entityService;
_appCaches = appCaches;
}
/// <inheritdoc />
protected override UmbracoObjectTypes TreeObjectType => UmbracoObjectTypes.Media;
/// <inheritdoc />
protected override int[] CalculateUserStartNodeIds()
=> _backOfficeSecurityAccessor
.BackOfficeSecurity?
.CurrentUser?
.CalculateMediaStartNodeIds(_entityService, _appCaches)
?? [];
/// <inheritdoc />
protected override string[] CalculateUserStartNodePaths()
=> _backOfficeSecurityAccessor
.BackOfficeSecurity?
.CurrentUser?
.GetMediaStartNodePaths(_entityService, _appCaches)
?? [];
}
@@ -0,0 +1,140 @@
using Umbraco.Cms.Api.Management.Models.Entities;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Services.Entities;
/// <summary>
/// Abstract base class for user start node tree filter services.
/// </summary>
/// <remarks>
/// Contains the shared filtering logic for tree controllers that support user start node access.
/// Concrete implementations provide the start node resolution for their specific domain
/// (documents or media).
/// </remarks>
internal abstract class UserStartNodeTreeFilterService : IUserStartNodeTreeFilterService
{
private readonly IUserStartNodeEntitiesService _userStartNodeEntitiesService;
private readonly IDataTypeService _dataTypeService;
/// <summary>
/// Initializes a new instance of the <see cref="UserStartNodeTreeFilterService"/> class.
/// </summary>
/// <param name="userStartNodeEntitiesService">The service for retrieving user access entities.</param>
/// <param name="dataTypeService">The data type service.</param>
protected UserStartNodeTreeFilterService(
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService)
{
_userStartNodeEntitiesService = userStartNodeEntitiesService;
_dataTypeService = dataTypeService;
}
/// <summary>
/// Gets the object type to include in tree queries.
/// </summary>
protected abstract UmbracoObjectTypes TreeObjectType { get; }
private int[] UserStartNodeIds => field ??= CalculateUserStartNodeIds();
private string[] UserStartNodePaths => field ??= CalculateUserStartNodePaths();
/// <inheritdoc />
public bool ShouldBypassStartNodeFiltering(Guid? dataTypeKey = null)
=> UserHasRootAccess() || IgnoreUserStartNodes(dataTypeKey);
/// <inheritdoc />
public UserAccessEntity[] GetFilteredRootEntities(out long totalItems)
{
UserAccessEntity[] result = _userStartNodeEntitiesService
.RootUserAccessEntities(TreeObjectType, UserStartNodeIds)
.ToArray();
totalItems = result.Length;
return result;
}
/// <inheritdoc />
public UserAccessEntity[] GetFilteredChildEntities(
Guid parentKey,
int skip,
int take,
Ordering ordering,
out long totalItems)
{
UserAccessEntity[] result = _userStartNodeEntitiesService.ChildUserAccessEntities(
TreeObjectType,
UserStartNodePaths,
parentKey,
skip,
take,
ordering,
out totalItems)
.ToArray();
return result;
}
/// <inheritdoc />
public UserAccessEntity[] GetFilteredSiblingEntities(
Guid target,
int before,
int after,
Ordering ordering,
out long totalBefore,
out long totalAfter)
{
UserAccessEntity[] result = _userStartNodeEntitiesService.SiblingUserAccessEntities(
TreeObjectType,
UserStartNodePaths,
target,
before,
after,
ordering,
out totalBefore,
out totalAfter)
.ToArray();
return result;
}
/// <inheritdoc />
public TItem[] MapWithAccessFiltering<TItem>(
IEntitySlim[] entities,
Dictionary<Guid, bool> accessMap,
Func<IEntitySlim, TItem> mapEntity,
Func<IEntitySlim, TItem> mapEntityAsNoAccess)
where TItem : class =>
entities.Select(entity =>
{
if (accessMap.TryGetValue(entity.Key, out var hasAccess) is false)
{
return null;
}
return hasAccess ? mapEntity(entity) : mapEntityAsNoAccess(entity);
})
.WhereNotNull()
.ToArray();
/// <summary>
/// Calculates the start node IDs for the current user.
/// </summary>
/// <returns>An array of start node IDs.</returns>
protected abstract int[] CalculateUserStartNodeIds();
/// <summary>
/// Calculates the start node paths for the current user.
/// </summary>
/// <returns>An array of start node paths.</returns>
protected abstract string[] CalculateUserStartNodePaths();
private bool UserHasRootAccess() => UserStartNodeIds.Contains(Constants.System.Root);
private bool IgnoreUserStartNodes(Guid? dataTypeKey)
=> dataTypeKey.HasValue
&& _dataTypeService.IsDataTypeIgnoringUserStartNodes(dataTypeKey.Value);
}
@@ -0,0 +1,26 @@
using Umbraco.Cms.Api.Management.ViewModels.Member;
using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
using Umbraco.Cms.Core.Models.Membership;
namespace Umbraco.Cms.Api.Management.Services;
/// <summary>
/// Service for resolving members across both content and external stores and creating presentation models.
/// </summary>
public interface IMemberPresentationService
{
/// <summary>
/// Resolves a member by key from either the content or external store and creates a response model.
/// </summary>
/// <param name="id">The unique identifier of the member.</param>
/// <param name="currentUser">The current backoffice user performing the operation.</param>
/// <returns>A <see cref="MemberResponseModel"/> if found; otherwise <c>null</c>.</returns>
Task<MemberResponseModel?> CreateResponseModelByKeyAsync(Guid id, IUser currentUser);
/// <summary>
/// Resolves members by keys from both the content and external stores and creates item response models.
/// </summary>
/// <param name="ids">The unique identifiers of the members to resolve.</param>
/// <returns>A collection of <see cref="MemberItemResponseModel"/> for all resolved members.</returns>
Task<IEnumerable<MemberItemResponseModel>> CreateItemResponseModelsAsync(HashSet<Guid> ids);
}
@@ -0,0 +1,21 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Services;
/// <summary>
/// Service for retrieving paged references to a member, handling the external member fallback
/// when the entity-based lookup fails (external members have no <c>umbracoNode</c> entry).
/// </summary>
public interface IMemberReferenceService
{
/// <summary>
/// Gets a paged list of items that reference the specified member.
/// </summary>
/// <param name="id">The unique identifier of the member.</param>
/// <param name="skip">The number of items to skip.</param>
/// <param name="take">The maximum number of items to return.</param>
/// <returns>An <see cref="Attempt{TResult,TStatus}"/> containing the paged relation items or an operation status on failure.</returns>
Task<Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus>> GetPagedReferencesAsync(Guid id, int skip, int take);
}
@@ -0,0 +1,79 @@
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Member;
using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Services;
/// <summary>
/// Resolves members across both the content and external member stores and creates presentation models.
/// </summary>
internal sealed class MemberPresentationService : IMemberPresentationService
{
private readonly IEntityService _entityService;
private readonly IMemberEditingService _memberEditingService;
private readonly IMemberPresentationFactory _memberPresentationFactory;
/// <summary>
/// Initializes a new instance of the <see cref="MemberPresentationService"/> class.
/// </summary>
/// <param name="entityService">Service used for entity operations and retrieval.</param>
/// <param name="memberEditingService">Service used for member editing operations.</param>
/// <param name="memberPresentationFactory">Factory responsible for creating member presentation models.</param>
public MemberPresentationService(
IEntityService entityService,
IMemberEditingService memberEditingService,
IMemberPresentationFactory memberPresentationFactory)
{
_entityService = entityService;
_memberEditingService = memberEditingService;
_memberPresentationFactory = memberPresentationFactory;
}
/// <inheritdoc/>
public async Task<MemberResponseModel?> CreateResponseModelByKeyAsync(Guid id, IUser currentUser)
{
IMember? member = await _memberEditingService.GetAsync(id);
if (member is not null)
{
return await _memberPresentationFactory.CreateResponseModelAsync(member, currentUser);
}
ExternalMemberIdentity? externalMember = await _memberEditingService.GetExternalMemberAsync(id);
if (externalMember is not null)
{
return await _memberPresentationFactory.CreateExternalMemberResponseModelAsync(externalMember);
}
return null;
}
/// <inheritdoc/>
public async Task<IEnumerable<MemberItemResponseModel>> CreateItemResponseModelsAsync(HashSet<Guid> ids)
{
IMemberEntitySlim[] contentMembers = _entityService
.GetAll(UmbracoObjectTypes.Member, ids.ToArray())
.OfType<IMemberEntitySlim>()
.ToArray();
var responseModels = new List<MemberItemResponseModel>(
contentMembers.Select(_memberPresentationFactory.CreateItemResponseModel));
var resolvedIds = contentMembers.Select(m => m.Key).ToHashSet();
foreach (Guid unresolvedId in ids.Where(id => resolvedIds.Contains(id) is false))
{
ExternalMemberIdentity? externalMember = await _memberEditingService.GetExternalMemberAsync(unresolvedId);
if (externalMember is not null)
{
responseModels.Add(_memberPresentationFactory.CreateExternalMemberItemResponseModel(externalMember));
}
}
return responseModels;
}
}
@@ -0,0 +1,55 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Services;
/// <summary>
/// Retrieves paged references to a member, handling the external member fallback
/// when the entity-based lookup fails (external members have no <c>umbracoNode</c> entry).
/// </summary>
internal sealed class MemberReferenceService : IMemberReferenceService
{
private readonly ITrackedReferencesService _trackedReferencesService;
private readonly IMemberEditingService _memberEditingService;
/// <summary>
/// Initializes a new instance of the <see cref="MemberReferenceService"/> class.
/// </summary>
/// <param name="trackedReferencesService">Service used to manage tracked references.</param>
/// <param name="memberEditingService">Service used for member editing operations.</param>
public MemberReferenceService(
ITrackedReferencesService trackedReferencesService,
IMemberEditingService memberEditingService)
{
_trackedReferencesService = trackedReferencesService;
_memberEditingService = memberEditingService;
}
/// <inheritdoc/>
public async Task<Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus>> GetPagedReferencesAsync(Guid id, int skip, int take)
{
Attempt<PagedModel<RelationItemModel>, GetReferencesOperationStatus> result =
await _trackedReferencesService.GetPagedRelationsForItemAsync(id, UmbracoObjectTypes.Member, skip, take, true);
if (result.Success)
{
return result;
}
// The entity-based lookup fails for external-only members (no umbracoNode entry).
// Fall back to a key-based relation query if this is an external member.
if (result.Status == GetReferencesOperationStatus.ContentNotFound
&& await _memberEditingService.IsExternalMemberAsync(id))
{
#pragma warning disable CS0618 // Type or member is obsolete — using the key-based overload that doesn't require an entity.
PagedModel<RelationItemModel> externalRelations = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true);
#pragma warning restore CS0618
return Attempt.SucceedWithStatus(GetReferencesOperationStatus.Success, externalRelations);
}
return result;
}
}
@@ -1,10 +1,9 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
@@ -14,29 +13,25 @@ namespace Umbraco.Cms.Api.Management.Services.PermissionFilter;
internal sealed class DocumentPermissionFilterService : IDocumentPermissionFilterService
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IUserService _userService;
private readonly IContentPermissionService _contentPermissionService;
/// <summary>
/// Initializes a new instance of the <see cref="DocumentPermissionFilterService"/> class.
/// </summary>
/// <param name="backOfficeSecurityAccessor">Provides access to the current backoffice user's security context.</param>
/// <param name="userService">Service used to retrieve user and document permissions.</param>
/// <param name="contentPermissionService">Service used to retrieve content permissions.</param>
public DocumentPermissionFilterService(
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IUserService userService)
IContentPermissionService contentPermissionService)
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_userService = userService;
_contentPermissionService = contentPermissionService;
}
/// <inheritdoc />
public async Task<(IEntitySlim[] Entities, long TotalItems)> FilterAsync(IEntitySlim[] entities, long totalItems)
{
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
if (permissionsByNodeKey is null)
{
return (entities, totalItems);
}
Dictionary<Guid, NodePermissions> permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
IEntitySlim[] filteredEntities = FilterEntitiesWithBrowsePermission(entities, permissionsByNodeKey);
var removedCount = entities.Length - filteredEntities.Length;
@@ -47,11 +42,7 @@ internal sealed class DocumentPermissionFilterService : IDocumentPermissionFilte
/// <inheritdoc />
public async Task<(IEntitySlim[] Entities, long TotalBefore, long TotalAfter)> FilterAsync(Guid targetKey, IEntitySlim[] entities, long totalBefore, long totalAfter)
{
Dictionary<Guid, NodePermissions>? permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
if (permissionsByNodeKey is null)
{
return (entities, totalBefore, totalAfter);
}
Dictionary<Guid, NodePermissions> permissionsByNodeKey = await GetDocumentPermissionsByNodeKeyAsync(entities);
// Find the index of the target entity to determine before/after boundaries
var targetIndex = Array.FindIndex(entities, e => e.Key == targetKey);
@@ -65,27 +56,33 @@ internal sealed class DocumentPermissionFilterService : IDocumentPermissionFilte
return (filteredEntities, totalBefore - removedBefore, totalAfter - removedAfter);
}
private async Task<Dictionary<Guid, NodePermissions>?> GetDocumentPermissionsByNodeKeyAsync(IEntitySlim[] entities)
private async Task<Dictionary<Guid, NodePermissions>> GetDocumentPermissionsByNodeKeyAsync(IEntitySlim[] entities)
{
Guid userKey = CurrentUserKey();
IUser currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser
?? throw new InvalidOperationException("No backoffice user found");
var entityKeys = entities.Select(e => e.Key).ToHashSet();
Attempt<IEnumerable<NodePermissions>, UserOperationStatus> permissionsAttempt =
await _userService.GetDocumentPermissionsAsync(userKey, entityKeys);
IEnumerable<NodePermissions> permissions = await _contentPermissionService.GetPermissionsAsync(currentUser, entityKeys);
return permissionsAttempt.Success
? permissionsAttempt.Result.ToDictionary(p => p.NodeKey)
: null;
// Build dictionary with an entry for every requested key. Keys missing from the result
// default to empty permissions so they are treated as denied (fail-closed).
var result = entityKeys.ToDictionary(
key => key,
key => new NodePermissions { NodeKey = key, Permissions = new HashSet<string>() });
foreach (NodePermissions nodePermissions in permissions)
{
result[nodePermissions.NodeKey] = nodePermissions;
}
return result;
}
private Guid CurrentUserKey()
=> _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key
?? throw new InvalidOperationException("No backoffice user found");
private static IEntitySlim[] FilterEntitiesWithBrowsePermission(IEntitySlim[] entities, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
=> entities.Where(e => HasBrowsePermission(e, permissionsByNodeKey)).ToArray();
private static bool HasBrowsePermission(IEntitySlim entity, Dictionary<Guid, NodePermissions> permissionsByNodeKey)
=> permissionsByNodeKey.TryGetValue(entity.Key, out NodePermissions? nodePermissions) is false
|| nodePermissions.Permissions.Contains(ActionBrowse.ActionLetter);
=> permissionsByNodeKey.TryGetValue(entity.Key, out NodePermissions? nodePermissions)
&& nodePermissions.Permissions.Contains(ActionBrowse.ActionLetter);
}
@@ -62,4 +62,15 @@ public class MemberResponseModel : ContentResponseModelBase<MemberValueResponseM
/// Gets or sets the classification of the member, indicating the type or category of the member entity.
/// </summary>
public MemberKind Kind { get; set; }
/// <summary>
/// Gets or sets the raw JSON profile data for external-only members.
/// </summary>
/// <remarks>
/// Populated only for members whose <see cref="Kind"/> is <see cref="MemberKind.ExternalOnly"/>,
/// from <see cref="Umbraco.Cms.Core.Security.ExternalMemberIdentity.ProfileData"/>. The shape is
/// integrator-defined (typically claims serialised by an <c>OnExternalLogin</c> handler), so the
/// API returns the raw JSON string and leaves interpretation to the consumer.
/// </remarks>
public string? ProfileData { get; set; }
}
@@ -391,7 +391,7 @@ internal sealed class CollectibleRuntimeViewCompiler : IViewCompiler
foreach (var message in messages ?? Enumerable.Empty<string>())
{
_logger.LogError(compilationException, "Compilation error occured with message: {ErrorMessage}", message);
_logger.LogError(compilationException, "Compilation error occurred with message: {ErrorMessage}", message);
}
}
@@ -23,11 +23,13 @@ public class UmbracoEFCoreComposer : IComposer
builder.AddNotificationAsyncHandler<DatabaseSchemaAndDataCreatedNotification, EFCoreCreateTablesNotificationHandler>();
builder.AddNotificationAsyncHandler<UnattendedInstallNotification, EFCoreCreateTablesNotificationHandler>();
builder.Services.AddUmbracoDbContext<UmbracoDbContext>((provider, options, connectionString, providerName) =>
{
// Register the entity sets needed by OpenIddict.
options.UseOpenIddict();
});
builder.Services.AddUmbracoDbContext<UmbracoDbContext>(
(provider, options, connectionString, providerName) =>
{
// Register the entity sets needed by OpenIddict.
options.UseOpenIddict();
},
shareUmbracoConnection: true);
builder.Services.AddOpenIddict()
@@ -31,22 +31,19 @@ public static class UmbracoEFCoreServiceCollectionExtensions
this IServiceCollection services,
Action<DbContextOptionsBuilder>? optionsAction = null)
where T : DbContext
#pragma warning disable CS0618 // Type or member is obsolete
=> AddUmbracoDbContext<T>(services, (sp, optionsBuilder, connectionString, providerName) => optionsAction?.Invoke(optionsBuilder));
#pragma warning restore CS0618 // Type or member is obsolete
/// <summary>
/// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes.
/// </summary>
[Obsolete("Use the overload accepting shareUmbracoConnection. Scheduled for removal in Umbraco 19.")]
public static IServiceCollection AddUmbracoDbContext<T>(
this IServiceCollection services,
Action<DbContextOptionsBuilder, string?, string?, IServiceProvider?>? optionsAction = null)
where T : DbContext
{
return AddUmbracoDbContext<T>(services, (IServiceProvider provider, DbContextOptionsBuilder optionsBuilder, string? providerName, string? connectionString) =>
{
ConnectionStrings connectionStrings = GetConnectionStringAndProviderName(provider);
optionsAction?.Invoke(optionsBuilder, connectionStrings.ConnectionString, connectionStrings.ProviderName, provider);
});
}
=> AddUmbracoDbContext<T>(services, optionsAction, shareUmbracoConnection: true);
/// <summary>
/// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes.
@@ -56,22 +53,68 @@ public static class UmbracoEFCoreServiceCollectionExtensions
this IServiceCollection services,
Action<IServiceProvider, DbContextOptionsBuilder>? optionsAction = null)
where T : DbContext
#pragma warning disable CS0618 // Type or member is obsolete
=> AddUmbracoDbContext<T>(services, (sp, optionsBuilder, connectionString, providerName) => optionsAction?.Invoke(sp, optionsBuilder));
#pragma warning restore CS0618 // Type or member is obsolete
/// <summary>
/// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes.
/// </summary>
[Obsolete("Use the overload accepting shareUmbracoConnection. Scheduled for removal in Umbraco 19.")]
public static IServiceCollection AddUmbracoDbContext<T>(
this IServiceCollection services,
Action<IServiceProvider, DbContextOptionsBuilder, string?, string?>? optionsAction = null)
where T : DbContext
=> AddUmbracoDbContext<T>(services, optionsAction, shareUmbracoConnection: true);
/// <summary>
/// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="optionsAction">An optional action to configure the DbContext options.</param>
/// <param name="shareUmbracoConnection">
/// When <c>true</c> (default), the EF Core scope shares the NPoco (Umbraco main database)
/// connection and transaction. Set to <c>false</c> when the DbContext targets a separate
/// database with its own connection string.
/// </param>
public static IServiceCollection AddUmbracoDbContext<T>(
this IServiceCollection services,
Action<DbContextOptionsBuilder, string?, string?, IServiceProvider?>? optionsAction,
bool shareUmbracoConnection)
where T : DbContext
{
return AddUmbracoDbContext<T>(
services,
(IServiceProvider provider, DbContextOptionsBuilder optionsBuilder, string? connectionString, string? providerName) =>
{
ConnectionStrings connectionStrings = GetConnectionStringAndProviderName(provider);
optionsAction?.Invoke(optionsBuilder, connectionStrings.ConnectionString, connectionStrings.ProviderName, provider);
},
shareUmbracoConnection);
}
/// <summary>
/// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="optionsAction">An optional action to configure the DbContext options.</param>
/// <param name="shareUmbracoConnection">
/// When <c>true</c> (default), the EF Core scope shares the NPoco (Umbraco main database)
/// connection and transaction. Set to <c>false</c> when the DbContext targets a separate
/// database with its own connection string.
/// </param>
public static IServiceCollection AddUmbracoDbContext<T>(
this IServiceCollection services,
Action<IServiceProvider, DbContextOptionsBuilder, string?, string?>? optionsAction,
bool shareUmbracoConnection)
where T : DbContext
{
optionsAction ??= (sp, optionsBuilder, connectionString, providerName) => { };
services.AddPooledDbContextFactory<T>((provider, optionsBuilder) => SetupDbContext(optionsAction, provider, optionsBuilder));
services.AddTransient(services => services.GetRequiredService<IDbContextFactory<T>>().CreateDbContext());
services.AddSingleton(new EFCoreScopeConfiguration<T> { ShareUmbracoConnection = shareUmbracoConnection });
services.AddUnique<IAmbientEFCoreScopeStack<T>, AmbientEFCoreScopeStack<T>>();
services.AddUnique<IEFCoreScopeAccessor<T>, EFCoreScopeAccessor<T>>();
services.AddUnique<IEFCoreScopeProvider<T>, EFCoreScopeProvider<T>>();
@@ -27,6 +27,7 @@ internal sealed class EFCoreDetachableScope<TDbContext> : EFCoreScope<TDbContext
/// <param name="scopeContext">The scope context (must be null for detachable scopes).</param>
/// <param name="eventAggregator">The event aggregator.</param>
/// <param name="dbContextFactory">The DbContext factory.</param>
/// <param name="shareUmbracoConnection">Whether to share the NPoco connection and transaction.</param>
/// <param name="repositoryCacheMode">The repository cache mode.</param>
/// <param name="scopeFileSystems">Whether to scope file systems.</param>
public EFCoreDetachableScope(
@@ -38,6 +39,7 @@ internal sealed class EFCoreDetachableScope<TDbContext> : EFCoreScope<TDbContext
IScopeContext? scopeContext,
IEventAggregator eventAggregator,
IDbContextFactory<TDbContext> dbContextFactory,
bool shareUmbracoConnection = true,
RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
bool? scopeFileSystems = null)
: base(
@@ -49,6 +51,7 @@ internal sealed class EFCoreDetachableScope<TDbContext> : EFCoreScope<TDbContext
scopeContext,
eventAggregator,
dbContextFactory,
shareUmbracoConnection,
repositoryCacheMode,
scopeFileSystems)
{
@@ -20,9 +20,10 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
private readonly IEFCoreScopeAccessor<TDbContext> _efCoreScopeAccessor;
private readonly EFCoreScopeProvider<TDbContext> _efCoreScopeProvider;
private readonly IScope? _innerScope;
private readonly bool _shareUmbracoConnection;
private bool _disposed;
private TDbContext? _dbContext;
private IDbContextFactory<TDbContext> _dbContextFactory;
private readonly IDbContextFactory<TDbContext> _dbContextFactory;
private string? _originalConnectionString;
/// <summary>
@@ -36,6 +37,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
/// <param name="scopeContext">The scope context.</param>
/// <param name="eventAggregator">The event aggregator.</param>
/// <param name="dbContextFactory">The DbContext factory.</param>
/// <param name="shareUmbracoConnection">Whether to share the NPoco connection and transaction.</param>
/// <param name="repositoryCacheMode">The repository cache mode.</param>
/// <param name="scopeFileSystems">Whether to scope file systems.</param>
protected EFCoreScope(
@@ -47,6 +49,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
IScopeContext? scopeContext,
IEventAggregator eventAggregator,
IDbContextFactory<TDbContext> dbContextFactory,
bool shareUmbracoConnection = true,
RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
bool? scopeFileSystems = null)
: base(distributedLockingMechanismFactory, loggerFactory, scopedFileSystem, eventAggregator, repositoryCacheMode, scopeFileSystems)
@@ -55,6 +58,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
_efCoreScopeProvider = (EFCoreScopeProvider<TDbContext>)iefCoreScopeProvider;
ScopeContext = scopeContext;
_dbContextFactory = dbContextFactory;
_shareUmbracoConnection = shareUmbracoConnection;
}
/// <summary>
@@ -69,6 +73,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
/// <param name="scopeContext">The scope context.</param>
/// <param name="eventAggregator">The event aggregator.</param>
/// <param name="dbContextFactory">The DbContext factory.</param>
/// <param name="shareUmbracoConnection">Whether to share the NPoco connection and transaction.</param>
/// <param name="repositoryCacheMode">The repository cache mode.</param>
/// <param name="scopeFileSystems">Whether to scope file systems.</param>
public EFCoreScope(
@@ -81,6 +86,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
IScopeContext? scopeContext,
IEventAggregator eventAggregator,
IDbContextFactory<TDbContext> dbContextFactory,
bool shareUmbracoConnection = true,
RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
bool? scopeFileSystems = null)
: base(parentScope, distributedLockingMechanismFactory, loggerFactory, scopedFileSystem, eventAggregator, repositoryCacheMode, scopeFileSystems)
@@ -90,6 +96,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
ScopeContext = scopeContext;
_innerScope = parentScope;
_dbContextFactory = dbContextFactory;
_shareUmbracoConnection = shareUmbracoConnection;
}
/// <summary>
@@ -104,6 +111,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
/// <param name="scopeContext">The scope context.</param>
/// <param name="eventAggregator">The event aggregator.</param>
/// <param name="dbContextFactory">The DbContext factory.</param>
/// <param name="shareUmbracoConnection">Whether to share the NPoco connection and transaction.</param>
/// <param name="repositoryCacheMode">The repository cache mode.</param>
/// <param name="scopeFileSystems">Whether to scope file systems.</param>
public EFCoreScope(
@@ -116,6 +124,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
IScopeContext? scopeContext,
IEventAggregator eventAggregator,
IDbContextFactory<TDbContext> dbContextFactory,
bool shareUmbracoConnection = true,
RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified,
bool? scopeFileSystems = null)
: base(parentScope, distributedLockingMechanismFactory, loggerFactory, scopedFileSystem, eventAggregator, repositoryCacheMode, scopeFileSystems)
@@ -125,6 +134,7 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
ScopeContext = scopeContext;
ParentScope = parentScope;
_dbContextFactory = dbContextFactory;
_shareUmbracoConnection = shareUmbracoConnection;
}
@@ -213,27 +223,33 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
private void InitializeDatabase()
{
if (_dbContext is null)
{
_dbContext = FindDbContext();
}
_dbContext ??= FindDbContext();
_originalConnectionString ??= _dbContext.Database.GetConnectionString();
// Check if we are already in a transaction before starting one
// Check if we are already in a transaction before starting one.
if (_dbContext.Database.CurrentTransaction is null)
{
DbTransaction? transaction = _innerScope?.Database.Transaction;
_dbContext.Database.SetDbConnection(transaction?.Connection);
Locks.EnsureLocks(InstanceId);
if (transaction is null)
if (_shareUmbracoConnection)
{
_dbContext.Database.BeginTransaction();
DbTransaction? transaction = _innerScope?.Database.Transaction;
_dbContext.Database.SetDbConnection(transaction?.Connection);
Locks.EnsureLocks(InstanceId);
if (transaction is null)
{
_dbContext.Database.BeginTransaction();
}
else
{
_dbContext.Database.UseTransaction(transaction);
}
}
else
{
_dbContext.Database.UseTransaction(transaction);
// Separate database — use the DbContext's own configured connection.
Locks.EnsureLocks(InstanceId);
_dbContext.Database.BeginTransaction();
}
}
}
@@ -271,7 +287,14 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
{
try
{
if (_dbContext is null || _innerScope is not null)
if (_dbContext is null)
{
return;
}
// When sharing the Umbraco connection, the NPoco inner scope owns the transaction —
// skip commit/rollback here (the inner scope handles it).
if (_innerScope is not null && _shareUmbracoConnection)
{
return;
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
namespace Umbraco.Cms.Persistence.EFCore.Scoping;
/// <summary>
/// Per-DbContext configuration for EF Core scoping behavior.
/// </summary>
/// <typeparam name="TDbContext">The type of DbContext.</typeparam>
internal class EFCoreScopeConfiguration<TDbContext>
where TDbContext : DbContext
{
/// <summary>
/// Gets or sets a value indicating whether the EF Core scope should share
/// the NPoco (Umbraco main database) connection and transaction.
/// When <c>false</c>, the DbContext uses its own configured connection
/// and manages its own transaction independently.
/// Defaults to <c>true</c>.
/// </summary>
public bool ShareUmbracoConnection { get; set; } = true;
}
@@ -28,6 +28,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
private readonly FileSystems _fileSystems;
private readonly IScopeProvider _scopeProvider;
private readonly IDbContextFactory<TDbContext> _dbContextFactory;
private readonly bool _shareUmbracoConnection;
/// <summary>
/// Initializes a new instance of the <see cref="EFCoreScopeProvider{TDbContext}"/> class.
@@ -43,7 +44,8 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
StaticServiceProvider.Instance.GetRequiredService<IEventAggregator>(),
StaticServiceProvider.Instance.GetRequiredService<FileSystems>(),
StaticServiceProvider.Instance.GetRequiredService<IScopeProvider>(),
StaticServiceProvider.Instance.GetRequiredService<IDbContextFactory<TDbContext>>())
StaticServiceProvider.Instance.GetRequiredService<IDbContextFactory<TDbContext>>(),
StaticServiceProvider.Instance.GetRequiredService<EFCoreScopeConfiguration<TDbContext>>())
{
}
@@ -59,6 +61,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
/// <param name="fileSystems">The file systems.</param>
/// <param name="scopeProvider">The scope provider.</param>
/// <param name="dbContextFactory">The DbContext factory.</param>
/// <param name="scopeConfiguration">The per-DbContext scope configuration.</param>
internal EFCoreScopeProvider(
IAmbientEFCoreScopeStack<TDbContext> ambientEfCoreScopeStack,
ILoggerFactory loggerFactory,
@@ -68,7 +71,8 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
IEventAggregator eventAggregator,
FileSystems fileSystems,
IScopeProvider scopeProvider,
IDbContextFactory<TDbContext> dbContextFactory)
IDbContextFactory<TDbContext> dbContextFactory,
EFCoreScopeConfiguration<TDbContext> scopeConfiguration)
{
_ambientEfCoreScopeStack = ambientEfCoreScopeStack;
_loggerFactory = loggerFactory;
@@ -79,6 +83,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
_fileSystems = fileSystems;
_scopeProvider = scopeProvider;
_dbContextFactory = dbContextFactory;
_shareUmbracoConnection = scopeConfiguration.ShareUmbracoConnection;
_fileSystems.IsScoped = () => efCoreScopeAccessor.AmbientScope != null && ((EFCoreScope<TDbContext>)efCoreScopeAccessor.AmbientScope).ScopedFileSystems;
}
@@ -95,6 +100,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
null,
_eventAggregator,
_dbContextFactory,
_shareUmbracoConnection,
repositoryCacheMode,
scopeFileSystems);
@@ -187,6 +193,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
newContext,
_eventAggregator,
_dbContextFactory,
_shareUmbracoConnection,
repositoryCacheMode,
scopeFileSystems);
@@ -209,6 +216,7 @@ internal sealed class EFCoreScopeProvider<TDbContext> : IEFCoreScopeProvider<TDb
null,
_eventAggregator,
_dbContextFactory,
_shareUmbracoConnection,
repositoryCacheMode,
scopeFileSystems);
@@ -282,7 +282,7 @@ where tbl.[name]=@0 and col.[name]=@1;",
public override bool DoesPrimaryKeyExist(IDatabase db, string tableName, string primaryKeyName)
{
IEnumerable<SqlPrimaryKey>? keys = db.Fetch<SqlPrimaryKey>($"select * from sysobjects where xtype='pk' and parent_obj in (select id from sysobjects where name='{tableName}')")
IEnumerable<SqlPrimaryKey>? keys = db.Fetch<SqlPrimaryKey>("select * from sysobjects where xtype='pk' and parent_obj in (select id from sysobjects where name=@0)", tableName)
.Where(x => x.Name == primaryKeyName);
return keys.FirstOrDefault() is not null;
}
@@ -213,7 +213,7 @@ public class SqliteSyntaxProvider : SqlSyntaxProviderBase<SqliteSyntaxProvider>
/// <inheritdoc />
public override bool DoesPrimaryKeyExist(IDatabase db, string tableName, string primaryKeyName)
{
IEnumerable<string> items = db.Fetch<string>($"select sql from sqlite_master where type = 'table' and name = '{tableName}'")
IEnumerable<string> items = db.Fetch<string>("select sql from sqlite_master where type = 'table' and name = @0", tableName)
.Where(x => x.Contains($"CONSTRAINT {primaryKeyName} PRIMARY KEY"));
return items.Any();
@@ -0,0 +1,47 @@
<!doctype html>
<html class="no-js" lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Boot Failed</title>
<!--
The {{pathBase}} placeholder is replaced by BootFailedMiddleware
with the request's PathBase, so asset URLs resolve correctly when
Umbraco is hosted under a virtual directory.
-->
<link rel="stylesheet" href="{{pathBase}}/umbraco/website/nonodes.css" />
<style type="text/css">
body {
color: initial;
}
section {
background: none;
}
h1 {
margin-bottom: 0.5em;
}
h2 {
margin-bottom: 0.2em;
}
</style>
</head>
<body>
<section>
<article>
<div>
<h1>Boot Failed</h1>
<h2>Umbraco failed to boot</h2>
<p>If you are the owner of the website please see the log file for more details.</p>
</div>
</article>
</section>
</body>
</html>
@@ -1,5 +1,4 @@
using System.Collections.Concurrent;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Cache;
@@ -34,7 +33,7 @@ public abstract class AppPolicedCacheDictionary<TKey> : IDisposable
/// Gets or creates a cache.
/// </summary>
public IAppPolicyCache GetOrCreate(TKey key)
=> _caches.GetOrAdd(key, k => _cacheFactory(k));
=> _caches.GetOrAdd(key, _cacheFactory);
/// <summary>
/// Removes a cache.
@@ -1,10 +1,12 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services.Changes;
namespace Umbraco.Extensions;
@@ -308,12 +310,28 @@ public static class DistributedCacheExtensions
/// <returns>An enumerable of JSON payloads for the member cache refresher.</returns>
/// <remarks>Internal for unit test.</remarks>
internal static IEnumerable<MemberCacheRefresher.JsonPayload> GetPayloads(IEnumerable<IMember> members, IDictionary<string, object?> state, bool removed)
=> members
{
bool indexableFieldsChanged = GetMemberIndexableFieldsChanged(state);
return members
.DistinctBy(x => (x.Id, x.Username))
.Select(x => new MemberCacheRefresher.JsonPayload(x.Id, x.Username, removed)
.Select(x => new MemberCacheRefresher.JsonPayload(x.Id, x.Username, removed, indexableFieldsChanged)
{
PreviousUsername = GetPreviousUsername(x, state)
});
}
private static bool GetMemberIndexableFieldsChanged(IDictionary<string, object?> state)
{
// Default to true for backward compatibility — any save that doesn't explicitly signal
// "nothing indexable changed" is treated as potentially indexable.
if (state.TryGetValue(Constants.Conventions.Member.IndexableFieldsChangedStateKey, out object? value)
&& value is bool flag)
{
return flag;
}
return true;
}
private static string? GetPreviousUsername(IMember x, IDictionary<string, object?> state)
{
@@ -334,6 +352,59 @@ public static class DistributedCacheExtensions
#endregion
#region ExternalMemberCacheRefresher
/// <summary>
/// Refreshes the specified external members in the distributed cache.
/// </summary>
/// <param name="dc">The distributed cache.</param>
/// <param name="externalMembers">The external members to refresh in cache.</param>
[Obsolete("Use the overload taking notification state instead. Scheduled for removal in Umbraco 19.")]
public static void RefreshExternalMemberCache(this DistributedCache dc, IEnumerable<ExternalMemberIdentity> externalMembers)
=> dc.RefreshExternalMemberCache(externalMembers, new Dictionary<string, object?>());
/// <summary>
/// Refreshes the specified external members in the distributed cache.
/// </summary>
/// <param name="dc">The distributed cache.</param>
/// <param name="externalMembers">The external members to refresh in cache.</param>
/// <param name="state">The notification state dictionary.</param>
public static void RefreshExternalMemberCache(this DistributedCache dc, IEnumerable<ExternalMemberIdentity> externalMembers, IDictionary<string, object?> state)
=> dc.RefreshByPayload(
ExternalMemberCacheRefresher.UniqueId,
GetPayloads(externalMembers, state, removed: false));
/// <summary>
/// Removes the specified external members from the distributed cache.
/// </summary>
/// <param name="dc">The distributed cache.</param>
/// <param name="externalMembers">The external members to remove from cache.</param>
public static void RemoveExternalMemberCache(this DistributedCache dc, IEnumerable<ExternalMemberIdentity> externalMembers)
=> dc.RefreshByPayload(
ExternalMemberCacheRefresher.UniqueId,
GetPayloads(externalMembers, new Dictionary<string, object?>(), removed: true));
/// <summary>
/// Gets the JSON payloads for external member cache refresh operations.
/// </summary>
/// <param name="externalMembers">The external members to create payloads for.</param>
/// <param name="state">The notification state dictionary.</param>
/// <param name="removed">Whether the external members were removed.</param>
/// <returns>An enumerable of JSON payloads for the external member cache refresher.</returns>
/// <remarks>Internal for unit test.</remarks>
internal static IEnumerable<ExternalMemberCacheRefresher.JsonPayload> GetPayloads(
IEnumerable<ExternalMemberIdentity> externalMembers,
IDictionary<string, object?> state,
bool removed)
{
bool indexableFieldsChanged = GetMemberIndexableFieldsChanged(state);
return externalMembers
.DistinctBy(x => x.Key)
.Select(x => new ExternalMemberCacheRefresher.JsonPayload(x.Id, x.Key, removed, indexableFieldsChanged));
}
#endregion
#region MemberGroupCacheRefresher
/// <summary>
@@ -0,0 +1,26 @@
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Provides additional cache tags to evict from the Delivery API output cache when content changes.
/// </summary>
/// <remarks>
/// <para>
/// Multiple implementations can be registered. The eviction handler iterates all providers
/// to collect additional tags to evict beyond the built-in content key tag.
/// </para>
/// <para>
/// Works as a pair with <see cref="IDeliveryApiOutputCacheTagProvider"/>: the tag provider adds
/// custom tags when caching a response, and this provider maps content changes back to those
/// tags at eviction time.
/// </para>
/// </remarks>
public interface IDeliveryApiOutputCacheEvictionProvider
{
/// <summary>
/// Returns additional cache tags to evict when the specified content changes.
/// </summary>
/// <param name="context">Details of the content change.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>Additional cache tags to evict.</returns>
Task<IEnumerable<string>> GetAdditionalEvictionTagsAsync(OutputCacheContentChangedContext context, CancellationToken cancellationToken = default);
}

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