Compare commits

...
Author SHA1 Message Date
b836b44343 Table dates and User dates (#22169)
User-collection-table didn´t format and if you have da backoffice the time is still Am/pm

Co-authored-by: Lucas Bach Bisgaard <lucas.bisgaard@kraftvaerk.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-06-26 09:24:35 +02:00
d8f4342a86 Fix detail data request manager failing when items hit 40, making document unusable (#23164)
Fix detail data request manager failing as soon as the number of items requested hits the UmbItemDataApiGetRequestController batch limit (40)

Co-authored-by: Paul Woodland <paul.woodland@pwnewmedia.com>
2026-06-26 09:09:11 +02:00
Niels LyngsøandGitHub 022439065f Block Workspace: avoid JS error if destroyed (#23200)
avoid JS error if Block Workspace Context was destroyed while awaiting a frame
2026-06-25 15:59:33 +02:00
065e567f11 Media: Add umb-media-thumbnail with configurable checkerboard background (closes #23177) (#23178)
* feat(media): add umb-thumbnail and configurable checkerboard background

Adds `umb-thumbnail` as the recommended alias of `umb-imaging-thumbnail`
(the original tag stays registered for backwards compatibility), and makes
the checkerboard background opt-out via the `--umb-thumbnail-background` CSS
custom property plus an `img` part for full styling control. Also fixes an
action-event listener leak in the thumbnail element, and adds a Storybook
story, an MDX guide, and component tests.

Closes #23177

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

* refactor(media): address PR review on umb-thumbnail

- Rephrase the imaging-thumbnail JSDoc to a neutral alias statement instead of
  a "prefer" wording that read like an undeclared deprecation.
- Guard the thumbnail tests so a renamed private field fails loudly rather than
  producing vacuous assertions.

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

* refactor(media): make umb-thumbnail canonical, deprecate umb-imaging-thumbnail

Invert the inheritance so the implementation lives on `UmbThumbnailElement`
(`umb-thumbnail`) and `UmbImagingThumbnailElement` (`umb-imaging-thumbnail`)
is the thin subclass. Removing the old tag is now just deleting one file.

The deprecated subclass emits a one-time `UmbDeprecation` warning (a
module-level guard avoids per-instance console spam) and carries a
`@deprecated` JSDoc, scheduled for removal in Umbraco 19.

Migrate the four internal consumers to `umb-thumbnail` so the deprecation
warning targets external code only, not our own.

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

* test(media): trim deprecated-alias thumbnail tests to a registration guard

The img part, checkerboard default and --umb-thumbnail-background override are
covered by thumbnail.element.test.ts and inherited from UmbThumbnailElement, so
re-asserting them on the umb-imaging-thumbnail subclass only tested inheritance.
Keep a single backwards-compat guard that the deprecated alias stays registered
and on the inheritance chain.

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

* refactor(media): rename canonical thumbnail to umb-media-thumbnail; alias keeps @deprecated, no runtime warning

Per review (Niels): the forward-looking name is `umb-media-thumbnail`
(`UmbMediaThumbnailElement`), leaving room for non-media thumbnails later. The
implementation, CSS custom property (`--umb-media-thumbnail-background`), story,
guide and internal consumers all use the new name.

`umb-imaging-thumbnail` stays registered as a thin alias and keeps its
`@deprecated` JSDoc (IDE signal) but no longer emits a runtime UmbDeprecation
warning — both tags fly for now. Docs and comments lead with umb-media-thumbnail.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:37:53 +00:00
58a1c15626 Deprecations: Annotate warnings with caller origin and suppress core noise in production (#23188)
* feat(core): annotate deprecation warnings with caller origin, suppress core noise in production

Deprecation warnings now state where the call most likely came from — Umbraco
core, an /App_Plugins package, or other custom code — by classifying the call
stack (first frame not under /umbraco/backoffice/ is the caller). This answers
the Codegarden feedback that you can't tell whose code triggered a warning.

In production builds, core-origin warnings are suppressed (a consumer can't act
on Umbraco's own code); package/external/unknown origins are always shown. The
production signal is the client build, not the server runtime mode — the latter
is unreliable since Umbraco Cloud defaults to BackofficeDevelopment. The new
umbIsProductionBuild() reads Vite's import.meta.env.PROD (substituted to true in
the shipped core bundle) and falls back to false outside a Vite build.

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

* refactor(core): cleaner deprecation output and drop the throw for stack capture

Read new Error().stack directly instead of throwing and catching — the stack is
populated on construction. Annotate the warning with the resolved origin on its
own line rather than a bracketed prefix, and rely on the browser's native
expandable stack on console.warn for the full clickable trace instead of
printing one ourselves.

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

* chore(core): trim inline comments in deprecation utils

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

* refactor(core): address PR review on deprecation origin

- Clarify umbIsProductionBuild docs: in Vite dev import.meta.env is defined
  (PROD false); the guard is for non-Vite contexts (tsc pass, web-test-runner).
- Strip query/fragment from parsed frame URLs so the external-origin label
  can't carry ?/# noise.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:29:21 +00:00
Jacob Overgaard 05f8158e4a Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-06-23 14:29:24 +02:00
Jacob Overgaard 28b849f2d3 build(deps): bumps @umbraco-ui/uui to 1.18.1 2026-06-23 11:54:32 +02:00
ca7dcd5150 Published Cache: Guard against cache poisoning from a render-vs-publish race (#23169)
* Guard against cache poisoning from concurrency

* Resolve code review comments relating to tests.

* Avoid unnecessary second invalidation of memory cache generatio.

* Tighten the cache-generation guard.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-23 10:14:46 +02:00
Kenn JacobsenandGitHub ceef53d624 Examine: Queue cold boot Examine reindexing at start-up, not at first request (Closes #22883) (#23181)
* Queue cold boot Examine reindexing at start-up, not at first request

* Review comments from Claude
2026-06-23 08:40:24 +02:00
Andy ButlandandGitHub 5bb53172aa ModelsBuilder: Avoid ObjectDisposedException in InMemoryModelFactory during shutdown (#23171)
* Guard EnsureModels against disposed lock on shutdown.

* Addressed code review comment.
2026-06-23 07:13:49 +02:00
Andy ButlandandGitHub aa9473131b Content Types: Show the correct type name (Media/Member Type) in the Compositions dialog (closes #23102) (#23118)
* Display appropriate content type name in compositions dialog localised texts.

* Fix composition dialog translation typos and link references to the matching workspace

- fr: "sililaire" -> "similaire"
- it: "utlizzato" -> "utilizzato"
- es: remove duplicated "no puede no puede"

The reference list now builds its workspace edit href from the modal's
entityType instead of hardcoding document-type, so links resolve correctly
when the dialog is used for Media Types and Member Types.
2026-06-22 16:28:59 +01:00
Mads RasmussenandGitHub 35a3a2455c Entity Data Picker: Implement interaction memory + sync picker memory across all picker inputs (#23172)
* Integrate interaction memories into entity data picker

* Skip resetting unchanged data source API

Add an early-return guard in setDataSourceApi to avoid re-setting the same UmbPickerDataSource instance. Prevents rebuilding the modal token/route (which would close and reopen an open picker modal) on every re-render by only updating when the API actually changes.

* Add UmbEntityInputInteractionMemoryManager + implement across current inputs with memory

* clean up comment
2026-06-22 14:12:55 +00:00
Andy Butland 5a20452e7a Bump version to 17.5.0-rc3. 2026-06-19 18:44:26 +02:00
Andy Butland adeddeb148 Complete bump version to 17.5.0-rc2. 2026-06-19 15:51:10 +02:00
Lee KelleherandGitHub ab8b8b48d4 Block RTE: Implement unsupported block rendering (closes #23071) (#23126)
* Block RTE: Implement unsupported block rendering

* Fixes `.ProseMirror-selectednode` focus ring

* Markup tidy-up

* Adds test for `umb-unsupported-rte-block`

* Block RTE: Reflect unsupported state as a host attribute

Replaces @state() + toggleAttribute() with @property({ reflect: true })
so Lit manages the 'unsupported' attribute sync during the update cycle,
avoiding constructor-time attribute access flagged by the linter.

Also removes the now-inert uui-text/uui-font classes from the block
wrapper div (backing styles were removed with UmbTextStyles).

* Adds JSDoc comment to `unsupported` property

* Block RTE: Extract #observeBlockViewProps() to reduce constructor size

Moves the block-view-props observer setup out of the constructor into a
dedicated #observeBlockViewProps() method, following the same pattern as
#observeData(). Reduces constructor from 123 to 64 lines (threshold: 70).
2026-06-19 13:08:00 +01:00
Jacob Overgaard feb1689848 Merge branch 'release/17.5.0' into v17/dev 2026-06-19 13:56:04 +02:00
Jacob OvergaardandClaude Opus 4.8 a14b908574 test(backoffice): fix host type in extension-initializer-base test
The `loaded`-signal test (added in #23167) built its host with
`UmbControllerHostElementMixin(HTMLElement)`, mirroring the older
`UmbBaseExtensionInitializer` tests. But `UmbExtensionInitializerBase`
requires a full `UmbElement` host, so the test failed `tsc` (TS2345)
under the root tsconfig. The product build excludes `*.test.ts`, so it
slipped through CI but breaks `npm run compile`/the editor.

Use `UmbElementMixin(HTMLElement)`, matching what production callers pass
(app/backoffice/preview elements are all UmbElements).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:55:56 +02:00
Jacob Overgaard 925d6bc430 Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-06-19 13:37:44 +02:00
Jacob OvergaardandClaude Opus 4.8 acfaf23e43 Merge external-login entrypoint-race fix into v17/dev
Reconciles app.element.ts with #23020 (parallelized public extensions).
Kept the boot gate (await the app-entry-point initializer before routing)
and restored a blocking inline `await registerPublicExtensions()` instead
of the parallelized deferred form — a marginally slower but more robust
boot, identical to the release/17.5.0 fix (no empty-first-pass timing
reliance). extension-initializer-base.ts, the unit test, the acceptance
test and playwright config merge cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:35:21 +02:00
Niels LyngsøandGitHub 7c1b907410 Block Workspace: Support variant properties and make '$settings' a key-value-object (Closes #23095) (#23123)
* map settings to become a key-value-object

* implement type safety for block label ufm values

* added TODOs

* support variant value in Block Workspace Label
2026-06-19 11:26:18 +00:00
Niels Lyngsø 564ca0384b Squashed commit of the following:
commit cd132f44b1
Author: Niels Lyngsø <niels.lyngso@gmail.com>
Date:   Fri Jun 19 13:18:07 2026 +0200

    correct to use display: block;

commit a9ffa9f90b
Author: Andreas Lykke Borg <72602768+andreaslborg@users.noreply.github.com>
Date:   Mon Jun 15 20:56:51 2026 +0200

    Added css styling to block list and single to respect custom width
2026-06-19 13:25:17 +02:00
9f633416b1 External login: wait for app-entry-points before the login provider decision (#23167)
* External login: wait for app-entry-points before the login provider decision

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:11:28 +01:00
Lee KelleherandGitHub 91381604dd UFM: Add umbMemberName component (closes #22147) (#23165)
* Adds UFM Member Name component

This is to support the standalone Member Picker values.

* fix(ufm): clear stale value on empty member picker; validate UDI-extracted GUIDs

* test(ufm): add umbMemberName parsing tests to marked-ufm.test.ts
2026-06-19 09:48:51 +00:00
Jacob OvergaardandClaude Opus 4.8 c566dd0a71 fix(backoffice): harden loaded signal + narrow acceptance test glob (review)
Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:26 +02:00
Jacob OvergaardandClaude Opus 4.8 ea78147657 test(backoffice): guard the loaded-gate timing for permission loading
Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:19:39 +02:00
Andy ButlandandGitHub 0a5189e54a Content & Media: Enforce content type filters and allowed children/root rules when validating a create (#23163)
* Validate for content type filters on create document and media validation.

* Addressed code review feedback on tests.
2026-06-19 11:16:47 +02:00
Jacob OvergaardandClaude Opus 4.8 102e4aa80b External login: wait for app-entry-points before the login provider decision
The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:10:01 +02:00
Andy ButlandandGitHub 0bec947b8b Examine: Stream value sets to a single index during rebuild to reduce reindex peak memory (#23150)
* Stream value sets to a single index during rebuild to reduce reindex peak memory.

* Applied code review feedback.
2026-06-19 06:32:18 +02:00
Andy Butland d0e7ef0169 Merge branch 'release/17.5.0' into v17/dev 2026-06-17 19:31:46 +02:00
Jesper MadsenandJacob Overgaard e2cf205d34 Let the external login button show "sign in with {providername}" in languages (#23135) 2026-06-17 15:57:57 +02:00
Jacob Overgaard bf0270b244 build: deploy to npm through a template 2026-06-17 15:49:00 +02:00
Jacob OvergaardandClaude Opus 4.7 dc77f37129 Build: tag prerelease npm publishes with 'next' dist-tag (#22909)
* Build: tag prerelease npm publishes with 'next' dist-tag

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

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

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

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

* Build: source npmPrereleaseVersion via dependencies, not dependsOn

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

* Build: align Deploy_Npm with Umbraco Deploy publish pattern

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 15:48:53 +02:00
dbecec3451 Cache: Populate the domain cache eagerly during start-up (#23139)
* Populate the domain cache eagerly during start-up

* Added extension method to encapsulate and test logic for skipping startup seeding.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-16 13:19:09 +00:00
Andy ButlandandGitHub d92e6bbeff Tiptap: Preserve wrapping link when editing an image (closes #23013) (#23019)
* Retain surrounding link when editing image details in rich text editor.

* Add JSDocs.
2026-06-16 11:06:45 +01:00
e56ddcc3f9 Tiptap RTE: Fixes toolbar button active state not updating on collapsed-cursor mark toggle (closes #22907) (#22929)
* update tiptap event listneres

* Tiptap: Add regression test for toolbar button active state on collapsed-cursor toggle (closes #22907)

Tests verify the `transaction` listener wiring that fixes the stored-mark active-state bug.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-06-16 09:56:42 +00:00
fb8c3b19ce Backoffice: Fix core circular import (fetchAllPages) breaking the test check (#23133)
Backoffice: Move fetchAllPages into the repository module to break a core circular import

#22765 added the offset pagination helper `fetchAllPages` under
`@umbraco-cms/backoffice/utils`, but its contract is expressed entirely in
repository-owned types (`UmbDataSourceResponse<UmbPagedModel<T>>`). That made
`utils` import `repository` while `repository` already imports `utils`,
introducing a 17th core bidirectional module import and tripping
`check:module-dependencies` (threshold 16) — failing the `test` job on every
open PR.

Relocate the helper (and its test) into the `repository` module, which
legitimately owns those types, and export it from
`@umbraco-cms/backoffice/repository`. The sole consumer
(UmbLanguageCollectionRepository) already imports from that module. Core
bidirectional imports are back to 16.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 09:51:36 +01:00
Andy ButlandandGitHub 7a7aadffe5 User Groups: Clear the user group cache when a language is deleted (closes #23121) (#23131)
* Ensure user group cache is cleared on language delete.

* Apply code review feedback.
2026-06-16 08:47:46 +02:00
Andy ButlandandGitHub 0ac6e8500a Background Jobs: Recover cache-sync and server-touch jobs when a database call hangs (closes #23106) (#23119)
* Recover cache-sync job if database Sync() hangs.

* Apply also to TouchServerJob.

* Addressed code review comments.

* Added debug logging to allow monitorring of job runs.

* Added tests verifying that jobs resume after an inflight call completes.
2026-06-16 08:01:14 +02:00
4c35c0c2e9 Management API: Add endpoints to sort the children of a document or media item by a system field (#23077)
* Add endpoints for sorting documents and media by system fields.

* Addressed code review feedback.

* Persist sort-children-by-field with a single set-based update.

* Addressed second round of code review feedback.

* DRYed up similar code, improved comments.

* Split tests into individual class files

* Added test for combined sort of invariant and variant children.

* Addressed further code review feedback.

* Include test scenario from #23128 for SortChildren()

* Renamed children authorizer as it is generic, not specific for sorting - and updated XML docs accordingly

---------

Co-authored-by: kjac <kja@umbraco.dk>
2026-06-16 05:29:47 +00:00
Andy ButlandandGitHub de47e0b1f7 Content/Media: Reload content on Sort to avoid data loss with partially loaded entities (closes #23120) (#23128)
Ensure calling sort for content or media with partially loaded entities doesn't lose the unloaded data on persistence.
2026-06-15 19:31:34 +02:00
Andy Butland 969ae87798 Added TODO to adjust the ScheduledPublishingSettings.AlignToClock default to true. 2026-06-15 16:39:23 +02:00
Andy Butland fe3318ef79 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

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

* Use Lock object.
2026-06-15 16:19:24 +02:00
Andy ButlandandGitHub 828e359666 Languages: Page through to load all configured languages (#22765)
* Ensure all languages are retrieved handling rare (theoretical?) case where the number of languages exceeds the default page size.

* Addressed code review feedback.

* Addressed further code review feedback.
2026-06-15 16:17:30 +02:00
a5b7e0dac1 Scheduled publishing: Add configurable period and optional clock-aligned scheduling (#23127)
* Add configurable period for scheduled publishing task with optional clock alignment.

* Apply suggestions from code review

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

* Addressed code review comments.

* Clarified the maths, improved comments and test coverage.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 13:48:34 +00:00
Andy ButlandandGitHub 86abc3528d Request Logging: Avoid forcing a session store load when resolving the session id for logging (closes #23082) (#23083)
* Guard read of session ID for log enrichment by presence of session cookie.

* Renamed tests.

* Add configurable option for session ID logging, retaining backward compatibility but giving options to skip session Id logging or use a cookie hash.
2026-06-15 06:47:59 +02:00
Andy Butland 7433641348 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:35:25 +02:00
Andy ButlandandGitHub c45b12ec58 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:34:57 +02:00
Andy ButlandandGitHub 22c4bc7835 Package migrations: Surface a failed unattended package migration as a boot failure instead of getting stuck on the upgrade screen (#23114)
* Surface a package migration exception as a boot failure, avoiding being stuck in an upgrading state.

* Addressed code review feedback.

* Fix failing integration tests.
2026-06-15 11:58:58 +09:00
Andy ButlandandGitHub 1e82376420 Skills: Add umb-release-notes skill for improving generated release notes (#23112)
* Add skill to help with improving the generated GitHub release notes.

* Addressed skill review feedback.
2026-06-15 11:41:57 +09:00
Zeegaan 764d4eb1d7 bump version 2026-06-11 14:02:32 +09:00
Mads RasmussenandGitHub aa854da3f4 Backoffice: Pre-expand tree to target entity when opening Duplicate and Move To modals (closes #22015) (#23063)
* Support tree expansion in generic Duplicate To modal

* Add expansion prop to tree picker modal types

* Apply expansion from data to picker context

* move to action: populate tree picker expansion with ancestors

* Pass tree expansion to duplicate document modal

* Extract ancestor fetching into private method

* Use UmbDocumentTreeRepository directly

* Exclude self from ancestor results

* make name more explicit

* Guard ancestor fetch and simplify expansion

* Only set treeExpansion when ancestors exist

* fix type issues

* Parallelize ancestor and pickable filter fetch

* Use getter for treeExpansion; remove unused imports
2026-06-09 16:53:37 +02:00
Niels LyngsøandGitHub 28cdbe5317 TipTap: Let the stylesheet load parallel to tiptap-extensions (#23024)
do not await stylesheets to be loaded before extensions
2026-06-09 11:14:49 +00:00
3dbd4baefe Backoffice Search: Batch the ancestors lookup for search results to avoid exceeding the maximum URL length (closes #23032) (#23048)
* Ensure requests to fetch ancestors after retrieving search results are batched to avoid a single query exceeding the maximum URL length.

* Guard against undefined ancestor entries from a failed batch

batchTryExecute resolves each chunk via tryExecute, which never rejects, so
a per-chunk failure comes back as a fulfilled result carrying an error and
leaves an undefined hole in the amalgamated data without surfacing an error.
Detect that before mapping and return an explicit error instead of throwing.

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

* Assert ancestor id uniqueness and silence direct-api lint rule

Strengthen the batching tests to assert every search-result id is requested
exactly once (Set size), not just that the total count matches. Add the
no-direct-api-import disable on the controller's api callback, matching the
existing url data sources, since the call is wrapped by the controller.

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

* Addessed Codescene warnings.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:31:37 +02:00
Andy ButlandandGitHub fa5dd209c1 Tags: Fix null reference error in tags input on render (closes #23044) (#23049)
Fix intermittent null reference exception in tags element.
2026-06-09 10:29:27 +02:00
Andy ButlandandGitHub 4cc4acee62 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

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

* Use Lock object.
2026-06-07 15:35:38 +02:00
Andy Butland d0fc7dc8a0 Tiptap RTE: Ignore no-op transactions in onUpdate to prevent phantom dirty state (closes #22767) (#22781)
Ignore Tiptap no-op transactions in onUpdate to prevent phantom dirty state.
2026-06-05 15:56:23 +02:00
62663d9573 Runtime Cache: Fix IAppPolicyCache.ClearByKey intermittently failing to clear cached items (closes #23064) (#23068)
* Prevent ClearByKey leaving stale runtime cache items.

* Lowered loop timer.

* Clarified code comment.

* Improve assertions and comments in tests.

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2026-06-05 09:23:26 +02:00
b582f9d2ef Dependencies: Upgrade Examine to 3.8.0 (#23075)
Upgrade Examine to 3.8.0

Co-authored-by: Simon Gibbs <sgibbs@qmu.ac.uk>
2026-06-05 06:59:11 +02:00
ad90db8b38 Performance: Coalesce concurrent tree data requests (Management API client) (#23021)
* perf(tree): coalesce concurrent identical tree data requests

The tree data request manager hit the network on every call, so multiple
concurrent consumers (sidebar tree, breadcrumb structure, pickers) each
fetched the same data independently — e.g. three identical tree/document/root
requests per document-workspace load.

Apply the existing UmbManagementApiInFlightRequestCache (already used by the
item and detail request managers) to the tree request manager via a shared
static cache, coalescing concurrent identical root/children/ancestors/siblings
calls into a single in-flight request, cleared on settle (in-flight only, so
no stale-cache risk). The document tree opts in; other trees are unchanged
until they pass a cache.

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

* test(tree): cover request coalescing; address review feedback

- Add focused tests: concurrent identical root requests share one call,
  the in-flight entry is cleared on settle, and no cache means no coalescing.
- Build the cache key lazily (only when a cache is wired) so non-opted-in
  trees keep the original lightweight path.
- Constrain the #coalesce generic to drop the cast on cache.set.
- Document the new inflightRequestCache arg; trim the comment to one line.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:40:58 +02:00
Erik-Jan WestendorpandAndy Butland 8e3b821a55 Localization: Add Dutch translations for create and delete actions (#23039)
Update nl.ts
2026-06-04 08:37:53 +02:00
Andy ButlandandGitHub 8ac989c4e3 Data Types: Tolerate invalid configuration when determining the editor value storage type (closes #23057) (#23058)
* Tolerate invalid data type configuration when getting the editor value storage type.

* Add logging in case of error.

* Resolve warning.

* Removed exception from warning (it's not useful).

* Log an error instead of a warning.
2026-06-04 14:38:21 +09:00
Andy Butland 89baa9482b Background Jobs: Resolve server role so recurring jobs run when no application URL is configured (#23033)
Resolve server role when no application URL is configured.
2026-06-04 06:40:00 +02:00
Andy ButlandandGitHub 3913a61b74 Background Jobs: Resolve server role so recurring jobs run when no application URL is configured (#23033)
Resolve server role when no application URL is configured.
2026-06-04 06:37:08 +02:00
Lee KelleherandGitHub 90bedcd42e Menu Structure: Guard against use-after-destroy in async structure request (#23055)
* Menu Structure: Guard against use-after-destroy in async structure request

When navigating to a trashed item, the IS_NOT_TRASHED condition initially
permits the standard menu structure context, which is then destroyed once the
workspace confirms the item is trashed. The in-flight async #requestStructure()
could resume after destruction and call setValue() on a completed subject,
throwing "_subject is undefined".

Guard the state mutations with the framework's existing _host-cleared-on-destroy
signal, and handle the previously fire-and-forget #requestStructure() promises so
a teardown mid-request is silently abandoned rather than surfacing as an uncaught
rejection. Applied to both the variant and non-variant menu structure base
contexts.

* Menu Structure: Make #requestStructure non-throwing instead of catching at call sites

Per PR review feedback: replace the blanket .catch(() => {}) wrappers with
early returns inside #requestStructure(). The _host guard already prevents
post-destroy state mutation; the throws only fire for can't-happen missing
observable states and were producing unhandled rejections with no caller
able to act on them.

* Added console warning, if the host is still available
2026-06-03 18:32:29 +02:00
Lee KelleherandGitHub c54189aa90 Block Grid: Guard validator against torn-down manager on navigation (#22852)
* Block Grid: Guard validator against torn-down manager on navigation

The form-control mixin's updated() hook runs validators when the element
re-renders during teardown. If navigation has already disposed _manager,
checkBlockTypeConfigurationValidity would throw "Cannot read properties
of undefined (reading 'getContentTypeKeyOfContentKey')".

Early-return as valid when the manager is gone and use optional chaining
on the per-entry lookup as a safety net.

* Removed optional chaining of `_manager`

As `_manager` has already been checked.

* Reverting the `_manager` optional chaining

As TypeScript compiler doesn't like it, (inside the `filter` callback).
2026-06-03 15:29:41 +02:00
88ec0a248f Media: Restore friendly naming of uploaded media items (closes #22989) (#22998)
* Update uploaded media file name to a friendly name.

* Correct test description for acronym handling.

The case JUST-A-FILE.jpg verifies all-uppercase words are preserved
as acronyms, not that lowercase words get lowercased.

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

* Match server-side StripFileExtension semantics in toFriendlyName.

The TypeScript helper previously delegated to getFileExtension, which
diverges from the C# StripFileExtension on two edge cases:
- a trailing dot ("file.") is stripped by the server but not the client
- an "extension" containing whitespace is preserved by the server but
  stripped by the client

Inlined a stripFileExtension helper that mirrors the C# rules exactly,
making the "keep in sync" cross-reference accurate. Added tests for both
divergent cases and replaced the contrived leading/trailing whitespace
test with a realistic interior-whitespace case.

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

* Add parity test for trailing-whitespace extension span.

Restores the '  spaced-name.jpg  ' case as a parity test against
StripFileExtension's "extension containing whitespace is preserved"
rule. Output is 'Spaced Name.Jpg' (Jpg title-cased, matching the
server's TextInfo.ToTitleCase behaviour on the now-unstripped extension).

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

* Handle getContext rejection in ensureMediaNameFromFile.

getContext rejects on timeout when the dataset context never resolves;
callers used void ensureMediaNameFromFile(...) so an unhandled rejection
would bubble. Catch the rejection and treat it as an absent context.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:37:49 +01:00
Lee KelleherandGitHub 3e22733081 Document Recycle Bin: Checks user permission for Document "Read" (#23041)
* Adds conditions to Document Recycle Bin

that the user must have "Read" permission.

* Directly imports Media Recycle Bin condition

this will remove an extra fetch request.
2026-06-02 16:31:51 +02:00
Andy ButlandandGitHub 232077e820 EF Core: Retry transient SQLite lock errors during long-running operations (closes #22939) (#22969)
* Retry transient SQLite lock errors during long-running operations.

* Addressed code review comments.
2026-06-02 13:59:37 +02:00
Jacob Overgaard 943d1eeccd Merge branch 'release/17.5.0' into v17/dev 2026-06-02 12:44:29 +02:00
Jacob Overgaard b3666dad8b build(deps): bumps @umbraco-ui/uui to 1.18.0 2026-06-02 12:43:21 +02:00
Sven GeusensandAndy Butland 28a403361e Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

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

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

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-01 14:38:35 +02:00
38d73b3a41 Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

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

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

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-01 12:34:24 +00:00
Niels Lyngsø 5c0cb154f5 cherry picked #23027 2026-06-01 10:04:01 +02:00
4a621a13bc Performance: Parallelize independent boot API requests (server status/config + public extensions) (#23020)
* perf(core): parallelize independent boot API requests

UmbServerConnection.connect() awaited server status and configuration
sequentially even though they are independent reads; run them with
Promise.allSettled so both errors surface (the app cannot function
without either) while saving a round-trip.

During app startup, public (login) extension registration was awaited
before the auth flow; kick it off in parallel and await it only before
routing, where the login screen actually needs it.

Each serialized call costs a full management-API round-trip, which is
negligible locally but ~150 ms each on high-latency (e.g. Cloud) hosts.

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

* refactor(core): only mark connection connected once both calls succeed

Move isConnected.setValue(true) out of #setStatus() into connect() after
the allSettled check, so the observable never reflects a partially
established connection when configuration fails but status succeeded.

Addresses review feedback on the parallelized connect().

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:20:34 +02:00
bc2048573c Link Picker: Render picked content/media as non-interactive when their workspace URL can't be resolved (closes #22955) (#22964)
* Entity refs render readonly when their workspace URL can't be resolved.
Also fixes name on remove dialog.

* Apply read-only on the picked content ref only in the non-routable link picker

* Address PR feedback: simplify document item resolver guard in the link picker, document the implicit uui-card-media disabled dependency in input-media, and cover the disabled card state with a test.

* Drop out of date comments.

* Simplify updates.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 13:23:48 +00:00
Andy ButlandandGitHub 424209ac06 References: Fix missing node name when content referenced by media or member (closes #22990) (#22965)
Fix missing node name when content referenced by media or member.
2026-05-28 13:34:25 +01:00
Andy Butland 5cf577bf57 Fix broken tiptap reference for storybook build. 2026-05-28 13:32:09 +02:00
ed4b207fe7 Backoffice: Render $index in block detail overlay label (closes #21154) (#22959)
* Render $index in block detail overlay label.

* Cache $index, resolve append sentinel, and cover with unit tests.

* refactor(block): use pipeline for index deduplication and clean up stale observer

* Rename function to remove the unnecessary umb prefix.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 11:15:21 +00:00
Andy ButlandandGitHub 40e027d0ea Content Type Editor: Fix empty Design tab when opened in a modal workspace (closes #22855) (#22954)
* Fix empty content type Design tab when opened in a modal workspace.

* Addressed code review comments.

* Fixed failing E2E tests.
2026-05-28 09:39:11 +00:00
Andy ButlandandGitHub a6f6bdf8bc Backoffice: Hide "Edit permissions" button in create modals from users without Settings section access (closes #22981) (#22984)
* Show the edit permissions for document type button only for users with settings access.

* Fix translation for message (the "Permissions" tab is not called "Structure").

* Addressed code review feedback.
2026-05-28 09:23:26 +01:00
Jacob OvergaardandClaude Opus 4.7 8e6a791de0 Backoffice: Drop redundant search manifest import from Storybook preview
`core/manifests.ts` already imports and spreads `core/search/manifests.ts`
into its aggregate (line 23 + 58), so importing `searchManifests`
separately in `.storybook/preview.js` and spreading it next to
`coreManifests` registered the same manifests twice. Remove the redundant
import and spread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:47:38 +02:00
Jacob OvergaardandClaude Opus 4.7 5ffea3152b Backoffice: Repoint Storybook preview imports at umbraco-package.ts
PR #22957 deleted every package's `manifests.ts` and consolidated the
exports into `umbraco-package.ts`, but `.storybook/preview.js` still
imported from the old paths. The result was a Vite resolve error during
`npm run build-storybook` (first failure: "Could not resolve
../src/packages/block/manifests from .storybook/preview.js").

37 import paths swapped from `…/<pkg>/manifests` to
`…/<pkg>/umbraco-package`. The two packages that still expose their
manifests via a standalone `manifests.ts` — `core` and `core/search` —
are left untouched.

Verified by `npm run build-storybook` — succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:43:38 +02:00
Jacob OvergaardandClaude Opus 4.7 2043ff1dbd Tiptap: Fix dead manifests.js import in the input-tiptap story
PR #22995 added `input-tiptap.stories.ts` with an import from
`'../../manifests.js'`, but PR #22957 (already on release/17.5.0) had
deleted that file and moved the `manifests` array into
`umbraco-package.ts`. The merge into release/17.5.0 didn't catch the dead
import, so Storybook 404s on the story load.

Point the import at the new home — `manifests` is still exported by name,
so this is a one-line path fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:40:17 +02:00
Jacob Overgaard f1bc1db6ce Merge branch 'v17/dev' of https://github.com/umbraco/Umbraco-CMS into v17/dev 2026-05-28 08:27:15 +02:00
Jacob Overgaard c4d5b89fc5 Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-05-28 08:27:04 +02:00
7597a8ad40 Sort Dialog: Show current language node names (closes #22872) (#22948)
* Display variant node name on sort children dialog.

* Preserve user sort order when patching variant names on culture change

Patch names in-place on the existing _tableItems rather than rebuilding
from _children, so a user's drag-sorted or column-ordered arrangement is
not silently reverted if the app culture changes while the modal is open.

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

* Refactor to reduce cyclomatic complexity of #resolveName method.

* Resolve sort dialog variant names and icons via item data resolvers

Replace the inlined variant-name logic in the content sort dialog with the
shared UmbItemDataResolver abstraction, and add UmbMediaItemDataResolver so
media items resolve their active-culture name and icon the same way documents
do. Each content sort entity action now supplies its resolver through manifest
meta, flowing into the modal via a new content-specific modal data type and a
base-action _getModalData() hook. This also removes the previously hard-coded
document icon in the dialog.

* Disable load more when page of items is being retrieved.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 05:50:16 +00:00
d28507e2e5 Migrations: Convert all sibling RTE blocks (closes #22979) (#22980)
* Fix migration of embedded block data when blocks are direct siblings in the 13 RTE source code.

* Apply suggestions from code review to update comments.

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

* Address review: keep RteBlockHelper in original namespace; tidy docs and comment

- Move RteBlockHelper back to Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks
  to avoid a binary breaking change within the obsolete window (scheduled removal in v18).
  Kept as its own file rather than reverting it into LocalLinkRteProcessor.cs.
- Add a <remarks> note on ConvertBlockUdisToKeys explaining that blocks with malformed UDIs
  are dropped rather than preserved.
- Replace the opaque "fix recursive hiccup" comment in LocalLinkRteProcessor with one that
  describes what the line actually does.
- Move RteBlockHelperTests back to mirror the production namespace.

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:45:01 +02:00
7b75324172 Migrations: Convert all sibling RTE blocks (closes #22979) (#22980)
* Fix migration of embedded block data when blocks are direct siblings in the 13 RTE source code.

* Apply suggestions from code review to update comments.

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

* Address review: keep RteBlockHelper in original namespace; tidy docs and comment

- Move RteBlockHelper back to Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks
  to avoid a binary breaking change within the obsolete window (scheduled removal in v18).
  Kept as its own file rather than reverting it into LocalLinkRteProcessor.cs.
- Add a <remarks> note on ConvertBlockUdisToKeys explaining that blocks with malformed UDIs
  are dropped rather than preserved.
- Replace the opaque "fix recursive hiccup" comment in LocalLinkRteProcessor with one that
  describes what the line actually does.
- Move RteBlockHelperTests back to mirror the production namespace.

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:38:56 +02:00
172a3be5ac Repositories: Batch WHERE IN queries to avoid SQL Server 2100-parameter limit (#22987)
* Batch WHERE IN queries to avoid SQL Server 2100-parameter limit and add memory files.

* Drop past-incident references from SQL parameter-limit docs

The memory files should describe the current rule and safe patterns;
specific historical bugs belong in commit history, not CLAUDE.md.

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

* Update comments from code review

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

* Addressed memory file feedback.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 10:44:15 +09:00
ca28195b7c Tiptap: Load enabled extensions in parallel and inline manifest APIs (#22995)
* Tiptap: Load enabled extensions in parallel and inline manifest APIs

Replace the for…of/await loop in umb-input-tiptap's #loadExtensions with
Promise.all over .map, so all enabled Tiptap extension APIs are fetched
in parallel. Configured-extension order in _extensions is preserved.

Inline the first-party Tiptap manifest API references: every
`api: () => import('./X.tiptap-api.js')` and the equivalent toolbar /
statusbar / kind references now use a static top-of-file import and
`api: ClassName`. The dynamic `await import('rich-text-essentials.tiptap-api.js')`
fallback in input-tiptap.element.ts is inlined for the same reason.

External (plugin-supplied) Tiptap extensions and the lazy modal/toolbar
UI element imports are unchanged.

Why: on Umbraco Cloud, opening a document workspace with a rich text
editor takes ~16 s uncached, of which ~14.6 s is a single serial
waterfall — 31 extension APIs fetched one after the other from a
for…of await loop, ~170 ms RTT stacked. Replacing the loop with
Promise.all collapses that to roughly one round-trip; eagerly bundling
the first-party manifests removes the dynamic chunk explosion that made
the waterfall so long in the first place. The toolbar APIs (~20 of them)
already load in a sub-100 ms parallel burst against the same server,
confirming HTTP/2 multiplexing handles bulk parallel requests fine.

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

* Tiptap: Inline element references for toolbar/statusbar/modal/clipboard manifests

Extends the manifest-inlining pass to the remaining `element: () => import(...)`
and runtime API loader sites in the Tiptap package — toolbar/menu/action-button
kinds, the table & character-map & anchor modals, the colour-picker button, the
property-editor configuration UIs, both clipboard translators, the style-menu
kind, and the default toolbar API fallback in tiptap-toolbar.element.ts.

Result on the same Cloud test site (uncached, 17.5-rc):
  Tiptap chunk count: 71 → 4
  Total tiptap bytes: ~3.2 MB → ~3.1 MB (essentially unchanged)
  Phase 5 of the load — the serial extension chain — collapses to a single
  consolidated chunk fetch.

`input-tiptap.element.ts` and `property-editor-ui-tiptap.element.ts` are
intentionally not inlined into anything else: `<umb-input-tiptap>` is a public
element usable standalone (custom dashboards, workspace views), and the
property-editor shell loads via the property-editor UI loader. They remain
exported as their own modules.

CLAUDE.md updated to document the new convention for first-party Tiptap
extensions (direct class refs) and the carve-out for external plugin
extensions that may keep `() => import(...)` to ship their API code in a
separate chunk.

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

* Tiptap: Move extension APIs and elements into a shared lazy boundary chunk

The previous PR collapsed ~70 Tiptap chunks into 3 by inlining first-party API
and element references directly into manifest files. That win came with a real
downside flagged in code review (lke / mra): the API/element implementation
bytes ended up in the manifest registration bundle, so every workspace —
including ones without an RTE — paid ~700 KB of Tiptap code on boot.

This commit keeps the chunk-coalescing win but restores the lazy boundary by
routing every first-party manifest's `api` / `element` reference through a
single shared bundle file `extensions/extension-apis.bundle.ts`. Each manifest
holds a dynamic-import thunk pointing at that one bundle, so:

- Rollup still emits a single chunk for all Tiptap extension code (no chunk
  explosion).
- The manifest registration bundle stays slim — it carries only metadata
  (alias / label / icon / group / kind / forExtensions) plus the thunks.
- The bundle is only fetched the first time `<umb-input-tiptap>` actually
  mounts.

Data-type configuration UIs (`extensions-configuration`,
`toolbar-configuration`, `statusbar-configuration`) read manifest metadata
via `umbExtensionsRegistry.byType(...)` only — they never call
`loadManifestApi` / `loadManifestElement`, so the data-type editor continues
to work without loading any Tiptap implementation code.

Property-editor UI elements (`tiptap-rte`, the three configuration UIs) also
revert to `() => import('./X.element.js')` so each loads on demand from its
own chunk rather than being inlined into the manifest bundle.

`umb-input-tiptap` no longer statically imports the Rich Text Essentials API;
it prepends the alias to the observed list instead, so essentials resolves
through the same lazy bundle as every other extension.

Added a test and stories file that mount `<umb-input-tiptap>` standalone (no
property-editor wrapper) to make the public usage pattern explicit.

Built and verified via `npm run build:for:cms`:
- `dist-cms/packages/tiptap/manifests.js`           48 KB  (eager at boot)
- `dist-cms/packages/tiptap/extension-apis.bundle-*.js` 84 KB  (lazy)
- `dist-cms/packages/tiptap/tiptap-toolbar-element-api-base-*.js` 654 KB
  (lazy dependency of the bundle)
- per-element property-editor UI chunks load on demand when settings open

`npm run check:circular`, `npm run compile`, `npx wtr src/packages/tiptap`
all pass.

Related to #21152, builds on #22995.

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

* Tiptap: Don't mount <umb-input-tiptap> in the standalone test

Mounting the element via fixture() spins up an UmbTiptapRteContext that
consumes UMB_SERVER_CONTEXT. In the unit-test runtime no server context
provider exists, so the context request stays pending. When @open-wc's
fixture tears down at end-of-file the request rejects with
"host disconnected" — surfaced as an unhandled promise rejection that
web-test-runner counts as a fatal runner error, exiting 1 even though every
individual test passed. The rejection happened to be in flight while a
block-grid clipboard test was active in CI, which is why the failure surfaced
there rather than in the tiptap test file itself.

Drop the manifest-registration assertion too — pulling the package-level
`manifests.ts` aggregator triggers a transitive 404 on the
`@umbraco-cms/backoffice/tiptap` importmap entry in the wtr environment.

The class-export + custom-element-registration checks are enough to prove
standalone exportability. The Storybook stories still cover the visual
end-to-end load path.

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-27 14:32:20 +00:00
Jacob Overgaard 4c1fde9e0c Merge branch 'release/17.5.0' into v17/dev 2026-05-27 14:28:03 +02:00
Mads RasmussenandJacob Overgaard f0013330e6 Backoffice: Embed package root manifests into umbraco-package.ts to reduce startup requests (#22957)
* Consolidate block package into index export

* keep umbraco-package.ts and embed manifests instead

* Inline package manifests into umbraco-package

* update docs
2026-05-27 14:26:56 +02:00
Mads RasmussenandJacob Overgaard bd2c985187 Backoffice: Embed implementations directly in core manifests to reduce startup network requests (#22944)
* Use direct imports in core manifests

* Extract theme aliases into constants file

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2026-05-27 14:26:34 +02:00
Mads RasmussenandJacob Overgaard 9711a5d012 Backoffice: Swap relative imports to @umbraco-cms/backoffice module imports in core packages (#22942)
Use @umbraco-cms/backoffice imports

Replace numerous relative/internal import paths with centralized '@umbraco-cms/backoffice' package entry points across core modules.This consolidates exports, simplifies import paths.
2026-05-27 14:24:57 +02:00
Andy ButlandandGitHub 808cba2747 Members: Default Approved to true when creating a member (closes #22991) (#22993)
Default new members created via the backoffice to approved.
2026-05-27 06:59:24 +00:00
Mads Rasmussen da0117f240 Merge branch 'v17/dev' of https://github.com/umbraco/Umbraco-CMS into v17/dev 2026-05-26 09:50:48 +02:00
Jacob OvergaardandGitHub 61d3e4c53d Backoffice: Add Cache-Control headers to cache-busted backoffice assets (AB#68478) (#22951)
* Backoffice: Add Cache-Control headers to cache-busted backoffice assets (AB#68478)

Adds a UseUmbracoBackOfficeCacheHeaders middleware that sets
Cache-Control: public, max-age=31536000, immutable on responses served
from the cache-busted backoffice path (/umbraco/backoffice/<hash>/*).
The hash in the URL is derived from the Umbraco version, so the URL
itself invalidates on every release - making 'immutable' safe regardless
of whether individual filenames contain a content hash.

In debug mode the cache-bust hash changes per request, so the header is
set to 'no-cache' to avoid filling the browser disk cache with single-use
entries.

Design is non-destructive to consumer customisation, addressing the
review feedback on the v14 attempt (#14475):

- Does not touch StaticFileOptions; consumer
  services.Configure<StaticFileOptions>(...) and OnPrepareResponse
  callbacks continue to work unchanged.
- Sets the header via Response.OnStarting with a ContainsKey guard, so
  any synchronous Cache-Control set upstream wins; consumer OnStarting
  callbacks registered later fire first (LIFO) and also win.
- Skips non-2xx responses to avoid long-lived caching of error responses.

Related: GH #21152, PR #22896.

* Backoffice: Correct rationale for no-cache in debug mode

Reword the XML doc on UseUmbracoBackOfficeCacheHeaders to reflect that
IBackOfficePathGenerator is a singleton, so the cache-bust hash is
computed once at startup even in debug mode (per Copilot review on
#22951). The reason for no-cache is not "hash changes per request" but
that built assets may change in place during dev iteration; no-cache
allows fast 304 revalidation while no-store would force full
re-downloads.

No functional change.

* Backoffice: Add unit tests for UseUmbracoBackOfficeCacheHeaders

Covers six scenarios via a minimal in-process pipeline composed with
Microsoft.AspNetCore.TestHost:

- Production: 200 under hash prefix gets immutable header
- Debug: 200 under hash prefix gets no-cache
- Non-2xx under prefix: header not set (status gate)
- Path outside prefix: header not set (path gate)
- Consumer synchronous override: ContainsKey guard skips, consumer wins
- Consumer OnStarting override: LIFO ordering lets consumer win

Adds Microsoft.AspNetCore.TestHost to Umbraco.Tests.UnitTests (standard
Microsoft package, version pinned in tests/Directory.Packages.props).

* Backoffice: Extract cache-headers logic into IMiddleware class

Matches the existing Umbraco middleware convention (BootFailedMiddleware,
PreviewAuthenticationMiddleware, UmbracoRequestMiddleware, etc.) per
Kenn's note: prefer UseMiddleware<T>() with a DI-resolved class over
inline builder.Use lambdas.

The new UmbracoBackOfficeCacheHeadersMiddleware:
- Implements IMiddleware; registered as a singleton in AddWebComponents
- Computes prefix and header value once in the constructor (both
  dependencies are singletons themselves, so this is stable)
- Behaviour is unchanged from the inline version

The UseUmbracoBackOfficeCacheHeaders extension method becomes a thin
UseMiddleware<T>() wrapper. Tests updated to register the middleware in
the TestServer DI container so it can be resolved through UseMiddleware.

* Backoffice: Document IMiddleware convention in Web.Common CLAUDE.md

Adds an explicit "Convention" note before the middleware list so future
contributors (and AI assistants) default to the IMiddleware class +
AddSingleton + UseMiddleware<T>() pattern rather than inline
builder.Use(async ...) lambdas. Also lists the new
UmbracoBackOfficeCacheHeadersMiddleware in the folder structure and
middleware reference.

* Backoffice: Tighten middleware convention note with full corroboration

Lists every IMiddleware implementer in the codebase (10/10) and calls
out the two known inline-lambda exceptions (CspNonceExtensions,
WebApplicationExtensions) so the rule reads as the established
convention rather than an absolute, while still steering new work
toward IMiddleware + AddSingleton + UseMiddleware<T>().

* Backoffice: Register cache-headers middleware in AddBackOfficeCore

DI scope validation runs in Development/CI and pre-checks every
singleton's dependency graph can be constructed. The middleware was
registered in AddWebComponents (which runs for every Umbraco bootstrap),
but its IBackOfficePathGenerator dependency is only registered by
AddBackOffice(). The previous CI run on this branch surfaced the
problem in four Delivery-only/Website-only bootstrap tests
(CoreWithDeliveryApi_BootsSuccessfully, DeliveryOnlyScenario_BootsSuccessfully,
etc.) with "Unable to resolve service for type 'IBackOfficePathGenerator'
while attempting to activate 'UmbracoBackOfficeCacheHeadersMiddleware'".

Move the registration alongside IBackOfficePathGenerator in
AddBackOfficeCore (Api.Management), which is the same scope as the
backoffice itself. This also matches the wire-up gate in
UmbracoApplicationBuilder.cs that only calls UseUmbracoBackOfficeCacheHeaders
when IBackOfficeEnabledMarker is registered.

CLAUDE.md updated with the rule ("register the middleware next to its
dependencies' registration") and a pitfall note about DI scope validation.

* Backoffice: Address review feedback from AndyButland (PR #22951)

- Move UseUmbracoBackOfficeCacheHeadersTests from Umbraco.Tests.UnitTests
  to Umbraco.Tests.Integration. It uses HostBuilder + TestServer to
  exercise the real HTTP pipeline, which is integration-shaped rather
  than unit-shaped. Drop Microsoft.AspNetCore.TestHost from UnitTests
  (Mvc.Testing in Integration provides it transitively) and from
  tests/Directory.Packages.props.
- Soften the misleading "no trailing slash" comment in
  UmbracoBackOfficeCacheHeadersMiddleware — we trim anyway, so the
  comment is now framed as defensive normalisation.
- Trim the dense middleware convention note in Web.Common/CLAUDE.md to
  one paragraph (rule + the two known inline-lambda exceptions). Move
  the DI-scope-validation pitfall narrative out of CLAUDE.md and into a
  three-line code comment next to the AddSingleton call in
  AddBackOfficeCore where it actually applies.

* Backoffice: HTTP verb gate, 304 inclusion, namespace + unused using (PR #22951 review)

Three more from AndyButland's review:

1. Verb gate + 304 inclusion in UmbracoBackOfficeCacheHeadersMiddleware.
   Restrict the path-prefix match to GET and HEAD so POST/PUT/DELETE
   responses and OPTIONS (CORS preflight) responses don't get tagged as
   immutable. Include 304 alongside 2xx in the status gate so
   intermediate caches (CDN/proxy) receive the Cache-Control directive on
   revalidation responses too. Extended the test suite with four new
   cases: NotModifiedResponseUnderPrefix_SetsImmutable,
   HeadRequestUnderPrefix_SetsImmutable,
   OptionsRequestUnderPrefix_DoesNotSetHeader,
   PostRequestUnderPrefix_DoesNotSetHeader. All 10 tests pass.
2. Test namespace updated to Umbraco.Cms.Tests.Integration.* to match
   the convention used by ~629 other files in Umbraco.Tests.Integration
   (vs the 2 outliers I copied from).
3. Drop unused 'using Umbraco.Extensions;' from the test file.

* Backoffice: Extract conditional checks to satisfy CodeScene complexity gate

CodeScene flagged InvokeAsync with "Complex Conditional" (advisory rule,
code health impact 9.69) after the verb + 304 additions in the prior
commit. Extract the two checks into IsCacheableAssetRequest and
ShouldSetCacheControl helper methods. No behaviour change; tests still
green (10/10, 149 ms).
2026-05-26 08:31:10 +02:00
51d70877d1 QA: Stabilise rollback content versioning E2E test (#22975)
* Stabilise rollback E2E test by waiting for document reload before asserting.

* Condense rollback wait comment per code-review feedback.

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

* Addressed code review feedback.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:21:03 +09:00
Andy ButlandandGitHub 2dcfe68208 Backoffice search: Preserve Lucene score order through content-picker lookups (closes #22862) (#22977)
* Preserve Lucene score order through content-picker lookups.

* Removed unnecessary tests.  Addressed code review comments.
2026-05-26 12:15:21 +09:00
ce06c4ba4d Management API: ensure the order from the search endpoints taking a collection of keys is preserved (#22973)
* ensure the order from the search endpoints taking a collection of keys is preserved

* Align cosmetic changes to ensure later merge up doesn't run into conflicts.

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-25 17:32:17 +00:00
Andy Butland a91de6e677 Fix StoryBook build failure following updates in #22957. 2026-05-25 10:20:45 +02:00
faf3824a0a Backoffice: Embed implementations directly in core manifests to reduce startup network requests (#22944)
* Use direct imports in core manifests

* Extract theme aliases into constants file

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2026-05-25 10:07:24 +02:00
Andy Butland 58ed9899be Merge branch 'release/17.5.0' into v17/dev 2026-05-25 08:01:04 +02:00
Engiber LozadaandGitHub bc7bd9a32a Body Layout: Replace overflow: auto with uui-scroll-container (#22950)
* replace overflow: auto with uui-scroll-container in layout components

* Remove stale comment
2026-05-25 07:57:22 +02:00
8160ede4b6 Background Jobs: Refine RecurringBackgroundJobBase API (#22966)
* Add IgnoredDelayChanged event to allow updates during back-off

* Make Period and IgnoredDelay settable on RecurringBackgroundJobBase with auto-raising events

* Address PR review: handle CTS race, restore negative-IgnoredDelay guard, clarify setter remarks

- Swallow ObjectDisposedException in OnIgnoredDelayChanged for the shutdown race where an in-flight handler reads the to-be-disposed CTS via Interlocked.Exchange before Dispose disposes it.
- Restore "skip back-off when IgnoredDelay <= TimeSpan.Zero (and not Timeout.InfiniteTimeSpan)" guard in IgnoreAndWaitAsync to defend against direct IRecurringBackgroundJob implementations / property overrides returning a negative value that would otherwise tight-loop via ComputeNextDelay clamping to zero.
- Add regression test for the negative-IgnoredDelay skip path.
- Mirror the constructor "stored without raising" remark on the Period and IgnoredDelay setter doc comments.

* Dispose newly-installed CTS when shutdown race wins the rotate-and-cancel

* Clarify XML docs.

* Introduce helper for cancellation source rotate and cancel.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-25 05:42:41 +00:00
Mads RasmussenandGitHub e06a583f1a Backoffice: Embed package root manifests into umbraco-package.ts to reduce startup requests (#22957)
* Consolidate block package into index export

* keep umbraco-package.ts and embed manifests instead

* Inline package manifests into umbraco-package

* update docs
2026-05-23 10:09:03 +02:00
Andy Butland 61cc37ad1d Revert "Entity refs render readonly when their workspace URL can't be resolved."
This reverts commit dc7b34eb58.
2026-05-23 09:42:42 +02:00
Andy Butland dc7b34eb58 Entity refs render readonly when their workspace URL can't be resolved.
Also fixes name on remove dialog.
2026-05-23 09:35:42 +02:00
Andy Butland dfe98ffa3b Disable failing link to selected content from link picker launched from RTE.
Also fixes name on remove dialog.
2026-05-23 09:16:47 +02:00
Andreas Lykke BorgandAndy Butland 6b8e8935fd Accessibility: Added missing labels to webhook details and headers (#22918)
* Added missing labels to webhoot details and headers

* Changed toggle label to aria-label to remove visible text
2026-05-22 19:04:41 +02:00
Andy ButlandandSven Geusens 53c74efd35 Migrations: Append data-anchor value to href when missing in local link migration (closes #22860) (#22936)
* Support anchor fragments that are included in the data attribute but missing in the href when migrating local links.

* Addressed code review feedback.
2026-05-22 12:04:12 +02:00
Jacob Overgaard 1c058a32d9 Merge branch 'release/17.5.0' into v17/dev 2026-05-22 11:22:13 +02:00
Andy ButlandandGitHub 12f838277c Migrations: Append data-anchor value to href when missing in local link migration (closes #22860) (#22936)
* Support anchor fragments that are included in the data attribute but missing in the href when migrating local links.

* Addressed code review feedback.
2026-05-22 11:20:05 +02:00
Jacob OvergaardandClaude Opus 4.7 1ae2a780dd Workspace Actions: Restore waiting state for buttons with additional options (closes #18670, #20593) (#22554)
* Workspace Actions: Restore waiting state for buttons with additional options

The waiting state was suppressed whenever a workspace action reported
hasAdditionalOptions() (e.g. Save and publish on multi-variant sites),
so users saw the button jump straight from idle to the success tick
with no in-flight feedback.

Always set 'waiting' on click (unless the action is a link). The
variant-picker modal still opens on top of the button, so the spinner
is effectively invisible during selection — but it becomes visible
as soon as the modal closes and the publish request is in flight.

Fixes #22551

* Workspace Actions: Spin button only while real work is in flight

Replace the eager always-set-waiting behaviour from the previous commit
with an opt-in `isPending` signal so the spinner appears only while
actual work (validation + HTTP) is happening - not while the variant
picker modal is open, and never as a spurious success tick when the
user cancels the modal.

Changes:

- Add optional `isPending: Observable<boolean>` to UmbWorkspaceAction
  and a default UmbBooleanState + protected setPending() on the base
  class. Optional + backwards compatible for external implementers.

- Add optional `onActionStarting` callback (via a shared
  UmbWorkspaceActionExecutionOptions type) to
  UmbPublishableWorkspaceContext.saveAndPublish and
  UmbSaveableWorkspaceContext.requestSave. The document publishing
  context and content detail workspace base invoke the callback at the
  join point right after the variant picker resolves (or is skipped
  for the single-variant case), so it never fires when the modal is
  cancelled.

- Wire the document save and save-and-publish actions to clear pending
  at the start of execute() and pass an onActionStarting callback that
  flips it true when work begins.

- Update the workspace action element to observe api.isPending: when
  the observable is present the waiting state is driven by the
  observable (and the success tick is suppressed if the action
  resolves without ever signalling pending - i.e. a cancellation).
  When the observable is absent the element falls back to the legacy
  eager-waiting behaviour. Failures always surface the failed tick.

Fixes #22551

* Reduce cyclomatic complexity of #onClick and _handleSave

CodeScene Code Health Review flagged two complexity issues:

- UmbWorkspaceActionElement.#onClick reached cyclomatic complexity 9
  (threshold is < 9). Extracted the api-execution branch into a new
  private #runApiAction helper so #onClick collapses to a simple
  link-vs-action dispatch.
- _handleSave was already over the threshold (14); my optional-chain
  callback invocation pushed it to 16. Moved the
  `executionOptions?.onActionStarting?.()` call into a #notifyActionStarting
  helper so the call site is a plain method call and contributes zero
  cyclomatic complexity to _handleSave.

No behavioural change.

* Reduce cyclomatic complexity of #handleSaveAndPublish

Same fix as the previous commit's #notifyActionStarting extraction in
content-detail-workspace-base: move the optional-chain callback
invocation into a private helper so #handleSaveAndPublish stays at its
pre-PR cyclomatic complexity (15) instead of degrading to 17.

No behavioural change.

* DRY: extract notifyWorkspaceActionStarting into a shared utility

Both UmbDocumentPublishingWorkspaceContext.#handleSaveAndPublish and
UmbContentDetailWorkspaceContextBase._handleSave had identical private
optional-chain callback off the host method's cyclomatic complexity.

Replace both with a single exported notifyWorkspaceActionStarting()
utility co-located with UmbWorkspaceActionExecutionOptions. This:

- Removes a duplication point between the two contexts.
- Gives future workspace context implementations a ready-made way to
  honour the optional callback without re-inventing the helper or
  paying the cyclomatic-complexity cost at the call site.

No behavioural change.

* Rename isPending -> isExecuting to mirror the execute() method

Niels suggested correlating the observable's name with the action's
`execute()` method, so the symbol set is now:

- isExecuting (observable on UmbWorkspaceAction interface)
- _isExecuting / setExecuting (UmbWorkspaceActionBase)
- #observeIsExecuting / #executionStarted (workspace-action element)
- isExecutingObserver (observer alias)

Pure rename; no behavioural change.

* Address Copilot review: lazy isExecuting, observer scope, finally reset

Five Copilot findings on PR #22554. Three real regressions + two
contract violations, all addressed:

1. UmbWorkspaceActionBase always exposing `isExecuting` made every
   existing subclass appear to opt in to the new modal-aware flow,
   suppressing waiting/success states for actions that never call
   setExecuting(true). Made `_isExecuting`/`isExecuting` lazy: only
   created on the first setExecuting() call. Opt-in subclasses call
   `setExecuting(false)` in their constructor so the observable is
   exposed before the workspace-action element reads it. Subclasses
   that don't opt in keep `isExecuting` undefined and the element
   falls back to legacy eager waiting feedback.

2. Element observation of `isExecuting` now lives inside #runApiAction
   so it tracks whichever api is actually invoked (`_actionApi ?? #api`),
   correctly handling subclasses like UmbSaveAndPreviewWorkspaceActionElement
   that swap in a different api at runtime. The shared observer alias
   replaces any previous observation on re-clicks.

3. UmbSaveWorkspaceAction and UmbDocumentSaveAndPublishWorkspaceAction
   now wrap their execute() body in try/finally and reset
   setExecuting(false) on completion so the observable honours the
   "true while execute() is performing real work, false otherwise"
   contract instead of getting stuck at true between executions.

No behavioural change for actions that already worked correctly before
this PR; the regression-prone "always exposed" behaviour is gone.

* Address Claude review: Elements gap, type placement, tests + cleanup

Three follow-ups on top of c15eb2d0bc:

1. Elements gap — UmbElementSaveAndPublishWorkspaceAction +
   UmbElementPublishingWorkspaceContext now wire through the same
   onActionStarting/notifyWorkspaceActionStarting handshake as the
   Document equivalents, so multi-variant Elements (Forms, Commerce, etc.)
   get the spinner-after-modal behaviour rather than no spinner at all.

2. Type placement — moved UmbWorkspaceActionExecutionOptions out of
   publishable-workspace-context.interface.ts into its own file so the
   saveable interface no longer has a directional dependency on the
   publishable one. Both peer contexts now import from the same neutral
   location.

3. Unit tests — added blackbox coverage for notifyWorkspaceActionStarting
   (no-op on undefined options/callback, invokes when present) and the
   UmbWorkspaceActionBase.setExecuting lazy-opt-in contract (undefined
   until first call, observable then exposed, value flips, sequential
   emissions, stable reference across calls).

Code-review cleanup applied on the same pass:

- Dropped the redundant `setExecuting(false)` at the start of execute()
  in the save and save-and-publish actions; the finally block plus
  UmbBooleanState's value-dedup already cover idempotency on retries.
- Removed an overlong block comment on `_isExecuting`; the JSDoc on
  setExecuting already documents the lazy/opt-in contract for subclasses.
- Trimmed an internal motivation comment from notify-workspace-action-
  starting.function.ts that referenced cyclomatic complexity.
- Extracted a tiny makeAction() helper in the controller test to remove
  the `{ meta: {} as never }` repetition.

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

* Workspace Actions: Align Save button state with Save & Publish (Andy review feedback)

Two related fixes addressing the variant-Save inconsistency Andy reported:

- Element catch block now only sets `failed` once `#executionStarted` is
  true. Pre-flight rejections (user cancelling a variant-picker modal,
  context-missing throws, etc.) leave the button idle, matching the
  silent-cancel path used by `#handleSaveAndPublish`. Legacy actions
  that don't opt in to `isExecuting` are unaffected because they set
  `#executionStarted = true` eagerly on click.

- `UmbDocumentWorkspaceContext._handleSave` and
  `UmbElementWorkspaceContext._handleSave` now accept and forward the
  `UmbWorkspaceActionExecutionOptions` argument to `super._handleSave`.
  The previous overrides dropped the parameter, so the
  `onActionStarting` callback supplied by `UmbSaveWorkspaceAction` never
  fired - which is why Save showed no waiting/success indicator even
  on a successful submit.

Result: Save and Save-and-publish now behave identically -
cancel = no indicator, submit = waiting then success - for both
invariant and multi-variant documents and elements.

* Docs: Document the modal-aware execution feedback contract for workspace actions

New 'Button state when the action opens a modal' subsection in
docs/workspaces.md explaining the three-piece contract:
UmbWorkspaceActionExecutionOptions + notifyWorkspaceActionStarting +
UmbWorkspaceActionBase.setExecuting. Covers third-party authoring of
modal-aware buttons, the cancel/pre-flight idle behaviour, and the
silent-parameter-drop pitfall on _handleSave overrides.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:00:49 +02:00
Jacob OvergaardandClaude Opus 4.7 04f0e229c7 Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983) (#22896)
* Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.

* Backoffice: Normalise umbraco-package + manifests shapes (AB#67983)

Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

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-22 09:35:44 +02:00
5d76706553 Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983) (#22896)
* Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.

* Backoffice: Normalise umbraco-package + manifests shapes (AB#67983)

Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

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-22 09:28:31 +02:00
Mads RasmussenandGitHub 1fdcb835bc Devops: Report bidirectional import detection for both Core and Packages modules (#22938)
report bidirectional imports for core modules
2026-05-22 07:29:37 +02:00
Mads RasmussenandGitHub 6b3bdb59b7 Backoffice: Swap relative imports to @umbraco-cms/backoffice module imports in core packages (#22942)
Use @umbraco-cms/backoffice imports

Replace numerous relative/internal import paths with centralized '@umbraco-cms/backoffice' package entry points across core modules.This consolidates exports, simplifies import paths.
2026-05-22 07:24:39 +02:00
65ab1c0b2b Background Jobs: Rewrite RecurringHostedServiceBase with SemaphoreSlim and add signalling support (#22331)
* Compute next delay to compensate for time drift

* Use SemaphoreSlim to properly handle exceptions, cancellation tokens and triggering immediate executions

* Add RecurringBackgroundJobBase to contain default values and hide obsoleted method

* Add NextExecutionStrategy parameter to adjust the schedule after triggered executions

* Add TriggerExecution methods to RecurringBackgroundJobHostedServiceRunner

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

* Handle cancellation (application shutdown) and publish RecurringBackgroundJobCanceledNotification

* Match hosted services by Type instead of type name string

* Extract shared helper for TriggerExecution tests

* Clear trigger state when initial delay is interrupted

* Clear _nextExecutionSkipOnOvershoot unconditionally

* Combine ComputeNextDelay tests

* Consolidate trigger state into an immutable record for thread safety

* Use ConcurrentDictionary for thread-safe hosted service lookup

* Remove hosted services from dictionary on stop

* Fix API compatibility errors

* Removed unneeded using.

* Register RecurringBackgroundJobHostedServiceRunner as resolvable singleton

* Remove failed hosted service from dictionary when StartAsync throws

* Use semaphore signaling instead of Task.Delay in trigger tests

Use semaphore signaling instead of Task.Delay in trigger tests 2

* Inject TimeProvider into RecurringHostedServiceBase for deterministic testing

Fix timeprovider

* Use DelayCalculator.GetDelay instead of RecurringHostedServiceBase.GetDelay

* Fix Exception_In_PerformExecuteAsync_Does_Not_Kill_Loop test

* Avoid disposing period-change CTS while wait loop may still reference it

* Configure IEventMessagesFactory mock to return real EventMessages

* Clarify TriggerExecution(TimeSpan) docs and add ChangePeriod test

* Validate period is positive and use GetOrAdd to avoid creating unused hosted services

* Set up Period and Delay on mock job to satisfy constructor validation

* Ensure PeriodChanged event is unsubscribed again

* Fix trigger state race, simplify ReleaseSignal, and add canceled notification test

Fix trigger state

* Use Interlocked for _period reads/writes and implement thread-safe dispose pattern

* Remove hosted service from dictionary before stopping to prevent triggering during shutdown

* Replace Task.Yield with semaphore timeouts in negative assertions

* Tidy RecurringBackgroundJobBase docs and runner error handling

* Wait IgnoredDelay after ignored execution to prevent tight looping when Period is short or zero

* Add IRecurringBackgroundJobTrigger<TJob> for opt-in job triggering

* Register IRecurringBackgroundJobTrigger as open generic and drop AddTriggerableRecurringBackgroundJob

* Fix and add parameter validation

* Allow Timeout.InfiniteTimeSpan as Period for manual-trigger-only recurring jobs

* Migrate built-in jobs to RecurringBackgroundJobBase and require ITriggerableRecurringBackgroundJob in runner trigger overloads

* Support infinite Delay and honor TriggerExecution(TimeSpan) issued during the initial delay

* Handle edge case of backoff via InfiniteTimeSpan.

* Refactored large method.

* Added clarifying documentation.

* Suppress ExecutionContext flow when starting the recurring background loop, restoring previous timer behaviour.

* Relocate Suppress ExecutionContext flow to avoid package validation error.

* Align IRecurringBackgroundJobTrigger generic type constraint with AddRecurringBackgroundJob

* Rename ApplyTriggerState to ComputeNextDelayFromTriggerState

* Allow Timeout.InfiniteTimeSpan as IgnoredDelay to fully disable a job for the remaining application lifecycle

* Fix generic type constraint

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-21 19:39:21 +02:00
a54769758b Background Jobs: Rewrite RecurringHostedServiceBase with SemaphoreSlim and add signalling support (#22331)
* Compute next delay to compensate for time drift

* Use SemaphoreSlim to properly handle exceptions, cancellation tokens and triggering immediate executions

* Add RecurringBackgroundJobBase to contain default values and hide obsoleted method

* Add NextExecutionStrategy parameter to adjust the schedule after triggered executions

* Add TriggerExecution methods to RecurringBackgroundJobHostedServiceRunner

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

* Handle cancellation (application shutdown) and publish RecurringBackgroundJobCanceledNotification

* Match hosted services by Type instead of type name string

* Extract shared helper for TriggerExecution tests

* Clear trigger state when initial delay is interrupted

* Clear _nextExecutionSkipOnOvershoot unconditionally

* Combine ComputeNextDelay tests

* Consolidate trigger state into an immutable record for thread safety

* Use ConcurrentDictionary for thread-safe hosted service lookup

* Remove hosted services from dictionary on stop

* Fix API compatibility errors

* Removed unneeded using.

* Register RecurringBackgroundJobHostedServiceRunner as resolvable singleton

* Remove failed hosted service from dictionary when StartAsync throws

* Use semaphore signaling instead of Task.Delay in trigger tests

Use semaphore signaling instead of Task.Delay in trigger tests 2

* Inject TimeProvider into RecurringHostedServiceBase for deterministic testing

Fix timeprovider

* Use DelayCalculator.GetDelay instead of RecurringHostedServiceBase.GetDelay

* Fix Exception_In_PerformExecuteAsync_Does_Not_Kill_Loop test

* Avoid disposing period-change CTS while wait loop may still reference it

* Configure IEventMessagesFactory mock to return real EventMessages

* Clarify TriggerExecution(TimeSpan) docs and add ChangePeriod test

* Validate period is positive and use GetOrAdd to avoid creating unused hosted services

* Set up Period and Delay on mock job to satisfy constructor validation

* Ensure PeriodChanged event is unsubscribed again

* Fix trigger state race, simplify ReleaseSignal, and add canceled notification test

Fix trigger state

* Use Interlocked for _period reads/writes and implement thread-safe dispose pattern

* Remove hosted service from dictionary before stopping to prevent triggering during shutdown

* Replace Task.Yield with semaphore timeouts in negative assertions

* Tidy RecurringBackgroundJobBase docs and runner error handling

* Wait IgnoredDelay after ignored execution to prevent tight looping when Period is short or zero

* Add IRecurringBackgroundJobTrigger<TJob> for opt-in job triggering

* Register IRecurringBackgroundJobTrigger as open generic and drop AddTriggerableRecurringBackgroundJob

* Fix and add parameter validation

* Allow Timeout.InfiniteTimeSpan as Period for manual-trigger-only recurring jobs

* Migrate built-in jobs to RecurringBackgroundJobBase and require ITriggerableRecurringBackgroundJob in runner trigger overloads

* Support infinite Delay and honor TriggerExecution(TimeSpan) issued during the initial delay

* Handle edge case of backoff via InfiniteTimeSpan.

* Refactored large method.

* Added clarifying documentation.

* Suppress ExecutionContext flow when starting the recurring background loop, restoring previous timer behaviour.

* Relocate Suppress ExecutionContext flow to avoid package validation error.

* Align IRecurringBackgroundJobTrigger generic type constraint with AddRecurringBackgroundJob

* Rename ApplyTriggerState to ComputeNextDelayFromTriggerState

* Allow Timeout.InfiniteTimeSpan as IgnoredDelay to fully disable a job for the remaining application lifecycle

* Fix generic type constraint

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-21 19:36:04 +02:00
Andreas Lykke BorgandAndy Butland 1616997409 Image Cropper: Improve contrast of append label in crop options editor (closes #22878) (#22917)
Improve contrast of input append label in image crops editor
2026-05-21 19:06:01 +02:00
Andreas Lykke BorgandGitHub 609b74b475 Image Cropper: Improve contrast of append label in crop options editor (closes #22878) (#22917)
Improve contrast of input append label in image crops editor
2026-05-21 19:05:33 +02:00
Engiber LozadaandAndy Butland 63289e22cb Block Grid: Fix inline create button width not updating on workspace resize (closes #22527) (#22928)
* Add ResizeObserver for inline create buttons

* Disconnect layout resize observer on destroy

* Initialize ResizeObserver and simplify cleanup

* Remove optional chaining on layout observer disconnect
2026-05-21 18:12:23 +02:00
Engiber LozadaandGitHub 82b2991a18 Block Grid: Fix inline create button width not updating on workspace resize (closes #22527) (#22928)
* Add ResizeObserver for inline create buttons

* Disconnect layout resize observer on destroy

* Initialize ResizeObserver and simplify cleanup

* Remove optional chaining on layout observer disconnect
2026-05-21 18:11:24 +02:00
Andy Butland 721cf53d40 Fix styling of redirect tracker enabled/disabled icon. 2026-05-21 17:59:27 +02:00
Andy Butland f8ba3d8cfc Merge branch 'release/17.5.0' into v17/dev 2026-05-21 17:41:20 +02:00
Andy ButlandandLan Nguyen Thuy 7f832d261d Reset password: Add inline validation messaging for password pattern requirements (#22880)
* add custom validation for password input in reset password

* update remove invalid listeners in disconnectedCallback

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2026-05-21 17:34:05 +02:00
Mads RasmussenandGitHub f79e9586b4 Collections: Replace direct filter pass-through in collection server data sources (#22921)
Pass explicit skip/take to collection services
2026-05-21 09:18:36 +01:00
Mads RasmussenandGitHub c74a58246f Entity Data Picker: Fix "Not Found" in remove dialog for entities without a top-level name (#22915)
* Add item data resolver support to picker data sources

* add js docs

* remove duplicated fallback logic

* wip unit tests of requestItemName method

* Use DocumentVariantStateModel in mock documents to fix compiler

* Update input-entity-data.context.ts

* Update input-entity-data.context.test.ts
2026-05-21 09:13:45 +01:00
nikolajlauridsen 06b15157cf Merge branch 'release/17.4.2' into release/17.5.0
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	version.json
2026-05-21 09:18:11 +02:00
nikolajlauridsen 82f7830d26 Merge branch 'release/17.4.2' into v17/dev
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	version.json
2026-05-21 09:16:39 +02:00
8aaac65f83 Content Editing: Fix save composition values on invariant content and save of default segment (closes #22800, #22865) (#22846)
* Fix edit of a variant property composed to an invariant document.

* Collapse multi-line guard comment to a single line per project policy.

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

* Add unit test coverage for invariant content with a culture-variant composition property.

Adds a third mock document/document-type pair representing an invariant
content type whose flattened property list contains a culture-variant
property (the runtime shape produced when a variant composition is applied
to an invariant content type) and a setPropertyValue test asserting the
value is stored as a culture/segment-invariant entry.

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

* Remove null guard for segment variant documents, as null segment is the default segment.

* update mock data and tests to include real compositions

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-05-20 10:52:09 +02:00
8d5826c61f Content Editing: Fix save composition values on invariant content and save of default segment (closes #22800, #22865) (#22846)
* Fix edit of a variant property composed to an invariant document.

* Collapse multi-line guard comment to a single line per project policy.

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

* Add unit test coverage for invariant content with a culture-variant composition property.

Adds a third mock document/document-type pair representing an invariant
content type whose flattened property list contains a culture-variant
property (the runtime shape produced when a variant composition is applied
to an invariant content type) and a setPropertyValue test asserting the
value is stored as a culture/segment-invariant entry.

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

* Remove null guard for segment variant documents, as null segment is the default segment.

* update mock data and tests to include real compositions

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-05-20 10:43:02 +02:00
Lee KelleherandGitHub 5a73f63cfd feat(components): adds umb-entity-frame component + Storybook stories (#22844)
* feat(components): adds `umb-entity-frame` component + Storybook stories

* fix(components): address review feedback for `umb-entity-frame`

- Remove `pointer-events: auto` from `.tab` so the overlay is truly passive
  (was intercepting events above the parent and causing hover flicker when
  toggled via opacity).
- Replace `--uui-color-surface` tab text with `--uui-color-selected-contrast`
  (the proper paired contrast token) and expose
  `--umb-entity-frame-contrast-color` so consumers can override when supplying
  a non-default `--umb-entity-frame-color`. Fixes contrast in dark and
  high-contrast themes.
- Add `aria-hidden="true"` to `.tab`; the frame is purely decorative and the
  parent owns the real semantics.
- Add a unit test verifying slot content takes precedence over the `label`
  property.

* Removed `aria-hidden` from the label tab

As will need to be used with assistive technologies.
2026-05-20 08:39:49 +00:00
e463cd3a0c Log Viewer: Defensively handle corrupt log files (closes #22820) (#22826)
* Defensively handle log file corruptions by amalgamating errors per file and reporting as warning.

* Addressed code review comments.

* Use local reference to Newtonsoft.Json so it's clear we are only using it for exception handling.

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2026-05-20 07:22:54 +00:00
2901be793a Redirect URL Tracker: Remove ability to toggle from the UI (#22830)
* Remove the ability to enable or disable the redirect tracker from the UI.

* Addressed code review feedback.

* Update OpenApi.json.

* Regenerate backend SDK from updated OpenApi.json

* Update UI to use lozenge status indicator rather than an imperative action.

* Further UX tweak.

---------

Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>
2026-05-20 09:20:37 +02:00
Andy Butland 0b86312f52 Children/Descendants: improve traversal performance (closes #22646) (#22742)
* Add benchmark test for measuring improvements to children and descendant retrieval.

* Remove unnecessary sort from retrieval of children.

* Return the result of the filtered collection of children/decendants without materialising.

* Lazily build property wrappers when materializing IPublishedContent.

* Cache the ordered children list on NavigationNode.

* Cache descendants per parent on the navigation snapshot.

* Add synchronous fast path for retrieved of cached content.

* Additional unit tests.

* Add TODO to make UpdateSortOrder internal.

* Addressed code review feedback.

* Further unit tests.

* Future-proofed code comments.
2026-05-19 18:01:12 +02:00
Andy ButlandandGitHub f4592111fa Children/Descendants: improve traversal performance (closes #22646) (#22742)
* Add benchmark test for measuring improvements to children and descendant retrieval.

* Remove unnecessary sort from retrieval of children.

* Return the result of the filtered collection of children/decendants without materialising.

* Lazily build property wrappers when materializing IPublishedContent.

* Cache the ordered children list on NavigationNode.

* Cache descendants per parent on the navigation snapshot.

* Add synchronous fast path for retrieved of cached content.

* Additional unit tests.

* Add TODO to make UpdateSortOrder internal.

* Addressed code review feedback.

* Further unit tests.

* Future-proofed code comments.
2026-05-19 18:00:34 +02:00
Andy Butland 4c909d8ce8 Merge branch 'release/17.4.1' into release/17.5.0 2026-05-19 17:54:19 +02:00
Andy Butland 12c699d5bd Merge branch 'release/17.4.1' into v17/dev 2026-05-19 17:47:47 +02:00
Jacob Overgaard 1637d9b158 Merge branch 'release/17.5.0' into v17/dev 2026-05-19 10:55:58 +02:00
Jacob Overgaard 7737cd3d40 Localization: Honor DefaultUILanguage on initial load (closes #22808) (#22822)
* Localization: Honor DefaultUILanguage on initial load (closes #22808)

Closes #22808.

Previously, the configured DefaultUILanguage was silently overridden to
'en' at startup because the UmbLocalizationRegistry constructor called
loadLanguage(UMB_DEFAULT_LOCALIZATION_CULTURE) unconditionally. The
configured locale rendered into <html lang="..."> by Razor never had a
chance to flow through to the active language.

Changes:

- localization.registry.ts: stop forcing the active language to 'en'
  in the constructor. Initial state is canonicalised from
  document.documentElement.lang, falling back to 'en' for empty or
  malformed input. The extension filter now always includes the
  default culture alongside the active locale so 'en' translations
  remain available as a key-level fallback regardless of which
  language is active. A synchronous tap mirrors the active locale to
  document.lang and the manager when the state changes, so a fresh
  element rendered between loadLanguage() and the async translation
  load picks up the right language immediately.

- localization.manager.ts: drop the MutationObserver on
  document.documentElement and rely on the registry as the single
  channel for language changes. setActiveLanguage accepts a `silent`
  option so the synchronous tap can update fields without firing a
  consumer notification (translations may still be loading). A new
  notifyLanguageChanged() method is fired by the registry once
  translations are in place.

- app.element.ts: subscribe to umbLocalizationRegistry.currentLanguage
  in connectedCallback and mirror it onto the host element's lang
  attribute, so myApp.lang reflects the source of truth rather than a
  stale snapshot of <html lang>.

- auth.element.ts (login app): same lang subscription, plus after the
  slim backoffice controller registers extensions, prefer the
  visitor's navigator.language if a matching localization extension
  exists (falls through baseName -> language -> en automatically).

Tests: new initialization tests for the registry, manager
setActiveLanguage tests, and the controller tests refactored to use
the new explicit setActiveLanguage API instead of writing directly to
document.documentElement.lang.

* Login: Only override DefaultUILanguage with navigator.language when default has no translation

If the admin sets DefaultUILanguage to a language we have a translation for,
respect that choice over the visitor's browser language. Falling back to
navigator.language only when the configured default isn't available avoids
silently ignoring the admin's explicit setting (e.g., DefaultUILanguage='da-DK'
on a site whose visitor's browser is 'en-GB' should still show Danish).

* Simplify: split setActiveLanguage from notifyLanguageChanged

Drop the silent option in favor of two intent-revealing methods:
setActiveLanguage updates the active language and direction without
side-effects; notifyLanguageChanged tells all connected controllers
to re-render against the current state. Callers compose them based
on what they need (the registry's pipeline updates language sync
then flushes notifications after async translation load).

Also extracts baseLocaleOf() helper, simplifies the navigator.language
match logic in the login app's #applyPreferredLanguage, and removes
narration-style comments in the new code.

* Restore deprecated UmbLocalizationManager.updateAll for backward compat

The old MutationObserver-driven updateAll() field was technically part
of the manager's public surface. Restore it as a deprecated alias that
reads document.lang/dir and forwards to setActiveLanguage + notifyLanguageChanged,
with a runtime UmbDeprecation warning pointing consumers at the new API.

* Fix deprecation removal version to v20 + correct baseLocaleOf JSDoc

Per the deprecation policy in CLAUDE.md (current major + 2): a method
deprecated in v18 must remain through v19 before removal, so the
earliest removal is v20, not v19.

Also corrects the baseLocaleOf JSDoc — Intl.Locale.baseName can include
script subtags (e.g. 'zh-Hant-TW'), not just language and region.

* Scope the active language to the host element, drop navigator.language

- Razor now sets `lang` on `<umb-app>` and `<umb-auth>` from
  DefaultUILanguage. The element passes its lang through on connect, so
  the host owns its own scope — future multi-backoffice scenarios (e.g.
  signing into two Umbraco Cloud sites in the same document) get their
  own language without fighting over a global `<html lang>`.

- The registry no longer reads or writes `document.documentElement.lang`.
  Host elements drive it via `loadLanguage()`; `<html lang>` stays as
  whatever Razor rendered.

- Removed the navigator.language preference detection in the login app.
  Not in scope for the bug fix and adds behavior the admin can't opt out
  of. The existing current-user-locale flow already handles per-user
  preference after login.

- Tests updated to assert on `umbLocalizationManager.documentLanguage`
  instead of `document.documentElement.lang`.

* Set <html lang="en"> to match the static (noscript) text in the templates

The page's `<html lang>` should describe the language of the document's
own innate content. Both Index.cshtml files only contain English static
text (the noscript fallback), so the page-level lang is now "en".

The dynamic UI inside <umb-app> / <umb-auth> carries its own `lang`
attribute (from DefaultUILanguage), which overrides for that subtree —
correct per the HTML spec for language inheritance.

* Drop deprecated UmbLocalizationManager.updateAll

It was public as an artifact of being an arrow function so it could be
passed to a MutationObserver without binding — not because it was
intended as part of the public API. External usage is effectively
zero, and the new explicit setActiveLanguage + notifyLanguageChanged
covers anyone who did reach for it.

* Docs: document active-language-on-host pattern in package-development.md

After PR #22822, the active UI language is driven by the shell elements
(<umb-app>, <umb-auth>) via their own lang attribute, not by <html lang>.
Document that so future contributors don't reach for the global.

* Collapse setActiveLanguage + notifyLanguageChanged into one method

The silent-write path is just `manager.documentLanguage = ...` — no new
method needed; the field is already public and was always writable. The
notify path keeps setActiveLanguage, which now both sets and notifies.

Net: one new public method on the manager instead of two.

* Inline the active-language write in the registry, drop setActiveLanguage

The previous version added setActiveLanguage on the manager as
a 'cleaner API' than direct field writes. But the manager's fields
(documentLanguage, documentDirection, connectedControllers) have
always been public, the registry is the only caller, and the wrapper
was just more public surface to maintain through the eventual
manager/registry collapse.

Net: -70 lines across the test file, no new public methods on the
manager, the registry pipeline writes the fields and iterates the
controllers directly where it would have called setActiveLanguage.

* Document that documentLanguage/Direction are read-only for consumers

Note in JSDoc that the only supported way to change the active language
is umbLocalizationRegistry.loadLanguage(). The fields stay writable for
the registry pipeline (cross-module internal); the comment is here so
the next contributor doesn't reach for them as a shortcut and end up
with the manager state out of sync with what's actually loaded.
2026-05-19 10:52:55 +02:00
139ac6ad72 Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers (#22875)
* Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers

Adds the missing DocumentBlueprintContainer case so the conversion is
symmetric with FromUmbracoObjectType().

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

* Add missing case for MemberTypeContainer.

* Use reflection to ensure other future missed cases are surfaced without having to explicitly extend the tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-19 09:13:52 +02:00
cd4521bd77 Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers (#22875)
* Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers

Adds the missing DocumentBlueprintContainer case so the conversion is
symmetric with FromUmbracoObjectType().

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

* Add missing case for MemberTypeContainer.

* Use reflection to ensure other future missed cases are surfaced without having to explicitly extend the tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-19 09:11:09 +02:00
80e2764eda 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-18 12:06:34 +02:00
d9bb17de2a Relation Type: Migrate custom table collection view to generic table (#22837)
* migrate relation type table collection view to table kind

* update page locator

* Request relations when workspace unique is set

* fix types

* split models

* Potential fix for pull request finding

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>

* Use constant for relation type collection alias + remove redundant fields

* Add observer keys in relation-type workspace view

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 08:26:42 +00:00
72fdf281fd Backoffice: Provide entity context via UMB_ENTITY_CONTEXT in menu components (#22835)
* Use UmbEntityContext for entity actions

* Potential fix for pull request finding

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>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 09:26:07 +02:00
Andy ButlandandGitHub b24c9ba8ac Log Viewer: Updated the saved log viewer searches for new installs to reference Umbraco.Cms instead of Umbraco.Core. (#22843)
* Updated the saved log viewer searches for new installs to reference Umbraco.Cms instead of Umbraco.Core.

* Update mock and default data too.
2026-05-15 08:29:09 +09:00
2377e9a555 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-15 08:28:06 +09:00
Andy Butland f964a18b5b Merge branch 'release/17.5.0' of https://github.com/umbraco/Umbraco-CMS into release/17.5.0 2026-05-14 19:10:30 +02:00
Lee KelleherandAndy Butland 2369f00544 Mocks: Add missing signalR property to mock server configuration response (#22849)
Mocks: Add missing signalR property to mock server configuration response

The GetServerConfigurationResponse type was updated in #22700 to require
a signalR.skipNegotiation property, but the MSW mock handler was not
updated to match, causing a tsc compilation error.
2026-05-14 19:08:11 +02:00
Lee KelleherandGitHub 0f438c551c Mocks: Add missing signalR property to mock server configuration response (#22849)
Mocks: Add missing signalR property to mock server configuration response

The GetServerConfigurationResponse type was updated in #22700 to require
a signalR.skipNegotiation property, but the MSW mock handler was not
updated to match, causing a tsc compilation error.
2026-05-14 17:05:55 +00:00
Sebastiaan Janssen d822518dd3 Core: Preserve path case in ShadowFileSystem (#22838)
* Core: Preserve path case in ShadowFileSystem

ShadowFileSystem stored staged files at their original case via _sfs.AddFile
but tracked them under a lowercased key (NormPath calling ToLowerInvariant).
On Complete(), Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Key)) reconstructed
the staged file's path from the lowercased key, so on case-sensitive file
systems (Linux) File.Move failed with FileNotFoundException whenever a path
contained any uppercase character.

Drop the ToLowerInvariant from NormPath and switch the tracking dictionary
to StringComparer.OrdinalIgnoreCase. Lookups remain case-insensitive
(matching Windows semantics) while the stored key now matches what was
written to disk. IsChild/IsDescendant updated to OrdinalIgnoreCase
StartsWith for consistency.

Added regression test reproducing the original FileNotFoundException with
Views/PageNotFound.cshtml.

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

* Rename regression test to follow Can_ naming convention

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

* Track canonical staged path per shadow node

The case-insensitive node dictionary preserved only the first inserted
key, so re-staging a logical path with a different case (e.g. AddFile
"Views/Foo.cshtml" then "views/foo.cshtml") wrote a phantom second file
to _sfs on Linux while Complete still resolved the original key — leaving
orphaned shadow files and committing stale content.

Track the original-case staged path on each ShadowNode and route all
_sfs operations (AddFile, OpenFile, GetFullPath, GetLastModified,
GetCreated, GetSize, MoveFile, Complete) through that canonical path.
Inner.AddFile on commit still uses the stored dictionary key, so the
destination case in the inner file system is unchanged.

Expanded the regression test to also exercise OpenFile, GetSize and
AddFile against a different-cased path, and to assert that the staged
file is written exactly once.

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

* Make ShadowNode.CanonicalPath non-nullable

Every node now carries the original-case path it tracks, set at construction.
This removes the defensive 'sf.CanonicalPath ?? path' fallbacks at the read
sites (OpenFile, GetFullPath, GetLastModified, GetCreated, GetSize, Complete)
which were unreachable but noise.

The GetCanonicalPath helper is gone; AddFile and MoveFile now use the existing
node variable inline ('sf?.CanonicalPath ?? path' — node can legitimately be
null when staging a path for the first time).

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

* Address Copilot review: normalize delete key, cross-platform test

DeleteDirectory(recursive=false) stored the deletion marker under the
caller-supplied path (which can contain backslashes) instead of the
normalized key, so a follow-up NormPath-based lookup could miss the
deletion and IsChild scans could become inconsistent. Use normPath.

The shadow-second-file assertion in the regression test used
File.Exists on a different-cased path; that returns true on
case-insensitive file systems (Windows / default macOS) regardless of
the actual stored case, so the assertion was platform-dependent.
Replaced it with a directory-count check that's cross-platform.

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

* Add cross-platform regression test so any reversion would be caught on a non-case sensitive file system.

* Cleaned up warnings, obsoletions and comments in the existing tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
(cherry picked from commit abfa8cb144)
2026-05-14 10:42:20 +02:00
abfa8cb144 Core: Preserve path case in ShadowFileSystem (#22838)
* Core: Preserve path case in ShadowFileSystem

ShadowFileSystem stored staged files at their original case via _sfs.AddFile
but tracked them under a lowercased key (NormPath calling ToLowerInvariant).
On Complete(), Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Key)) reconstructed
the staged file's path from the lowercased key, so on case-sensitive file
systems (Linux) File.Move failed with FileNotFoundException whenever a path
contained any uppercase character.

Drop the ToLowerInvariant from NormPath and switch the tracking dictionary
to StringComparer.OrdinalIgnoreCase. Lookups remain case-insensitive
(matching Windows semantics) while the stored key now matches what was
written to disk. IsChild/IsDescendant updated to OrdinalIgnoreCase
StartsWith for consistency.

Added regression test reproducing the original FileNotFoundException with
Views/PageNotFound.cshtml.

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

* Rename regression test to follow Can_ naming convention

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

* Track canonical staged path per shadow node

The case-insensitive node dictionary preserved only the first inserted
key, so re-staging a logical path with a different case (e.g. AddFile
"Views/Foo.cshtml" then "views/foo.cshtml") wrote a phantom second file
to _sfs on Linux while Complete still resolved the original key — leaving
orphaned shadow files and committing stale content.

Track the original-case staged path on each ShadowNode and route all
_sfs operations (AddFile, OpenFile, GetFullPath, GetLastModified,
GetCreated, GetSize, MoveFile, Complete) through that canonical path.
Inner.AddFile on commit still uses the stored dictionary key, so the
destination case in the inner file system is unchanged.

Expanded the regression test to also exercise OpenFile, GetSize and
AddFile against a different-cased path, and to assert that the staged
file is written exactly once.

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

* Make ShadowNode.CanonicalPath non-nullable

Every node now carries the original-case path it tracks, set at construction.
This removes the defensive 'sf.CanonicalPath ?? path' fallbacks at the read
sites (OpenFile, GetFullPath, GetLastModified, GetCreated, GetSize, Complete)
which were unreachable but noise.

The GetCanonicalPath helper is gone; AddFile and MoveFile now use the existing
node variable inline ('sf?.CanonicalPath ?? path' — node can legitimately be
null when staging a path for the first time).

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

* Address Copilot review: normalize delete key, cross-platform test

DeleteDirectory(recursive=false) stored the deletion marker under the
caller-supplied path (which can contain backslashes) instead of the
normalized key, so a follow-up NormPath-based lookup could miss the
deletion and IsChild scans could become inconsistent. Use normPath.

The shadow-second-file assertion in the regression test used
File.Exists on a different-cased path; that returns true on
case-insensitive file systems (Windows / default macOS) regardless of
the actual stored case, so the assertion was platform-dependent.
Replaced it with a directory-count check that's cross-platform.

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

* Add cross-platform regression test so any reversion would be caught on a non-case sensitive file system.

* Cleaned up warnings, obsoletions and comments in the existing tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-14 10:36:47 +02:00
Andy Butland 21ab470d88 Merge branch 'release/17.4.0' into release/17.5.0 2026-05-14 08:31:39 +02:00
Andy Butland 5dd8378c57 Merge branch 'release/17.4.0' into v17/dev 2026-05-14 08:30:01 +02:00
Mads RasmussenandGitHub 8e7440580a User: Delete unused custom table collection view (#22839)
delete unused user table code
2026-05-13 17:36:03 +02:00
Mads RasmussenandGitHub 358d435948 Member Group: Migrate custom table collection view to generic table kind (#22833)
* Migrate member group custom table to use table kind

* Use data-mark collection view selector for member group view
2026-05-13 17:04:22 +02:00
Andreas Lykke BorgandGitHub dcf1595e74 Accessibility: Added missing labels to code block copy button and embedded media URL input (#22825)
* Added label to copy button

* Added label to url input in editor

* Changed term to url

* Removed unnecessary readonly #localize

* Added copied translation key
2026-05-13 14:17:29 +00:00
0add0f5b18 Backoffice: Preserve user-supplied property editor UI group names (closes #22189) (#22196)
* Preserve user-supplied property editor UI group names.

* Add support for localised property editor groups, and use localised values for all core property editors.

* Fixed check to look for '#' as the first character of the provided group name.

* danish translation

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2026-05-13 11:29:51 +02:00
Ronald BarendseandAndy Butland 4921ab9257 SignalR: Mark ServerEventSender as a distributed cache notification handler (#22818)
* Mark ServerEventSender as distributed cache notification handler

* Batch and deduplicate notifications in ServerEventSender

* Introduce IDistributedCacheAsyncNotificationHandler<T> and use it in ServerEventSender

* Add ServerEventSender unit tests and address PR review feedback
2026-05-13 08:29:22 +02:00
Ronald BarendseandGitHub e07f188bd4 SignalR: Mark ServerEventSender as a distributed cache notification handler (#22818)
* Mark ServerEventSender as distributed cache notification handler

* Batch and deduplicate notifications in ServerEventSender

* Introduce IDistributedCacheAsyncNotificationHandler<T> and use it in ServerEventSender

* Add ServerEventSender unit tests and address PR review feedback
2026-05-13 08:26:50 +02:00
Ronald Barendseandleekelleher 63bae5958a User Permission: Re-export fallback condition config type and global augmentation (#22794)
(cherry picked from commit 55fec1dc2a)
2026-05-12 17:15:28 +01:00
Ronald BarendseandGitHub 55fec1dc2a User Permission: Re-export fallback condition config type and global augmentation (#22794) 2026-05-12 17:14:48 +01:00
Andy Butlandandleekelleher 35d726ad31 Sort Children: Show loading state on Sort button (closes #22651) (#22813)
* Add submit button state to sort dialog.

* Guard against re-entrant submit in sort-children-of modal.

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

* Set failed button state when sort-children-of submit throws.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit dfe93c5639)
2026-05-12 17:06:44 +01:00
dfe93c5639 Sort Children: Show loading state on Sort button (closes #22651) (#22813)
* Add submit button state to sort dialog.

* Guard against re-entrant submit in sort-children-of modal.

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

* Set failed button state when sort-children-of submit throws.

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-12 17:04:03 +01:00
Nhu DinhandGitHub 22a7a9577b Build: Updated nightly E2E test pipeline schedule in v17 (#22803)
Updated nightly E2E test pipeline schedule
2026-05-12 15:36:27 +07:00
Kenn JacobsenandAndy Butland fc9ca861b0 Content: Ensure correct variant change tracking when unpublishing variant content (#22799)
* Ensure correct change tracking when unpublishing

* Update src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs

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

* Add comment

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-12 10:01:20 +02:00
c784858b32 Content: Ensure correct variant change tracking when unpublishing variant content (#22799)
* Ensure correct change tracking when unpublishing

* Update src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs

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

* Add comment

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-12 09:58:20 +02:00
Sven Geusensandmole 68194e1a27 Distributed background jobs: Improve gracefull shutdown behaviour (#22796)
* Dont fail silently on missing ambientscope

This makes it in line with other methods in the repo

* Pass on Cancellationtoken to the job to support gracefull job shutdown

(cherry picked from commit 5ae17ace6a)
2026-05-12 09:51:25 +02:00
Sven GeusensandGitHub 5ae17ace6a Distributed background jobs: Improve gracefull shutdown behaviour (#22796)
* Dont fail silently on missing ambientscope

This makes it in line with other methods in the repo

* Pass on Cancellationtoken to the job to support gracefull job shutdown
2026-05-12 09:28:00 +02:00
Andy ButlandandGitHub cd476ab6ed Tiptap RTE: Ignore no-op transactions in onUpdate to prevent phantom dirty state (closes #22767) (#22781)
Ignore Tiptap no-op transactions in onUpdate to prevent phantom dirty state.
2026-05-11 14:13:21 +01:00
Niels Lyngsø 4b66c114c4 Revert "fix validation filter"
This reverts commit 0bdb1bb1ed.
2026-05-10 20:17:36 +02:00
Niels Lyngsø 0bdb1bb1ed fix validation filter 2026-05-10 20:16:23 +02:00
1c1787c445 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:58:19 +02:00
Andy Butland a434ad7b33 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:19:00 +02:00
Andy Butland 3714ebbb29 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:18:09 +02:00
Andy Butland 1214771847 Bump version to 17.6.0-rc. 2026-05-08 10:07:34 +02:00
Lee KelleherandGitHub 396497a921 Documents: Alias DocumentVariantStateModel API model for backoffice client (#22716)
* Client: Aliased `DocumentVariantStateModel` for documents and document-blueprints packages

Hoist `UmbDocumentVariantState` and `UmbDocumentBlueprintVariantState` aliases (re-exporting `DocumentVariantStateModel`) into dedicated `variant-state.ts` leaf files. Internal package modules, mocks and the core split-view selector now consume the alias instead of referencing `DocumentVariantStateModel` directly, mirroring the structure on `v18/dev` to reduce upstream-merge conflicts.

* Revert mock data changes

to prevent importing the whole "document" module.

* Tweaked the `DocumentVariantStateModel` import for mock data

Otherwise this is problematic for cherry-picked commits for v18.0.

* Missed one!
2026-05-08 08:04:07 +00:00
Niels Lyngsø bf32f9e5a6 update package-lock 2026-05-08 09:01:20 +02:00
Niels Lyngsø 3220739faa upgrade to UI LIbrary 1.17.3 2026-05-08 08:59:49 +02:00
Niels Lyngsø ff565b95e0 update package-lock 2026-05-08 08:54:28 +02:00
Niels Lyngsø 93a1f82b05 Merge branch 'release/17.4.0'
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	version.json
2026-05-08 08:53:57 +02:00
Andreas ZerbstandGitHub 54ded689e8 E2E: QA: Add .prettierrc.json to acceptance tests for formatting consistency (#22751)
Add .prettierrc.json to acceptance tests for formatting consistency
2026-05-08 09:19:26 +07:00
Andy ButlandandGitHub 2292b7479d 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:13:05 +02:00
Andy ButlandandGitHub 9b1fc50de3 Dictionary: Order SQL before FetchOneToMany to prevent duplicate items in collection view (closes #22640) (#22750)
* Order SQL before FetchOneToMany in dictionary entry retrieval to prevent duplicate items in collection view.

* Used PrimaryKey instead of UniqueId to take advantage of the clustered index.
2026-05-07 14:29:43 +00:00
3052b57203 E2E: QA: add acceptance tests for content versioning (#22702)
* Added tests

* Updated

* Cleaned up

* Fixes based on comments

* Apply suggestions from code review

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

* Added helpers for verifying document

* Removed redundant method

* Cleaned up

* Reverted deletion of constants

* Undo revert

* Fixes based on comments

* updated command

* Added removed method

* Update smokeTest command in package.json

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 10:13:29 +00:00
aba8e3eb7a Icons: developer icon manager (#22437)
* extend icons with information from theseaurus

* implement new icon search logic

* clean-up data

* icon manager

* 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

* Potential fix for pull request finding

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>

* improve search

* related should not show up in search

* update threshold

* separate name words

* also consider full icon name match

* better comment

* other approach for full name matches

* full icon name search if query contains a -

* fix test

* remove related code

* updates to related

* make its own package

* revert changes

* update tsconfig

* package-lock

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 10:11:05 +00:00
Mads RasmussenandGitHub 040a0d5e49 Document Workspace: Add CRUD and property value tests for document workspace context (#22621)
* temp mock set

* test getPropertyValue

* Extend document workspace context tests to cover read/write property values

* move context files into context folder

* Add document CRUD tests, mock handler & interceptor

* temp mock error interceptor

* Return 404 when document not found

* Use undefined for entity unique state until initialized

* Fix import paths for document workspace editor

* Add test utils and extend document workspace tests

* Update document-workspace-context.test-utils.ts

* Match invariant variant when variantId missing

* Ensure finishPropertyValueChange runs on exit

Wrap setPropertyValue implementation in a try/finally and move finishPropertyValueChange into the finally block so cleanup always runs even if an error is thrown. No other functional changes — code was re-indented and organized but behavior remains the same except for guaranteed cleanup on error.

* Require variantId for culture/segment-variant props

* fix types

* fix mock modal typescript error

* Distinguish unloaded vs root entity unique

* use the real current user context

* hide mock set in UI

* rename mock set

* Move initiatePropertyValueChange into try

* Use 'satisfies' for UmbMockDataSet assertions

* Preserve requested unique on failed load

* Treat missing variantId as invariant

* Reset update lock on destroy

* remove unused group + user

* Guard _current.unmute and remove destroy override

* Add tests for element data manager

* Guard subject access and add destroy test

* Throw when calling methods after destroy
2026-05-07 09:36:05 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jacob Overgaard
ef01edb46a Bump lodash from 4.17.21 to 4.18.1 in /src/Umbraco.Web.UI.Client (#22723)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

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-05-06 16:47:05 +00:00
7c6b755ca5 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:14 +02:00
2ac2b3e4fa Fix main branch after merge issue (#22729)
* Revert "MD files for Design knowledge (#22725)"

This reverts commit 212f3183c1.

* Revert "Backoffice Mocks: Derive user language access from user groups (#22721)"

This reverts commit 9671fec9ad.

* Revert "File-system Services: Complete child scopes on read-miss and validation-failure paths (#22717)"

This reverts commit 489d9ebc2e.

* Revert "manual revert of merge gone wrong"

This reverts commit a443f8ba08.

* Revert "fix(installer-user): added min length message for installer user elem… (#21829)"

This reverts commit 6789d7e757.

* Reapply "Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS into claude/keen-nightingale-5ef5bd"

This reverts commit daecbd02b8.

* fix(installer-user): added min length message for installer user elem… (#21829)

* fix(installer-user): added min length message for installer user element.

* Update src/Umbraco.Web.UI.Client/src/apps/installer/user/installer-user.element.ts

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

* Fix password minlength message binding syntax

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Emma L Garland <1649855+emmagarland@users.noreply.github.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>

* File-system Services: Complete child scopes on read-miss and validation-failure paths (#22717)

* Ensure scopes in FolderServiceOperationBase are completed.

* Added integration tests to verify the fixes.

* Backoffice Mocks: Derive user language access from user groups (#22721)

fix(mocks): derive user language access from user groups

Previously hasAccessToAllLanguages was hardcoded to true and languages to
an empty array. Now both are derived from the user's user group memberships.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* MD files for Design knowledge (#22725)

* Fix issues following merge.

* Fixed linting errors.

* Fix linter errors (2).

* Restore current-user.context.ts

* Restore block-list-entry.element.ts.

* Removed failing webhook repository test files.

---------

Co-authored-by: Yari Mariën <75362020+Yinzy00@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Emma L Garland <1649855+emmagarland@users.noreply.github.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:01:05 +02:00
Niels LyngsøandGitHub 212f3183c1 MD files for Design knowledge (#22725) 2026-05-06 09:26:28 +00:00
Niels Lyngsø 38c68ef384 Merge branch 'v17/hotfix/22472' 2026-05-06 10:39:09 +02:00
9671fec9ad Backoffice Mocks: Derive user language access from user groups (#22721)
fix(mocks): derive user language access from user groups

Previously hasAccessToAllLanguages was hardcoded to true and languages to
an empty array. Now both are derived from the user's user group memberships.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:29:53 +02:00
Andy ButlandandGitHub 489d9ebc2e File-system Services: Complete child scopes on read-miss and validation-failure paths (#22717)
* Ensure scopes in FolderServiceOperationBase are completed.

* Added integration tests to verify the fixes.
2026-05-06 13:32:39 +09:00
Mads Rasmussen 9e34b76bf0 Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2026-05-05 22:29:56 +02:00
Mads Rasmussen a443f8ba08 manual revert of merge gone wrong 2026-05-05 22:29:35 +02:00
6789d7e757 fix(installer-user): added min length message for installer user elem… (#21829)
* fix(installer-user): added min length message for installer user element.

* Update src/Umbraco.Web.UI.Client/src/apps/installer/user/installer-user.element.ts

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

* Fix password minlength message binding syntax

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Emma L Garland <1649855+emmagarland@users.noreply.github.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2026-05-05 22:26:40 +02:00
Mads Rasmussen daecbd02b8 Revert "Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS into claude/keen-nightingale-5ef5bd"
This reverts commit 0c57e304f8, reversing
changes made to 7c7073428d.
2026-05-05 22:12:39 +02:00
Mads Rasmussen 0c57e304f8 Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS into claude/keen-nightingale-5ef5bd 2026-05-05 22:10:55 +02:00
ede972f711 Radio button list: Not saving value on keyboard navigation (closes #22698) (#22699)
Fix radio button list not saving value on keyboard navigation

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
2026-05-05 19:50:59 +00:00
Mads RasmussenandGitHub 2c88f2ae3c Current User: Fix reload not fetching fresh data when entity events fire (#22719)
* Ensure current-user reloads fetch fresh data

* Update current-user.context.test.ts
2026-05-05 21:32:35 +02:00
d40c959be7 Dashboard: Browser title + Hints (#22517)
* View Contexts for Dashboards + Section Views to support Browser Title and Hints

* fix code

* use alias for observe ctrl alias

* remove test code

* Position badge in section icon slot

---------

Co-authored-by: engjlr <enl@umbraco.dk>
2026-05-05 21:26:37 +02:00
77ded81eff Languages: Sort the global content language selector (closes #22628) (#22711)
* Align sorting of content language selector with variant selector.

* Hoist sortLanguages helpers to module scope.

---------

Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-05-05 18:33:07 +02:00
b3a9f86fe0 SignalR: Add configurable transport settings for load-balanced deployments without sticky sessions (#22700)
* WIP

* Cleanup and type generation

* Improve obsoletions

* Fix removed constructor

* Simplify logic because of SignalR's JS limitations

* Apply suggestions from code review

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

* Add SignalRSettings to Schema

* Abstrack SignalRRoutes class

* Fix bool to observable<bool>

* Refactor base class: pull down common service property, make abstract with protected constructor.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-05 14:51:45 +00:00
Andy ButlandandGitHub c7d055a6e2 Caching: Invalidate published content type cache for element types (#22704)
* Ensure content type cache is correctly invalidated for element types.

* Clear key to Id map on clear all.

* Refactor and update tests for additional coverage and naming alignment.

* Updates from code review.
2026-05-05 13:20:37 +02:00
9e930739fb Tags: Close suggestion dropdown on blur and escape (closes #22636) (#22650)
* Close suggestion dropdown on blur and escape, fix suggestion selection

* Fix code complex

* Fix to tab and complexity

* Fix to tab and complexity

* Fix to tab and complexity

* Clear matches on add/escape and remove focus rule

---------

Co-authored-by: engjlr <enl@umbraco.dk>
Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-05-05 09:39:41 +00:00
727581b88b State System: more tests, MD updates and a tiny bit more consistency (#22673)
* unit test for boolean state

* improve umb class state set value identical check

* consistent ability to make a observablePart

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

---------

Co-authored-by: Copilot <copilot@github.com>
2026-05-05 09:33:24 +00:00
b49929af97 Backoffice: Introduce Value Type and Value Summary extensions (#22481)
* Add table collection view and manifests

* Use table kind in collection example

* Update entity-name-table-column-layout.element.ts

* Recompute table rows when item hrefs change

* define and render columns from manifest

* wip language implementation

* map to unique field

* rename to label

* test implementation for users table

* experiment: value minimal display extension

* register as workspace context

* add boolean display

* clean up

* add example entity actions

* add example description

* Update table-collection-view.element.ts

* Omit base 'meta' and relax table meta type

* Hardcode description column when present

* localize column names

* Update table-collection-view.element.ts

* Type manifest on collection view elements

* Use UmbLitElement instead of LitElement

* fix types

* Update entity-name-table-column-layout.element.ts

* provide entity context for each table row

* fix breaking change and introduce a deprecation warning

* Add status column to example collection view + localize column labels

* implement the UmbTableColumnLayoutElement interface

* add tests for the table collection view

* Make host element optional; add table docs/types

* Update controller-host.mixin.ts

* Update src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-table-column-view/entity-actions-table-column-view.element.ts

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

* Add language collection context

* introduction of value type and rename to value summary

* Add core DateTime value summary; migrate user last-login

* remove unused

* Rename user group value type to References

* Remove the component's standalone resolution path

* Add start-node value summaries & sections for user group table

* Guard resolver and render when start node missing

* refactor value-summary resolver, coordinator, and API

* introduce default kind

* remove $ in variable name

* make extension element name more specific to not collide with interface name

* add element base

* move to section module

* return as observable from resolver

* use extension item repository

* prefix start node feature with user

* add value type and value summary for date-time-with-time-zone property editor

* render timezone

* Add fallback render if no extensions can be found

* Add color-picker value summary and types

* add summary for slider + align types

* make manifest prop name more explicit

* align element name with class name

* reorganize

* manually combine imports to decrease the number of dynamic imports

* export as valueResolver instead of api

* Inline default value-summary kind manifest

* Use single raw value in value-summary coordinator

* Render summaries on Document Collection cards

* format date the same way as the property editor

* first iteration of docs and skills

* updates to docs + skills

* render icon for language collection items

* remove test collection manifest

* delete local language table collection view implementation

* implement the get hrefs method in the user group collection context

* Update controller-host.mixin.ts

* Update entity-name-table-column-layout.element.ts

* Update entity-actions-table-column-view.element.ts

* Handle undefined row element in table rendering

Allow onRowRendered to accept an undefined element and clean up row contexts when a row is unmounted. Update the callback signature in table.element.ts and handle the undefined case in table-collection-view.element.ts by destroying the host and removing the stored context for the item to avoid memory leaks when rows are removed.

* Update controller-host.mixin.ts

* remove test registration

* Prefix type in value key generation

* Skip render when boolean value is undefined

* Add JSDoc and reorder imports in coordinator

* fix lint errors

* Update icons.ts

* valueResolver to class in tests

* Update index.ts

* Add value-summary and value-type Vite entries

* Cache table config and column cell elements

* Use localization for user state labels

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-05-05 09:07:39 +00:00
984838433c MD: improve knowledge on get vs consume context (#22676)
* improve Md regarding get vs consume context

* Potential fix for pull request finding

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>

---------

Co-authored-by: Mads Rasmussen <madsr@hey.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 10:14:33 +02:00
Andreas Lykke BorgandGitHub cf2b519ff9 Accessibility: Added missing labels to add property and create new collection (#22701)
Add missing label attributes to form controls
2026-05-05 07:02:52 +02:00
Andy Butland a773ba168b Merge branch 'release/17.4.0' 2026-05-04 23:00:48 +02:00
Engiber LozadaandGitHub 8f6bb64ced Content Workspace: Add variant sync when switching app culture (closes #16853) (#22566)
* Sync workspace URL on language change

* Use template literals for workspace paths

* Move and improve culture URL sync logic
2026-05-04 17:01:24 +00:00
Niels LyngsøandGitHub 4e7bf3c483 Validation: Data lookup mismatch for JSON Path Queries (#22609)
* unit tests to prove issue

* ensure full match for json path filter query

* check for null value

* remove comment

* remove comment

* remove comment
2026-05-04 15:02:39 +02:00
7136e12e54 Property Editor UI Picker: Implement fuzzy search (#22468)
* 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

* implement fuzzy search for property editor UIs

* minor style update

* improve property editor UI search

* improve search

* improve search data for Property Editor UIs

* remove alias search from property editor ui search

* add usage keywords

* Property editor Suggestions based on Property Label

* related should not show up in search

* rename to suggestionQuery

* update threshold

* separate name words

* also consider full icon name match

* better comment

* other approach for full name matches

* full icon name search if query contains a -

* fix test

* cache all tokens as well

* catch rejection

* resolve feedback

* handle rejected promise

* cancel debounce on disconnect

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

* declare voids

* corrections

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

* back out if no tokens

---------

Co-authored-by: Copilot <copilot@github.com>
2026-05-04 12:28:25 +00:00
Nicklas KramerandGitHub f1f215a3a8 User management: Improved error message when deleting active user (closes #22669) (#22687)
* Adding a more detailed error message when deleting a logged in user

* Fixing overlooked integration test

* Fixing enum binary mistake. Appending enum to the end rather than in the middle.

* Introducing better naming for the enum
2026-05-04 12:12:07 +00:00
Niels LyngsøandGitHub 7e675e243b Claude MD: Code Comments (#22690)
* initial commit

* improve docs
2026-05-04 11:45:55 +00:00
5bd0b720a0 Document picker: show ancestor breadcrumb path in document picker search results (closes #22645) (#22649)
* Show ancestor breadcrumb path in RTE picker search results

* hide tree when searching

* use clear localization instead of delete

---------

Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-05-04 11:36:40 +00:00
Nhu DinhandGitHub 1215d83b0f E2E: QA Added acceptance tests for backoffice login, logout and reset password (#22635)
* Added api helper for reset auth state

* Added more constant variables for login and forgot password message

* Added ui helper for login page

* Added api helper for smtp

* Added tests for backoffice login

* Added tests for backoffice logout

* Added tests for forgot password

* Added api helper for user

* Make tests run in the pipeline

* Updated appsetting to enable reset password

* Added more waits

* Added waits

* Updated locator

* Fix flaky tests

* Updated confirmation message

* Fixed comments

* Removed unused code

* Reverted npm command
2026-05-04 10:30:27 +00:00
349e9d1130 Blueprints: Fix intermittent blank workspace when creating documents from blueprints (closes #21996) (#22422)
* Resolve blank workspace when creating documents from blueprints.

* Addressed code review feedback.

* Revert defensive fixes that don't appear to contribute to fixing the bug.

* remove comment

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
2026-05-04 10:28:03 +00:00
0eef8e6b31 Code Quality: Add ModelState validation to BackOfficeLoginController (#22681)
* Add ModelState.IsValid validation in controller action

* Update method documentation and return simple BadRequest response (aligns with other usages, e.g. BackOfficeController.Verify2FACode).

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-04 09:44:50 +00:00
fe413cd0ae Document Type Workspace: Hide non-applicable settings when Document Type is configured as Element Type (#22388)
* Avoid render structure view when element type is active

* Avoid render history clean up when is an element type

* Replace hidden sections with inline "not applicable" message for Element Types

---------

Co-authored-by: Mads Rasmussen <madsr@hey.com>
Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
2026-05-04 09:26:17 +00:00
6ce2ab9fe9 HttpClients: Deprecate unused HttpClient registered with certificate validation bypass (#22684)
Mark HttpClient IgnoreCertificateErrors as obsolete due to security risk and add TODO to remove in a future release

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2026-05-04 10:32:11 +02:00
8d961b1873 Blocks: Adds blockAction extension type (#22459)
* feat(block): add blockAction extension type for extensible block entry actions

Introduce a new `blockAction` extension type that allows both internal and
3rd-party extensions to register actions on block items. This replaces the
hardcoded Delete button on Block List entries with an extension-registered
action, while keeping Edit Content, Edit Settings, and Copy to Clipboard
as slotted content for incremental migration.

The new `<umb-block-action-list>` element owns the `<uui-action-bar>` and
renders a `<slot>` for hardcoded actions followed by extension-registered
`blockAction` extensions, enabling one-by-one migration of actions.

* feat(block): apply blockAction extension to grid, rte, and single block editors

Extend the blockAction pattern to all remaining block entry elements.
Each editor now uses <umb-block-action-list> with slotted hardcoded
actions and the Delete action registered via the extension registry.

* fix(block): render blockAction extensions directly in uui-action-bar

Replace umb-extension-with-api-slot with UmbExtensionsElementAndApiInitializer
to render blockAction elements as direct children of uui-action-bar. This fixes
the border-radius issue where the wrapper element broke :first-child/:last-child
structural selectors used by uui-action-bar for button styling.

* refactor(block): replace showOnReadOnly meta with BlockEntryIsReadOnly condition

Add a new Umb.Condition.BlockEntryIsReadOnly condition that checks the
read-only state from UMB_BLOCK_ENTRY_CONTEXT. This replaces the inline
read-only guard and showOnReadOnly meta flag on the default kind element.

Delete action uses the condition with match: false (hidden when read-only).
Copy to Clipboard has no condition (always visible). 3rd-party actions
opt in to read-only gating by adding the condition to their manifest.

* feat(block): migrate clipboard copy to blockAction extension

Move the Copy to Clipboard action from hardcoded buttons to a registered
blockAction extension across all four block editors. The copy logic is
moved from each entry element into its respective entry context, with a
base copyToClipboard() method on UmbBlockEntryContext.

* docs(block): add plan for migrating Edit Content and Edit Settings to blockAction

* feat(block): migrate Edit Settings to blockAction extension

Replace the hardcoded Edit Settings button with a blockAction extension
using the default kind. The API class provides getHref() for workspace
navigation and getValidationDataPath() for the invalid badge.

Adds getValidationDataPath() to the UmbBlockAction interface and default
kind element, enabling any blockAction to display a validation badge.

Introduces Umb.Condition.BlockEntryHasSettings condition to control
visibility based on whether the block has a settings element type.

* feat(block): migrate Edit Content to blockAction extensions

Split the hardcoded Edit Content button into two blockAction extensions
controlled by manifest conditions:

- Umb.BlockAction.EditContent — navigates to workspace content view,
  shows validation badge via getValidationDataPath()
- Umb.BlockAction.ExposeContent — calls context.expose() when block
  is not yet exposed and content edit is hidden

Adds match support to BlockEntryShowContentEdit condition and creates
a new BlockEntryIsExposed condition at the entry level.

Removes the <slot> from umb-block-action-list — all block entry actions
are now fully driven by the extension registry.

* Removes plan/spec files

* chore(block): address review findings for blockAction feature

- Add TODO comment for stale getHref/getValidationDataPath (I-1)
- Remove orphaned @state() properties from all four entry elements (I-2)
- Add UMB_BLOCK_ENTRY_SHOW_CONTENT_EDIT_CONDITION_ALIAS constant and
  replace string literals in edit-content/expose-content manifests (I-3)
- Change Expose Content weight from 400 to 399 (S-1)
- Add JSDoc to exported types and classes (S-2)
- Fix condition import alias — rename workspace-level to
  UmbBlockWorkspaceIsExposedCondition (S-3)

* fix(block): revert CSS custom property rename to preserve backwards compatibility

Restore the original per-editor CSS custom property names:
--umb-block-list-entry-actions-opacity, --umb-block-grid-entry-actions-opacity,
--umb-block-single-entry-actions-opacity. The action bar opacity styles are
now back in each entry element (using #actions selector), so the unified
property name is no longer needed.

* fix(block): address PR review feedback from Copilot and Claude bots

- Fix Expose button label regression — replace dynamic
  '#blockEditor_createThisFor' (function key) with static '#actions_create'
  so the button no longer renders "Create undefined"
- Guard empty-string href in EditContent and EditSettings actions —
  'workspaceEdit{Content,Settings}Path' emits '' before ready; return
  undefined instead of '' so the button doesn't get href="" (which would
  navigate to the base URL on click)
- Clear _href in default kind api setter — prevents stale href when the
  api is replaced or set to undefined
- Fix barrel imports in 3 block entry conditions — import
  UMB_BLOCK_ENTRY_CONTEXT directly from context-token.js rather than via
  the ../index.js barrel, reducing circular dependency risk
- Make block-action-list reactive to contentTypeAlias changes — the
  extensions initializer is now re-created when unique or
  contentTypeAlias changes, so forContentTypeAlias filters apply
  correctly when contentTypeAlias resolves asynchronously
- Throw in base copyToClipboard() — the default no-op on
  UmbBlockEntryContext now throws rather than logging a warning, so any
  future subclass that fails to override fails visibly

Tests for the new conditions were attempted but deferred to follow-up;
context observable mocking semantics need more investigation.

* fix(block): restore uui-action-bar styling on block-action buttons

Remove the `compact` attribute from the inner `<uui-button>` and bridge
the CSS custom properties set by `uui-action-bar::slotted(*:first-child)`
etc. through `<umb-block-action>`'s shadow DOM via intermediate
`--umb-button-*` variables. Without this bridge, `uui-button`'s own
`:host` declarations shadow the inherited values and the first/last
button border-radius + padding don't apply.

* fix(block): address second-pass PR review feedback

- Throw when RTE editor manifest is missing so clipboard entries are
  never written with an empty propertyEditorUiAlias (would silently
  fail to match on paste)
- Replace bare `return` with `return nothing` in default kind element
  render() for type-level clarity
- Add class-level JSDoc to exported block action classes
  (UmbEditContentBlockAction, UmbEditSettingsBlockAction,
  UmbDeleteBlockAction, UmbCopyToClipboardBlockAction,
  UmbExposeContentBlockAction) and UmbBlockActionDefaultElement

* refactor(block): reduce copyToClipboard complexity per CodeScene feedback

Extract `#buildPropertyValue()` helper in List, RTE, and Single entry
contexts to move the four content/layout/settings/expose ternaries out
of copyToClipboard, lowering its cyclomatic complexity.

Split the compound `||` context guards into sequential early-return
checks so each missing context throws with a specific error message,
and the "Complex Conditional" smell is removed.

* refactor(block): further reduce RTE copyToClipboard complexity

Consolidate three sequential `await getContext(...)` calls into a single
`Promise.all`, dropping the cyclomatic complexity below CodeScene's
threshold of 9.

* refactor(block): extract RTE clipboard write into helper method

Split the post-guard write phase into `#writeClipboardEntry` to bring
both methods well under CodeScene's cyclomatic complexity threshold.

* clean up action

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

* show edit content / settings despite read-only state

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 08:07:16 +00:00
Andy ButlandandGitHub 013d55ef30 Routing: Ensure IPublishedContent.UrlSegment respects umbracoUrlName (closes #22655) (#22663)
* Align obsolete UrlSegment with result of replacement service call.

* Resolved warnings in tests.

* Addressed code review feedback.

* Fix failing integration tests.

* Clarified handling of documents.

* Fix failing unit tests.

* Fixed further faliing integration test.
2026-05-04 10:29:00 +09:00
e414d05b9d Localization: Use invariant culture when parsing node paths (closes #22610) (#22625)
* Use InvariantCulture when parsing node paths.

* Add suggested validation of setup to integration test.

* Add more explicit tests for negative sign handling

---------

Co-authored-by: kjac <kja@umbraco.dk>
2026-05-03 07:40:12 +00:00
Andy ButlandandGitHub 52c133690d Media: Record the trashing user against the History audit entry (closes #22661) (#22668)
Ensure the trashing user for media is associated with the audit log entry.
2026-05-03 09:15:43 +02:00
Niels Lyngsø f61adcc1c0 improve acceptance test 2026-05-01 23:04:23 +02:00
Niels Lyngsø 64525c201f specify app loader + acceptance test queries 2026-05-01 22:13:49 +02:00
Andreas Lykke BorgandGitHub a49008b9f8 Accessibility: Added missing labels to number fields in the settings tab (#22667)
Added missing labels to fix console warning
2026-05-01 17:16:32 +02:00
Laura NetoandGitHub ef5d95a4c2 Merge branch 'release/17.4.0' into v17/hotfix/22472 2026-05-01 15:49:57 +02:00
Niels Lyngsø 06fb49fe7a make unit test only test output 2026-05-01 11:49:33 +02:00
Niels Lyngsø ab8e59a43f remove unused import 2026-05-01 11:44:30 +02:00
Niels Lyngsø 6037f7664c remove trash context for blocks 2026-05-01 11:36:16 +02:00
Niels Lyngsø 41ab7cbbab remove type cast 2026-05-01 11:22:06 +02:00
Niels LyngsøandCopilot 93a2d65702 JSDocs for INVARIANT umbVariantId
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 11:21:37 +02:00
Niels Lyngsø 62436a4c7f resolve load promise feedback 2026-05-01 11:20:48 +02:00
Niels LyngsøandCopilot 424a5060f8 fix typescript typings
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 11:19:23 +02:00
Niels Lyngsø 715831ae2a remove unused import 2026-05-01 11:09:03 +02:00
Niels Lyngsø afa0fab3fb back out if not available 2026-05-01 11:09:02 +02:00
Andreas Zerbst 1845a610a3 Makes helpers more robust by adding a hover step 2026-05-01 11:05:14 +02:00
Niels LyngsøandGitHub 59432bbbed Merge branch 'release/17.4.0' into v17/hotfix/22472 2026-05-01 10:06:03 +02:00
Niels LyngsøandCopilot a00d38eb04 readonly prop for grid,rte,single
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 09:55:15 +02:00
Niels Lyngsø 2a4cdcf884 readonly as view prop 2026-05-01 09:53:44 +02:00
Niels LyngsøandCopilot efc862d301 read-only as view prop for block list
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 09:53:23 +02:00
Niels LyngsøandCopilot 1568589576 is-trashed context + observation
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 09:23:38 +02:00
Niels LyngsøandCopilot e61e0b6f51 revert language readonly rules
Co-authored-by: Copilot <copilot@github.com>
2026-05-01 09:23:27 +02:00
1edf7e9853 Ensure published querying parity between V13 and V17 (#22622)
* Ensure published querying parity between V13 and V17

* Add unit tests for published ancestor path querying

* Fix Claude review comments

* Make Unfiltered() public on the interface

* Explicitly evaluate "unfiltered" items

* A little clean-up

* Add integration tests

* Addressed code review feedback.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-01 09:06:04 +02:00
Niels Lyngsø 68b19a506c assign symbol for is-trashed observer 2026-05-01 08:37:09 +02:00
Kenn JacobsenandGitHub ae2318d8e9 Cache: Do not assume "published" when unpublishing a single culture (#22662) 2026-05-01 05:55:36 +02:00
Andy ButlandandGitHub 728789aaf6 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.
2026-05-01 09:04:22 +09:00
Andy Butland efe1f0fe59 Merge remote-tracking branch 'origin/release/17.3.5' 2026-04-30 15:16:43 +02:00
Andy Butland f4a9310ecc Merge branch 'release/17.3.5' 2026-04-30 15:15:54 +02:00
Niels Lyngsø 151d96f127 load user at the end of loading all package modules 2026-04-30 12:51:30 +02:00
Niels LyngsøandGitHub 1486121ffa V17/hotfix/revert parts of 21982 (#22656)
* do not inherit property write permissions

* revert hidding edit actions
2026-04-30 12:49:10 +02:00
Niels Lyngsø 5cd048fe67 block language access tests 2026-04-30 12:31:06 +02:00
Niels Lyngsø db5bd9ec50 destroy consumer if existing 2026-04-30 10:58:24 +02:00
Niels Lyngsø 0908586e89 update package-lock with version number 2026-04-30 10:21:29 +02:00
Niels Lyngsø c1f7a37d2a comments and todos 2026-04-30 10:17:09 +02:00
Andy Butland e6f53b9d30 Bump version to 17.3.5. 2026-04-30 10:14:57 +02:00
Niels LyngsøandCopilot a1620c9a31 make sure load only calls once
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 10:00:45 +02:00
Niels LyngsøandCopilot b08e23d5ef comment
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 09:53:25 +02:00
Niels LyngsøandCopilot e27c16e1a9 enable routes to be undefined
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 09:45:34 +02:00
Niels Lyngsø 22d12449ac revert 2026-04-29 15:46:15 +02:00
Niels Lyngsø 2174b5f690 Merge remote-tracking branch 'origin/release/17.4.0' into v17/hotfix/22472 2026-04-29 15:34:03 +02:00
Niels Lyngsø 172ea1af59 remove lazy loads from dataSourceDataMapper 2026-04-29 15:32:27 +02:00
Niels Lyngsø d56c57cf2f embed umbraco-packages 2026-04-29 15:31:20 +02:00
Niels LyngsøandCopilot e65bacbdc8 app loader
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 15:23:33 +02:00
Niels Lyngsø ce0f5e77e8 base extension initializer is loaded update 2026-04-29 15:23:27 +02:00
Niels LyngsøandCopilot 69258aadea rename comment
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 14:35:28 +02:00
Niels Lyngsø 4d6b4b187b clean up imports 2026-04-29 14:32:45 +02:00
Niels Lyngsø 402e5dfa90 refactor backoffice -> app 2026-04-29 14:22:47 +02:00
Niels Lyngsø fc93fed936 remove unused imports 2026-04-29 14:14:54 +02:00
Mads Rasmussen 6306f3d4fd Merge branch 'v17/hotfix/22472' of https://github.com/umbraco/Umbraco-CMS into v17/hotfix/22472 2026-04-29 14:11:28 +02:00
Mads Rasmussen 586052bab1 Debounce extension updates and set loaded flag 2026-04-29 14:11:18 +02:00
Niels Lyngsø 87d8cab843 remove await on load for extension initializers 2026-04-29 14:11:08 +02:00
Mads Rasmussen 48973739aa Batch register extensions with validation 2026-04-29 13:58:34 +02:00
Mads Rasmussen 22c7e498d3 move initializer to app element 2026-04-29 13:54:46 +02:00
Andy Butland cc373296df Remove inadvertently committed research files from source control 2026-04-29 13:52:48 +02:00
Lee KelleherandGitHub e37a2919fc Content Rollback: Add notification message meta property (#22631)
* Extends `UmbContentRollbackModalValue` with `UmbEntityModel`

so that the Rollback modal can return the entity-type,
to display the correct notification message.

* Housekeeping

* Added localized fallback key

* Fixed typecasting issue for deprecated Document rollback

* Reverted logic, introduced `rollbackNotificationMessage` meta prop
2026-04-29 12:36:21 +01:00
Nhu DinhandGitHub b23b25163a E2E: QA Added acceptance tests for audit trail in content (#22479)
* Added constant variables for audit trail

* Added ui helper for audit trail

* Added tests for audit trails in content

* Added test for audit trail when trash content

* Added tests for audit trail when sort. move and rollback content

* Added tests for audit trail when bulk actions

* Updated tests for creating content

* Fixed comment
2026-04-29 17:37:21 +07:00
df3cd50e7f 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>
2026-04-29 11:06:38 +02:00
Niels LyngsøandCopilot 044950e0a4 await load all bundles
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 10:32:32 +02:00
Niels Lyngsø 2572f6f0b5 leave unregistere out 2026-04-29 09:44:09 +02:00
Niels Lyngsø 89f5e49293 package name for code editor 2026-04-29 09:41:57 +02:00
Niels Lyngsø 323a731ed1 refactor package registration logic 2026-04-29 08:59:26 +02:00
Niels Lyngsø 24177dc62d add comment 2026-04-28 16:22:48 +02:00
Niels Lyngsø 7b351b199c do not react to not existing user-data or missing context 2026-04-28 16:22:38 +02:00
2f52b7b2b8 Redirect Url Management: Implement workspace (#22624)
* add redirect tracking workspace

* change weight to match v13 order

* add missing alignment and text colour

* Align closer with referency by element

* Ad repository pattern from review

* remove obsolete

* Update src/Umbraco.Web.UI.Client/src/packages/documents/documents/redirect-management/info-app/document-redirect-management-workspace-info-app.element.ts

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

* Adds JSDocs

* Removed unused `created` and `documentUnique` from `UmbDocumentRedirectUrlModel`

* Align `setStatus` and `delete` return shape with other data source methods

* Align workspace context observer with sibling info-app pattern

* Polish dashboard and info-app: localize hardcoded strings, tidy templates and imports

* Apply review simplifications

- Drop duplicate `unique` guards from data source (kept at repository boundary)
- Drop unnecessary `?? []` fallbacks (`items` is non-nullable in the API type)
- Localize hardcoded zero-results strings in dashboard
- Simplify redundant length check in info-app `#getTargetUrl`
- Drop unused `userIsAdmin` from `UmbDocumentRedirectStatusModel`

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-04-28 14:22:02 +00:00
Niels Lyngsø f30178ebc5 import directly 2026-04-28 16:21:41 +02:00
Niels Lyngsø cd0a8b2478 null ctrl alias for constructor initiated observations 2026-04-28 14:56:28 +02:00
Andreas Zerbst 9bdc0709cc Updated tests to make them less fragile 2026-04-28 13:05:58 +02:00
Andreas Zerbst 632b0ae099 Updated locator to use new data-mark 2026-04-28 13:05:32 +02:00
Mads RasmussenandGitHub 3991c95c45 Current User: Reload when the current user or their groups change (#22623)
* Add action event listeners to current-user context

* Add current-user.context tests

* Update current-user.context.test.ts

* Debounce current user reloads caused by events
2026-04-28 11:52:12 +01:00
d467d57198 Rich Text Editor: Mark as supports read only (#22600)
* Mark RTE as supports read only

* RTE: Address read-only review feedback

- Remove `pointer-events: none` from `:host([readonly])` so users can select and copy text in read-only mode
- Make the editor's editable state reactive to the `readonly` property via `setEditable`
- Skip rendering the statusbar in read-only mode (mirrors the toolbar) to avoid the missing border-radius regression
- Remove the now-unused `readonly` property from `umb-tiptap-toolbar` and `umb-tiptap-statusbar`

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-04-28 10:49:34 +00:00
Niels Lyngsø 4df3fc7867 layout-headline 2026-04-28 12:44:02 +02:00
6bd3edeea3 Current User: Adds Current User workspace modal (#22268)
* init current user workspace

* adding current user workspace and their apis

* add new controllers

* add default implementation

* Update src/Umbraco.Core/Services/UserService.cs

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

* Update src/Umbraco.Cms.Api.Management/ViewModels/User/UpdateCurrentUserRequestModel.cs

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

* Update tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Update.cs

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

* Update src/Umbraco.Core/Models/CurrentUserUpdateModel.cs

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

* update openApi.json, remove userKey from model, remove redundant authentication check from controllers

* update localize

* allow blob: URLs in img-src CSP for avatar

* save image change later

* Remove references to "current" user from service layer.
Align validation for user profile update with update user service method.
Controller tidy-up of dependencies.

* Add missing controller from last commit.

* resolve conflicts 2

* Renamed/relocated "current-user-workspace" to "profile/edit"

Refactored the "Edit" (profile) button logic,
to handle the check whether the user has access to the Users section.

* Removed the "Section User No Permission" condition

as no longer used.

* UI tweaks + streamlining

* Profile edit: surface save errors and avoid blob URL leak

- Show danger notification when avatar upload/delete or profile update fails
- Refresh current user after avatar upload so the store holds server URLs, not a leaking local blob
- Element save() methods now return boolean; modal keeps itself open when a save fails and no longer double-submits

* Refactored to use `asPromise()`

* Current User: Adapt edit-profile modal into a workspace extension

Replaces Umb.Modal.CurrentUserEditProfile with a workspace registered
against entityType 'current-user'. The UmbSubmittableWorkspaceContextBase
subclass owns the editable user model and pending avatar state; submit()
coordinates uploadAvatar / deleteAvatar / updateProfile and throws on
failure so the workspace stays open, relying on the repository's existing
danger notifications.

The current-user "Edit" action now opens UMB_WORKSPACE_MODAL (sidebar,
small) instead of the bespoke modal. Avatar and settings children become
presentational views wired to the workspace context.

* Current User workspace: Address review findings

- Await initial load promise in submit() to prevent a race where the save
  action fires before the first requestCurrentUser() resolves.
- Guard the avatar element's async observer setup against post-disconnect
  attachment.
- Document the split between #data (editable persisted state) and
  #pendingAvatar (transient UI state) in the workspace context.
- Remove stray JSDoc whitespace in current-user.server.data-source.ts.

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-04-28 11:18:13 +01:00
57b063f3f4 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 09:33:59 +00:00
122e1a94d9 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 08:55:42 +00:00
Niels Lyngsø b4e4a6db25 apply entity-type to the workspace data-mark 2026-04-28 10:32:00 +02:00
Andy ButlandandGitHub b67ee798d5 Integration tests: Tolerate deadlocks in concurrent external login test (#22583)
* Prevent Concurrent_Save_Same_Login_Should_Not_Throw_Duplicate_Key_Exception from failing when exceptions other than what is being guarded against are triggered.

* Addressed code review feedback.
2026-04-28 09:49:22 +02:00
Andreas ZerbstandGitHub 2aa40e7629 E2E: QA: fixed outdated acceptance tests to match frontend changes (#22590)
* Updated helpers

* Updated test
2026-04-28 07:19:16 +00:00
MoleandGitHub fcf5af3d16 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
2026-04-28 10:06:25 +09:00
03cfdb6480 Collection: Add table kind collection view (#22163)
* Add table collection view and manifests

* Use table kind in collection example

* Update entity-name-table-column-layout.element.ts

* Recompute table rows when item hrefs change

* define and render columns from manifest

* wip language implementation

* map to unique field

* rename to label

* test implementation for users table

* clean up

* add example entity actions

* add example description

* Update table-collection-view.element.ts

* Omit base 'meta' and relax table meta type

* Hardcode description column when present

* localize column names

* Update table-collection-view.element.ts

* Type manifest on collection view elements

* Use UmbLitElement instead of LitElement

* fix types

* Update entity-name-table-column-layout.element.ts

* provide entity context for each table row

* fix breaking change and introduce a deprecation warning

* Add status column to example collection view + localize column labels

* implement the UmbTableColumnLayoutElement interface

* add tests for the table collection view

* Make host element optional; add table docs/types

* Update controller-host.mixin.ts

* Update src/Umbraco.Web.UI.Client/src/packages/core/entity-action/global-components/entity-actions-table-column-view/entity-actions-table-column-view.element.ts

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

* Update controller-host.mixin.ts

* Update entity-name-table-column-layout.element.ts

* Update entity-actions-table-column-view.element.ts

* Handle undefined row element in table rendering

Allow onRowRendered to accept an undefined element and clean up row contexts when a row is unmounted. Update the callback signature in table.element.ts and handle the undefined case in table-collection-view.element.ts by destroying the host and removing the stored context for the item to avoid memory leaks when rows are removed.

* Update controller-host.mixin.ts

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2026-04-27 20:04:09 +02:00
Andreas Zerbst e4c89092e2 Block Workspace: Add data-mark for acceptance test locator 2026-04-27 13:07:33 +02:00
Niels Lyngsø f292972078 offset condition 2026-04-27 12:27:47 +02:00
Niels Lyngsø cfe5ea4a5f improve switch condition 2026-04-27 12:24:25 +02:00
Niels Lyngsø de01efe718 fix test 2026-04-27 12:24:16 +02:00
Mads Rasmussen c364d0b629 Update base-extension-initializer.controller.ts 2026-04-27 11:12:32 +02:00
Mads Rasmussen 427b32fbd4 move block language access controller to block package 2026-04-27 10:11:31 +02:00
Andy ButlandandGitHub a832090c80 Migrations: Align type attribute casing in locallink migration for integer-based legacy links (closes #22597) (#22599)
* Align GUID-via-UDI and integer locallink sources in migration to consistent type attribute casing.

* Handle Pascal cased type attributes from local links.
2026-04-27 09:31:59 +02:00
Andy ButlandandGitHub d490554458 Public Access: Honour custom IMemberGroupService in backoffice dialog (closes #22580) (#22588)
Use IMemberGroupService for public access group selection and rendering.
2026-04-27 06:48:24 +02:00
Andreas Lykke BorgandGitHub 9c0c301a26 Link picker: Added swedish translations for link picker (closes #22542) (#22596)
Added swedish translations for link picker
2026-04-26 15:09:52 +02:00
cb1bebff91 Variant-Selector: improve visual alignment for segments (#22605)
* improve visual alignment for segments

* remove expand area for segments

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

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:43:29 +02:00
Andy ButlandandGitHub e6cba5ed09 Health Check: Add check for untrusted database constraints on SQL Server (#22592)
* Add healthcheck for verification of trusted database constraints.

* Re-use the SQL and DTO between the migration and healthcheck.
2026-04-26 09:50:31 +02:00
a056da9c85 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-24 21:23:59 +00:00
Niels Lyngsø 0f2ffb96a8 more variantId tests 2026-04-24 20:28:21 +02:00
Niels Lyngsø d6e5ff11d0 more guard unit tests 2026-04-24 20:22:12 +02:00
Niels Lyngsø 2415d72651 unit test for reactive fallback feature 2026-04-24 19:38:27 +02:00
Niels Lyngsø 831593c740 remove as const 2026-04-24 19:08:38 +02:00
Niels Lyngsø 83dd3e258e mark as readonly and make js-const 2026-04-24 19:08:03 +02:00
Niels Lyngsø c8b08f76ab remove style import 2026-04-24 19:07:54 +02:00
Niels Lyngsø 41a6bf3c83 add comment for clarification 2026-04-24 19:07:46 +02:00
Niels Lyngsø ca73e81b3c revert removal of || this._isReadOnly check for component rendering 2026-04-24 18:47:11 +02:00
Niels Lyngsø 03a63364ef prevent cancelled context get to cause problems 2026-04-24 17:25:11 +02:00
Niels Lyngsø d127289031 observe fallback for property + name guards 2026-04-24 17:02:32 +02:00
Niels Lyngsø 0c55587c7e no if sentence 2026-04-24 17:02:10 +02:00
Niels Lyngsø 31dfd52b72 todo comments for future 2026-04-24 16:52:02 +02:00
Niels Lyngsø ba80e12f6c observe readOnly languages 2026-04-24 16:51:19 +02:00
Niels Lyngsø a2187801ce make guard fallback reactive 2026-04-24 16:51:04 +02:00
Niels Lyngsø 7de9a853c0 read-only tag for Block Workspace 2026-04-24 16:05:21 +02:00
4596b36ab0 Member surface controllers: Add XML documentation and unit test coverage (#22584)
* Add XML header comments and unit tests for member operation surface controllers.

* Addressed code review feedback.

* Further code review feedback.

---------

Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>
2026-04-24 13:44:00 +00:00
Niels Lyngsø cccfc33977 inherit readOnly state when block workspace is invariant 2026-04-24 15:13:36 +02:00
Niels Lyngsø 9dcc2e9c15 set fallback on readOnly 2026-04-24 14:57:15 +02:00
Niels Lyngsø dee32a4171 RTE: set manager readOnly 2026-04-24 14:56:53 +02:00
56cb682c99 Add a constant for the "unroutable content" route (#22593)
* Add a constant for the "unroutable content" route

* Add one more constant for URL provider exceptions

* Update src/Umbraco.Core/Routing/UrlProviderExtensions.cs

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

* Update src/Umbraco.Core/Constants-Routing.cs

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

* Update src/Umbraco.Core/DeliveryApi/ApiContentRouteBuilder.cs

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

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-24 12:53:10 +00:00
Niels Lyngsø fbd6c61225 rename file in manifest 2026-04-24 13:19:24 +02:00
Niels Lyngsø c5425fe641 Revert "transform access context into local controller"
This reverts commit 1a83d9586b.
2026-04-24 13:18:09 +02:00
Niels Lyngsø 409c098acd strict compare on config object level, to cover multiple conditions of the same alias. 2026-04-24 11:39:00 +02:00
Niels Lyngsø 570597b78e update js docs 2026-04-24 11:37:28 +02:00
Niels Lyngsø 443b50b2eb simplify match 2026-04-24 11:36:19 +02:00
Niels Lyngsø 7b613a35fc re-introduce submit create button 2026-04-24 11:35:21 +02:00
c8ed4c1d3f Handle "broken" ancestor publish path in legacy routing (#22586)
* Handle "broken" ancestor publish path in legacy routing

* Update tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs

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

* Additional tests to validate handling of broken publish ancestor chain for invariant content

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-04-24 10:54:41 +02:00
Niels Lyngsø 1a83d9586b transform access context into local controller 2026-04-23 20:43:58 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jacob Overgaard
51efbac3ea Bump the npm_and_yarn group across 3 directories with 3 updates (#22578)
* Bump the npm_and_yarn group across 3 directories with 3 updates

Bumps the npm_and_yarn group with 1 update in the /src/Umbraco.Web.UI.Client directory: [uuid](https://github.com/uuidjs/uuid).
Bumps the npm_and_yarn group with 1 update in the /src/Umbraco.Web.UI.Client/src/packages/core directory: [uuid](https://github.com/uuidjs/uuid).
Bumps the npm_and_yarn group with 2 updates in the /src/Umbraco.Web.UI.Login directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) and [handlebars](https://github.com/handlebars-lang/handlebars.js).


Updates `uuid` from 13.0.0 to 14.0.0
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0)

Updates `uuid` from 13.0.0 to 14.0.0
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v13.0.0...v14.0.0)

Updates `vite` from 7.3.1 to 7.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

Removes `handlebars`

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 14.0.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: uuid
  dependency-version: 14.0.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: handlebars
  dependency-version: 
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

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

* deps: pin @hey-api/openapi-ts to specific version for Login

* deps: use latest Vite on the v7 line to avoid breaking runtime changes

---------

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-23 13:37:02 +00:00
Niels Lyngsø 52c29e3105 revert logic 2026-04-22 22:36:57 +02:00
Niels LyngsøandGitHub 5117e1ee24 Merge branch 'main' into v17/hotfix/22472 2026-04-22 22:34:23 +02:00
Niels Lyngsø 21dd725bc2 clean up 2026-04-22 22:31:07 +02:00
Niels Lyngsø 2811758e3f clean up 2026-04-22 22:29:02 +02:00
Niels Lyngsø 3878bb2009 unit test for the actual problem 2026-04-22 22:27:51 +02:00
Niels Lyngsø d3526d3448 clean up 2026-04-22 22:27:29 +02:00
Niels Lyngsø e9f85e1569 fix and clean-up 2026-04-22 22:10:51 +02:00
Niels Lyngsø cffac815f1 improve life cycle for extension initializer 2026-04-22 21:38:21 +02:00
Niels Lyngsø 8f1d4c49fc revert 2026-04-22 17:37:14 +02:00
Niels Lyngsø 2ff73f55a4 make isPermittedForObservableVariant return undefined in bad case 2026-04-22 17:36:03 +02:00
Niels Lyngsø 4679d9df77 simplify document-block-property-level-permissions 2026-04-22 17:35:14 +02:00
Niels Lyngsø 2cb015f42b Merge branch 'main' into v17/hotfix/22472 2026-04-22 12:32:21 +02:00
Niels Lyngsø 651574db44 setup read only state based on user permissions 2026-04-22 12:31:50 +02:00
Niels Lyngsø e49387cb35 no need for async 2026-04-22 12:31:26 +02:00
Niels Lyngsø 7351409b35 stop inheriting read only 2026-04-22 12:31:13 +02:00
Niels Lyngsø 49f8aab7ae parse readonly state, without variant ids as origin is the property read-only state 2026-04-22 08:16:26 +02:00
Niels Lyngsø dc2b471e4c INVARIANT variant id as static 2026-04-22 08:15:46 +02:00
Niels Lyngsø f67135a30f keep rendering edit in read-only mode 2026-04-21 13:52:51 +02:00
Mads RasmussenandClaude Sonnet 4.6 7c7073428d qa(backoffice): add client-side tests for UmbWebhookCollectionRepository
Covers requestCollection with shape validation and pagination behaviour
(take, skip, consistent total) using the kitchen sink mock set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 20:45:55 +02:00
Mads RasmussenandClaude Sonnet 4.6 e79f05e8f5 qa(backoffice): add client-side tests for UmbWebhookDetailRepository
Covers createScaffold, requestByUnique, create, save, and delete using
the kitchen sink mock set and MSW-intercepted webhook endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 20:32:36 +02:00
Mads RasmussenandClaude Sonnet 4.6 e1567d6c20 qa(backoffice): add client-side tests for UmbWebhookItemRepository
Uses the kitchen sink mock set to test requestItems and items against
the MSW-intercepted webhook item endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:12:48 +02:00
Niels Lyngsø 50c5d4eabb remove inheritance of readonly state 2026-04-17 16:36:46 +02:00
1384 changed files with 43664 additions and 7771 deletions
+135
View File
@@ -0,0 +1,135 @@
---
name: umb-release-notes
description: Improve a set of auto-generated GitHub release notes for an Umbraco CMS release. Cross-checks the notes against every PR carrying the release label, adds any that are missing, re-files every PR under the most appropriate category, and strips purely-internal entries. Use whenever the user asks to tidy up, improve, complete, or recategorize release notes for a given version, or mentions a release-notes text file plus a version number.
argument-hint: <version> <path-to-generated-notes-file>
---
# Umbraco CMS - Improve Release Notes
Takes a file of auto-generated GitHub release notes and produces an improved version that:
1. **Is complete** — every merged PR carrying the `release/<version>` label appears.
2. **Is well-categorized** — every PR sits under the most appropriate heading.
3. **Is free of noise** — purely-internal entries of no value to a reader are removed.
The result is written to a **new** file alongside the input, so the user can diff the two.
**Run autonomously.** Do NOT use `AskUserQuestion` once the required arguments (version and input file path) are available — only ask if one of them is missing from `$ARGUMENTS` and cannot be inferred (see Arguments). Beyond that, make the categorization calls yourself using the rules below; if a handful are genuinely borderline, place them anyway and note the borderline ones in your closing summary so the user can override.
## Arguments
`$ARGUMENTS` contains two values:
1. **Version** — e.g. `17.5.0`, `18.1.0`. The GitHub label to search is `release/<version>` (so version `17.5.0` → label `release/17.5.0`).
2. **Input file path** — full path to the text file holding the auto-generated notes (e.g. `C:\Temp\release-17.5.0-rc.md`).
If either is missing, ask the user once for the missing value, then proceed.
## Prerequisites
Run `gh auth status`. If it fails, tell the user to authenticate `gh` (e.g. `gh auth login`) and stop — the skill needs the GitHub CLI to query PRs. The repo is always `umbraco/Umbraco-CMS`.
## Procedure
### 1. Read the input notes
Read the input file. Note its structure — it is GitHub's generated format:
- A leading HTML comment (`<!-- Release notes generated ... -->`).
- A `## What's Changed` heading followed by `### <emoji> <Category>` sub-headings, each with `* <title> by @<author> in <url>` bullets.
- A trailing `## New Contributors` section and a `**Full Changelog**: ...` line.
Extract the set of PR numbers already present (parse the `/pull/<number>` from each bullet). Preserve each existing bullet's **exact text** (title, author, URL) when you re-emit it — only its category placement may change.
### 2. Fetch every labelled PR
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 \
--json number,title,author,labels,mergedAt \
--jq '.[] | select(.mergedAt != null) | "\(.number)\t\(.author.login)\t\([.labels[].name] | join(", "))\t\(.title)"' | sort -n
```
This is the authoritative list of what the release *should* contain. Each row gives number, author, labels, title.
**Guard against silent truncation.** `gh pr list` caps at `--limit` without warning, so a large release could drop the overflow and the skill would still look "complete". Count the returned rows and compare against the limit:
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 --json number --jq 'length'
```
If this equals 1000, the limit was hit — raise `--limit` and re-fetch before continuing. Do **not** proceed on a truncated list.
### 3. Reconcile
- **Missing labelled PRs** (labelled but not in the input file): these must be **added**. Build a bullet as `* <title> by @<author> in https://github.com/umbraco/Umbraco-CMS/pull/<number>`.
- **Author handle.** `<author>` in the template is the raw `.author.login` value — the bullet supplies the leading `@`, so do not prepend another. `gh`'s `.author.login` already returns bot accounts with the `[bot]` suffix as part of the login — Dependabot comes back as `dependabot[bot]`, not `dependabot` or `app/dependabot` (the `app/` form only appears in git committer metadata and CODEOWNERS, never in `gh`'s JSON). So the login is already in the right shape; use it verbatim (e.g. `.author.login` of `dependabot[bot]` renders as `@dependabot[bot]`, matching what GitHub's generator wrote for the existing bullets). The only thing to guard against is accidentally stripping or altering the `[bot]` suffix.
- **PRs in the file but not labelled**: keep them. The generated notes span a commit range (see the `Full Changelog` compare link), so they legitimately include backports / earlier-version PRs that lack the current label. For any of these you need to categorize, fetch its labels with:
```bash
gh pr view <number> --repo umbraco/Umbraco-CMS --json number,title,labels \
--jq '"\(.number)\t\([.labels[].name] | join(", "))\t\(.title)"'
```
Do **not** invent or alter the `New Contributors` section — carry it over verbatim. You cannot reliably recompute first-time contributors, so leave it as the generator produced it (mention this in the summary).
### 4. Categorize every PR
Use exactly these headings, in this order. Omit any heading that ends up with no entries.
| Heading | What goes here | Primary signal |
|---|---|---|
| `### 🙌 Notable Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/notable` |
| `### 💥 Breaking Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/breaking` |
| `### 📦 Dependencies` | Dependency bumps | label `dependencies`; or dependabot author |
| `### 🚀 New Features` | New user- or developer-facing capability | label `type/feature` / `category/feature`; or title introduces/adds a genuinely new capability |
| `### 🚤 Performance` | Performance improvements | label `category/performance`; or `Performance:` title prefix |
| `### 🌈 Accessibility Improvements` | A11y improvements (labels, contrast, keyboard) | label `category/accessibility` / `accessibility`; or clear a11y intent (e.g. "improve contrast", "missing labels") |
| `### 🐛 Bug Fixes` | Fixes to broken/incorrect behaviour | default for anything describing a fix |
| `### 🧪 Testing` | Test additions/changes only | label `category/test-automation` / `area/test`; or `E2E`/`QA`/"acceptance tests"/"unit test coverage"/"add tests" titles |
| `### 🛡️ Code Quality, Documentation and Refactoring` | Refactors, deprecations, API tidy-ups, XML/MD documentation, knowledge-base (`MD`) updates | label `category/refactor`; or titles about refactoring, deprecating, renaming, documenting, constants extraction, MD/CLAUDE.md content |
| `### 🧑‍💻 Developer Experience` | Things that improve the experience of developers building on or contributing to Umbraco — dev tooling, build/watch ergonomics, test mocks/harnesses, backoffice dev utilities | `Developer Experience` title prefix; dev tooling; mock/harness changes |
**Rules:**
- **Notable and Breaking are off-limits for recategorization** — never move a PR that is *already in the input file* into or out of these sections; they are driven purely by their labels and the generator placed them correctly. This does **not** exempt them from completeness: a PR discovered as missing in step 3 that carries `category/notable` or `category/breaking` must still be **added** under the matching section.
- Label signals beat title wording, except a `Performance:`/`Developer Experience:` title prefix is decisive for its section.
- A PR with both `type/feature` and `category/refactor` whose title clearly describes a refactor (e.g. "swap relative imports", "re-export type") belongs under Code Quality, not New Features.
- "Add ... tests"/"unit test coverage" → Testing, even if it also touches docs. If a PR adds XML documentation *and* tests, lead with where the title's emphasis lies (documentation → Code Quality; test coverage → Testing).
- When a PR is genuinely 50/50, pick the more reader-useful heading and list it in your closing summary as borderline.
### 5. Remove purely-internal noise
Drop entries that have **no value to anyone reading release notes** — pure repository plumbing with no shipped impact. Examples:
- Branch/merge maintenance ("Fix main branch after merge issue").
- CI/pipeline fixes that don't change the product.
- Reverts of changes that never shipped in a release.
**Keep** anything that ships in the product or genuinely helps developers building on Umbraco — that includes documentation/MD updates, dev tooling, and test mocks (those go to Code Quality or Developer Experience, they are *not* noise). When unsure whether something is noise, keep it and flag it in the summary rather than silently dropping it. List every removal in your closing summary.
### 6. Write the output
Write to a new file in the **same folder** as the input, named by appending ` - with updates` before the extension:
- Input `C:\Temp\release-17.5.0-rc.md` → Output `C:\Temp\release-17.5.0-rc - with updates.md`
Preserve the leading HTML comment, the `## What's Changed` heading, the `## New Contributors` section, and the `**Full Changelog**` line exactly. Only the `### <category>` groupings and their bullets change.
### 7. Report
Give a concise summary:
- Count of PRs added (with their numbers), and which categories they landed in.
- Notable recategorizations (PRs moved out of the catch-all Bug Fixes into Features/Performance/Testing/etc.).
- Every entry removed, with the one-line reason.
- Any borderline calls the user may want to override.
- The output file path.
## Verification
Before reporting done, confirm:
- Every PR number from step 2 is present in the output (except any you deliberately removed in step 5 — and those must be in the removal list).
- No PR appears under more than one heading.
- Notable and Breaking sections are byte-for-byte unchanged from the input.
- The header comment, New Contributors, and Full Changelog lines are intact.
+44
View File
@@ -435,6 +435,14 @@ When a PR changes Management API controllers or models, the `OpenApi.json` file
The backoffice is published to npm as `@umbraco-cms/backoffice`. Runtime dependencies are provided via importmap; npm peerDependencies provide types only. For full details on dependency hoisting, version range logic, and plugin development, see `/src/Umbraco.Web.UI.Client/CLAUDE.md` → "npm Package Publishing".
### SQL Server 2100-parameter limit
Any `WHERE IN (@0, @1, ...)` built from a runtime-sized collection risks hitting SQL Server's 2100-parameter ceiling and throwing `SqlException` 8003 in production.
Batch with `IEnumerable<T>.InGroupsOf(Constants.Sql.MaxParameterCount)` or `Database.FetchByGroups(...)` whenever the collection size is driven by user data — not just when it currently fits. Watch for products of two scaling dimensions (documents × languages, properties × versions) and config-tunable batch sizes whose defaults are safe but ceilings aren't.
Full guidance, safe patterns and decision rule: see `/src/Umbraco.Infrastructure/CLAUDE.md` → "Avoiding the SQL Server 2100-parameter limit".
### Known Limitations
1. **Circular Dependencies**: Avoided via `Lazy<T>` or event notifications
@@ -505,6 +513,42 @@ Labels are only added, never removed. Claude applies only labels it is confident
---
## 8. Code Comment Policy
**Default to no comment.** Applies to all code in this repository — C#, TypeScript, Razor, build scripts. Well-named identifiers and small functions are the primary form of self-documentation; comments are a fallback for the rare cases where the code itself cannot carry the meaning.
### When NOT to comment
- **Don't restate what the code does.** A line calling `resetState()` does not need `// Reset state`. A method named `validateInput` does not need `// Validate input`.
- **Don't narrate a sequence of calls.** If three lines run in order, the order is in the code — don't paraphrase it above.
- **Don't reference the current task, fix, callers, or PR.** No `// Fix for X`, `// Used by Y`, `// Added for the Z flow`, `// See PR #1234`. That belongs in commit messages and PR descriptions; in source it rots as the codebase evolves.
### When a comment IS justified
Write a comment only when **removing it would leave a future reader confused**. Concretely:
- **A non-obvious WHY.** A hidden constraint, business rule, or ordering requirement that is not visible from the code.
- **A workaround for a specific bug or platform quirk.** Link the issue (`(#21996)`, `https://...`) so the comment can be deleted once the upstream fix lands.
- **A subtle invariant** that the type system or method names do not enforce.
- **An edge case the code intentionally handles** that would surprise a reader (e.g. "must run before X because Y").
- **API documentation** — XML doc comments on C# members, JSDoc on exported TypeScript symbols. Required for the public contract; still keep them concise.
### TODOs
Allowed, but cheap to write and cheaper to leave behind. Keep them short and trackable: `// TODO (V19): remove once obsolete overload is gone` or `// TODO: pagination [NL]`. A TODO should have an author or a version trigger.
---
## 9. Testing Practices
### Tests for a bug fix must fail before the fix
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
---
## Quick Reference
### Essential Commands
+4 -4
View File
@@ -45,15 +45,15 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
<PackageVersion Include="Examine" Version="3.7.1" />
<PackageVersion Include="Examine.Core" Version="3.7.1" />
<PackageVersion Include="Examine" Version="3.8.0" />
<PackageVersion Include="Examine.Core" Version="3.8.0" />
<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.16.0" />
<PackageVersion Include="Markdig" Version="0.45.0" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
<PackageVersion Include="ncrontab" Version="3.4.0" />
@@ -92,4 +92,4 @@
<!-- 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>
</Project>
+32 -98
View File
@@ -825,74 +825,31 @@ stages:
publishFeedCredentials: "MyGet - Umbraco Nightly"
${{ else }}:
publishFeedCredentials: "MyGet - Pre-releases"
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
- job:
displayName: Push to pre-release feed (npm)
steps:
- checkout: none
- download: current
artifact: npm
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
- template: templates/npm-publish.yml
parameters:
artifactName: npm
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Push to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- job: PublishTestHelpersNpm
displayName: Push TestHelpers to pre-release feed (npm)
steps:
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Push test helpers to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- stage: Deploy_NuGet
displayName: NuGet release
@@ -941,53 +898,30 @@ stages:
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
dependsOn:
- Deploy_NuGet
variables:
# `latest` for stable releases, `next` for prereleases.
npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
jobs:
- job: Publish
displayName: Push to NPM
steps:
- checkout: none
- download: current
artifact: npm
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm/.npmrc
- template: templates/npm-publish.yml
parameters:
artifactName: npm
registry: https://registry.npmjs.org/
customEndpoint: "NPM - Umbraco Backoffice"
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Push to npm
npmTag: $(npmDistTag)
- job: PublishTestHelpers
displayName: Push Test Helpers to NPM
steps:
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
registry: https://registry.npmjs.org/
customEndpoint: "NPM - Umbraco Backoffice"
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Push test helpers to npm
npmTag: $(npmDistTag)
- stage: Upload_API_Docs
pool:
+2 -2
View File
@@ -5,10 +5,10 @@ trigger: none
schedules:
- cron: '0 3 * * *'
displayName: Daily 3AM build (main)
displayName: Daily 3AM build (v17/dev)
branches:
include:
- main
- v17/dev
parameters:
- name: skipIntegrationTests
+28
View File
@@ -0,0 +1,28 @@
parameters:
- name: artifactName # "npm" or "npm-testhelpers"
type: string
- name: registry # scoped-registry URL to publish to
type: string
- name: customEndpoint # npmAuthenticate service connection(s)
type: string
- name: displayName # label for the publish step
type: string
- name: npmTag # dist-tag to publish under
type: string
default: latest
steps:
- checkout: none
- download: current
artifact: ${{ parameters.artifactName }}
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
displayName: Add scoped registry to .npmrc
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
customEndpoint: ${{ parameters.customEndpoint }}
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
displayName: ${{ parameters.displayName }}
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
-244
View File
@@ -1,244 +0,0 @@
# 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
@@ -1,271 +0,0 @@
# 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.
@@ -40,10 +40,15 @@ public class BackOfficeLoginController : Controller
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <param name="model">The model containing login information and the return URL.</param>
/// <returns>
/// An <see cref="IActionResult"/> that renders the login view with the model, or a bad request result if the return URL is invalid.
/// An <see cref="IActionResult"/> that renders the login view with the model, or a bad request result if the model state or return URL is invalid.
/// </returns>
public async Task<IActionResult> Index(CancellationToken cancellationToken, BackOfficeLoginModel model)
{
if (ModelState.IsValid is false)
{
return BadRequest();
}
AuthenticateResult cookieAuthResult = await HttpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType);
if (cookieAuthResult.Succeeded)
{
@@ -53,11 +53,14 @@ public class SearchDataTypeItemController : DatatypeItemControllerBase
return Ok(new PagedModel<DataTypeItemResponseModel> { Total = searchResult.Total });
}
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(keys);
IEnumerable<IDataType> orderedDataTypes = OrderByRequestedIds(dataTypes, keys);
var result = new PagedModel<DataTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(dataTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(orderedDataTypes),
Total = searchResult.Total,
};
return Ok(result);
@@ -0,0 +1,100 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
/// <summary>
/// Provides an API endpoint for sorting the root-level documents by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenAtRootDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenAtRootDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenAtRootDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the root-level documents by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
/// </returns>
[HttpPut("root/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Sorts the root-level documents by a field.")]
[EndpointDescription("Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, (Guid?)null),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
parentKey: null,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
null,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -0,0 +1,102 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
/// <summary>
/// Provides an API endpoint for sorting the children of a document by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the child documents of the specified parent document by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="id">The unique identifier of the parent document whose children should be sorted.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent document does not exist.
/// </returns>
[HttpPut("{id:guid}/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Sorts the children of a document by a field.")]
[EndpointDescription("Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
id,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
id,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -0,0 +1,98 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Media;
/// <summary>
/// Provides an API endpoint for sorting the root-level media items by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenAtRootMediaController : MediaControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IMediaEditingService _mediaEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenAtRootMediaController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
public SortChildrenAtRootMediaController(
IAuthorizationService authorizationService,
IMediaEditingService mediaEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_mediaEditingService = mediaEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the root-level media items by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
/// </returns>
[HttpPut("root/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Sorts the root-level media items by a field.")]
[EndpointDescription("Sorts the root-level media items by a system field in the given direction.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortMediaChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
MediaPermissionResource.Root(),
AuthorizationPolicies.MediaPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
parentKey: null,
UmbracoObjectTypes.Media,
childKeys => MediaPermissionResource.WithKeys(childKeys),
AuthorizationPolicies.MediaPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
null,
requestModel.Field,
requestModel.Direction,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -0,0 +1,100 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Media;
/// <summary>
/// Provides an API endpoint for sorting the children of a media item by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenMediaController : MediaControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IMediaEditingService _mediaEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenMediaController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
public SortChildrenMediaController(
IAuthorizationService authorizationService,
IMediaEditingService mediaEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_mediaEditingService = mediaEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the child media items of the specified parent media item by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="id">The unique identifier of the parent media item whose children should be sorted.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent media item does not exist.
/// </returns>
[HttpPut("{id:guid}/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Sorts the children of a media item by a field.")]
[EndpointDescription("Sorts the children of the specified parent media item by a system field in the given direction.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortMediaChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
MediaPermissionResource.WithKeys(id),
AuthorizationPolicies.MediaPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
id,
UmbracoObjectTypes.Media,
childKeys => MediaPermissionResource.WithKeys(childKeys),
AuthorizationPolicies.MediaPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
id,
requestModel.Field,
requestModel.Direction,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -54,11 +54,14 @@ public class SearchMediaTypeItemController : MediaTypeItemControllerBase
return Task.FromResult<IActionResult>(Ok(new PagedModel<MediaTypeItemResponseModel> { Total = searchResult.Total }));
}
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray().EmptyNull());
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(keys.EmptyNull());
IEnumerable<IMediaType> orderedMediaTypes = OrderByRequestedIds(mediaTypes, keys);
var result = new PagedModel<MediaTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(mediaTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(orderedMediaTypes),
Total = searchResult.Total,
};
return Task.FromResult<IActionResult>(Ok(result));
@@ -1,11 +1,11 @@
using Asp.Versioning;
using J2N.Collections.Generic.Extensions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels.MemberGroup.Item;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.MemberGroup.Item;
@@ -16,18 +16,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.MemberGroup.Item;
[ApiVersion("1.0")]
public class ItemMemberGroupItemController : MemberGroupItemControllerBase
{
private readonly IEntityService _entityService;
private readonly IUmbracoMapper _mapper;
private readonly IMemberGroupService _memberGroupService;
// TODO (V19): When the obsolete constructor is removed, also remove the unused dependency on IEntityService.
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.MemberGroup.Item.ItemMemberGroupItemController"/> class, providing services for managing member group items.
/// Initializes a new instance of the <see cref="ItemMemberGroupItemController"/> class.
/// </summary>
/// <param name="entityService">The service used to interact with entities in the Umbraco CMS.</param>
/// <param name="mapper">The mapper used for mapping Umbraco objects.</param>
public ItemMemberGroupItemController(IEntityService entityService, IUmbracoMapper mapper)
/// <param name="memberGroupService">The service used to look up member groups.</param>
[ActivatorUtilitiesConstructor]
public ItemMemberGroupItemController(IEntityService entityService, IUmbracoMapper mapper, IMemberGroupService memberGroupService)
{
_entityService = entityService;
_mapper = mapper;
_memberGroupService = memberGroupService;
}
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public ItemMemberGroupItemController(IEntityService entityService, IUmbracoMapper mapper)
: this(
entityService,
mapper,
StaticServiceProvider.Instance.GetRequiredService<IMemberGroupService>())
{
}
[HttpGet]
@@ -35,17 +47,19 @@ public class ItemMemberGroupItemController : MemberGroupItemControllerBase
[ProducesResponseType(typeof(IEnumerable<MemberGroupItemResponseModel>), StatusCodes.Status200OK)]
[EndpointSummary("Gets a collection of member group items.")]
[EndpointDescription("Gets a collection of member group 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<MemberGroupItemResponseModel>()));
return Ok(Enumerable.Empty<MemberGroupItemResponseModel>());
}
IEnumerable<IEntitySlim> memberGroups = _entityService.GetAll(UmbracoObjectTypes.MemberGroup, ids.ToArray());
List<MemberGroupItemResponseModel> responseModel = _mapper.MapEnumerable<IEntitySlim, MemberGroupItemResponseModel>(memberGroups);
return Task.FromResult<IActionResult>(Ok(responseModel));
// Resolve via IMemberGroupService so custom implementations are honoured, rather than
// going directly to the entity/repository layer.
IEnumerable<IMemberGroup> memberGroups = await _memberGroupService.GetAsync(ids);
List<MemberGroupItemResponseModel> responseModel = _mapper.MapEnumerable<IMemberGroup, MemberGroupItemResponseModel>(memberGroups);
return Ok(responseModel);
}
}
@@ -32,6 +32,14 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
_mapper = mapper;
}
/// <summary>
/// Searches for member type items matching the specified query, with support for pagination.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="query">The search query used to filter member type items.</param>
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{MemberTypeItemResponseModel}"/> containing the search results.</returns>
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<MemberTypeItemResponseModel>), StatusCodes.Status200OK)]
@@ -45,11 +53,14 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
return Task.FromResult<IActionResult>(Ok(new PagedModel<MemberTypeItemResponseModel> { Total = searchResult.Total }));
}
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray());
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(keys);
IEnumerable<IMemberType> orderedMemberTypes = OrderByRequestedIds(memberTypes, keys);
var result = new PagedModel<MemberTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(memberTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(orderedMemberTypes),
Total = searchResult.Total,
};
return Task.FromResult<IActionResult>(Ok(result));
@@ -8,67 +8,38 @@ using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
/// <summary>
/// Controller for setting the redirect URL tracking status.
/// Controller for setting the redirect URL tracking status. Retained for backwards compatibility only;
/// the endpoint no longer modifies any configuration.
/// </summary>
[ApiVersion("1.0")]
[Obsolete("This controller is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public class SetStatusRedirectUrlManagementController : RedirectUrlManagementControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IConfigManipulator _configManipulator;
/// <summary>
/// Initializes a new instance of the <see cref="SetStatusRedirectUrlManagementController"/> class.
/// </summary>
/// <param name="backOfficeSecurityAccessor">The back office security accessor.</param>
/// <param name="configManipulator">The configuration manipulator.</param>
/// <param name="backOfficeSecurityAccessor">Ignored. Retained for binary compatibility.</param>
/// <param name="configManipulator">Ignored. Retained for binary compatibility.</param>
public SetStatusRedirectUrlManagementController(
#pragma warning disable IDE0060 // Remove unused parameter
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IConfigManipulator configManipulator)
#pragma warning restore IDE0060 // Remove unused parameter
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_configManipulator = configManipulator;
}
// TODO: Consider if we should even allow this, or only allow using the appsettings
// We generally don't want to edit the appsettings from our code.
// But maybe there is a valid use case for doing it on the fly.
/// <summary>
/// Sets the redirect URL tracking status.
/// Deprecated. Returns an OK response without modifying any configuration. To toggle redirect URL tracking,
/// set the <c>Umbraco:CMS:WebRouting:DisableRedirectUrlTracking</c> configuration key instead.
/// </summary>
/// <param name="cancellationToken">The cancellation token for the HTTP request.</param>
/// <param name="status">The redirect status to set.</param>
/// <returns>An OK result if successful.</returns>
/// <param name="status">The redirect status (ignored).</param>
/// <returns>An OK result.</returns>
[HttpPost("status")]
[EndpointSummary("Sets the redirect URL tracking status.")]
[EndpointDescription("Updates the redirect URL tracking configuration according to the provided status.")]
[EndpointSummary("Deprecated. No longer changes the redirect URL tracking status.")]
[EndpointDescription("This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
{
// TODO: uncomment this when auth is implemented.
// var userIsAdmin = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin();
// if (userIsAdmin is null or false)
// {
// return Unauthorized();
// }
var enable = status switch
{
RedirectStatus.Enabled => true,
RedirectStatus.Disabled => false,
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown redirect status")
};
// For now I'm not gonna change this to limit breaking, but it's weird to have a "disabled" switch,
// since you're essentially negating the boolean from the get go,
// it's much easier to reason with enabled = false == disabled.
await _configManipulator.SaveDisableRedirectUrlTrackingAsync(!enable);
// Taken from the existing implementation in RedirectUrlManagementController
// TODO this is ridiculous, but we need to ensure the configuration is reloaded, before this request is ended.
// otherwise we can read the old value in GetEnableState.
// The value is equal to JsonConfigurationSource.ReloadDelay
Thread.Sleep(250);
return Ok();
}
[Obsolete("This endpoint is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
=> Task.FromResult<IActionResult>(Ok());
}
@@ -28,6 +28,7 @@ public class ConfigurationServerController : ServerControllerBase
private readonly GlobalSettings _globalSettings;
private readonly IBackOfficeExternalLoginProviders _externalLoginProviders;
private readonly IHostingEnvironment _hostingEnvironment;
private readonly SignalRSettings _signalRSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationServerController"/> class.
@@ -36,13 +37,38 @@ public class ConfigurationServerController : ServerControllerBase
/// <param name="globalSettings">The global settings options.</param>
/// <param name="externalLoginProviders">The external login providers for back office.</param>
/// <param name="hostingEnvironment">The hosting environment.</param>
/// <param name="signalRSettings">The SignalR settings options.</param>
[ActivatorUtilitiesConstructor]
public ConfigurationServerController(IOptions<SecuritySettings> securitySettings, IOptions<GlobalSettings> globalSettings, IBackOfficeExternalLoginProviders externalLoginProviders, IHostingEnvironment hostingEnvironment)
public ConfigurationServerController(
IOptions<SecuritySettings> securitySettings,
IOptions<GlobalSettings> globalSettings,
IBackOfficeExternalLoginProviders externalLoginProviders,
IHostingEnvironment hostingEnvironment,
IOptions<SignalRSettings> signalRSettings)
{
_securitySettings = securitySettings.Value;
_globalSettings = globalSettings.Value;
_externalLoginProviders = externalLoginProviders;
_hostingEnvironment = hostingEnvironment;
_signalRSettings = signalRSettings.Value;
}
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Controllers.Server.ConfigurationServerController"/> class.
/// </summary>
/// <param name="securitySettings">The <see cref="SecuritySettings"/> options.</param>
/// <param name="globalSettings">The <see cref="GlobalSettings"/> options.</param>
/// <param name="externalLoginProviders">The external login providers used for back office authentication.</param>
/// <param name="hostingEnvironment">The hosting environment.</param>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public ConfigurationServerController(IOptions<SecuritySettings> securitySettings, IOptions<GlobalSettings> globalSettings, IBackOfficeExternalLoginProviders externalLoginProviders, IHostingEnvironment hostingEnvironment)
: this(
securitySettings,
globalSettings,
externalLoginProviders,
hostingEnvironment,
StaticServiceProvider.Instance.GetRequiredService<IOptions<SignalRSettings>>())
{
}
/// <summary>
@@ -78,6 +104,10 @@ public class ConfigurationServerController : ServerControllerBase
VersionCheckPeriod = _globalSettings.VersionCheckPeriod,
AllowLocalLogin = _externalLoginProviders.HasDenyLocalLogin() is false,
UmbracoCssPath = _hostingEnvironment.ToAbsolute(_globalSettings.UmbracoCssPath),
SignalR = new SignalRClientSettingsResponseModel
{
SkipNegotiation = _signalRSettings.ClientShouldSkipNegotiation,
},
};
return Task.FromResult<IActionResult>(Ok(responseModel));
@@ -32,6 +32,14 @@ public class SearchTemplateItemController : TemplateItemControllerBase
_mapper = mapper;
}
/// <summary>
/// Searches for template items matching the specified query, with support for pagination.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="query">The search query used to filter template items.</param>
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{TemplateItemResponseModel}"/> containing the search results.</returns>
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<TemplateItemResponseModel>), StatusCodes.Status200OK)]
@@ -45,11 +53,14 @@ public class SearchTemplateItemController : TemplateItemControllerBase
return Ok(new PagedModel<TemplateItemResponseModel> { Total = searchResult.Total });
}
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(keys);
IEnumerable<ITemplate> orderedTemplates = OrderByRequestedIds(templates, keys);
var result = new PagedModel<TemplateItemResponseModel>
{
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(templates),
Total = searchResult.Total
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(orderedTemplates),
Total = searchResult.Total,
};
return Ok(result);
@@ -0,0 +1,54 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.User.Current;
/// <summary>
/// Controller responsible for handling requests to clear the avatar of the currently authenticated user.
/// </summary>
[ApiVersion("1.0")]
public class ClearAvatarCurrentUserController : CurrentUserControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="ClearAvatarCurrentUserController"/> class.
/// </summary>
/// <param name="backOfficeSecurityAccessor">Provides access to back office security features for the current user.</param>
/// <param name="userService">Service for managing user-related operations.</param>
public ClearAvatarCurrentUserController(
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IUserService userService)
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_userService = userService;
}
/// <summary>
/// Removes the avatar image for the currently authenticated user.
/// </summary>
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
/// <returns>An <see cref="IActionResult"/> indicating the result of the operation.</returns>
[MapToApiVersion("1.0")]
[HttpDelete("avatar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Clears the current user's avatar.")]
[EndpointDescription("Removes the avatar image for the currently authenticated user.")]
public async Task<IActionResult> ClearAvatar(CancellationToken cancellationToken)
{
Guid userKey = CurrentUserKey(_backOfficeSecurityAccessor);
UserOperationStatus result = await _userService.ClearAvatarAsync(userKey);
return result is UserOperationStatus.Success
? Ok()
: UserOperationStatusResult(result);
}
}
@@ -20,7 +20,6 @@ namespace Umbraco.Cms.Api.Management.Controllers.User.Current;
public class SetAvatarCurrentUserController : CurrentUserControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IAuthorizationService _authorizationService;
private readonly IUserService _userService;
/// <summary>
@@ -29,13 +28,15 @@ public class SetAvatarCurrentUserController : CurrentUserControllerBase
/// <param name="backOfficeSecurityAccessor">Provides access to back office security features for the current user.</param>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="userService">Service for managing user-related operations.</param>
// TODO (V18): Remove the IAuthorizationService parameter from the constructor and the class, as it is not used in the current implementation.
public SetAvatarCurrentUserController(
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
#pragma warning disable IDE0060 // Remove unused parameter
IAuthorizationService authorizationService,
#pragma warning restore IDE0060 // Remove unused parameter
IUserService userService)
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_authorizationService = authorizationService;
_userService = userService;
}
@@ -55,16 +56,6 @@ public class SetAvatarCurrentUserController : CurrentUserControllerBase
{
Guid userKey = CurrentUserKey(_backOfficeSecurityAccessor);
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
UserPermissionResource.WithKeys(userKey),
AuthorizationPolicies.UserPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
UserOperationStatus result = await _userService.SetAvatarAsync(userKey, model.File.Id);
return result is UserOperationStatus.Success
@@ -0,0 +1,64 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.User;
using Umbraco.Cms.Core;
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;
namespace Umbraco.Cms.Api.Management.Controllers.User.Current;
/// <summary>
/// Controller responsible for update information about the currently authenticated user.
/// </summary>
[ApiVersion("1.0")]
public class UpdateCurrentUserProfileController : CurrentUserControllerBase
{
private readonly IUserService _userService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IUserPresentationFactory _userPresentationFactory;
/// <summary>
/// Initializes a new instance of the <see cref="UpdateCurrentUserProfileController"/> class, which manages user update operations in the Umbraco backoffice API.
/// </summary>
/// <param name="userService">Service for managing user data and operations.</param>
/// <param name="userPresentationFactory">Factory for creating user presentation models.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public UpdateCurrentUserProfileController(
IUserService userService,
IUserPresentationFactory userPresentationFactory,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_userService = userService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_userPresentationFactory = userPresentationFactory;
}
/// <summary>
/// Updates the current user with new details provided in the request model.
/// </summary>
/// <param name="model">The request model containing updated current user information.</param>
/// <returns>An <see cref="IActionResult"/> indicating the outcome of the update operation.</returns>
[HttpPut("profile")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Updates current user profile.")]
[EndpointDescription("Updates current user profile with the details from the request model.")]
public async Task<IActionResult> UpdateCurrentUser(UpdateCurrentUserRequestModel model)
{
Guid userKey = CurrentUserKey(_backOfficeSecurityAccessor);
UserUpdateProfileModel updateModel = await _userPresentationFactory.CreateUpdateProfileModelAsync(model);
Attempt<IUser?, UserOperationStatus> result = await _userService.UpdateProfileAsync(userKey, updateModel);
return result.Success
? Ok()
: UserOperationStatusResult(result.Status);
}
}
@@ -63,6 +63,10 @@ public abstract class UserOrCurrentUserControllerBase : ManagementApiControllerB
.WithTitle("Cannot delete user")
.WithDetail("The user cannot be deleted.")
.Build()),
UserOperationStatus.CannotDeleteUserWithLoginHistory => BadRequest(problemDetailsBuilder
.WithTitle("Cannot delete user")
.WithDetail("This user has logged in and may be referenced by audit logs or content history. Disable the user instead of deleting them.")
.Build()),
UserOperationStatus.CannotDisableSelf => BadRequest(problemDetailsBuilder
.WithTitle("Cannot disable")
.WithDetail("A user cannot disable itself.")
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Web.Common.Hosting;
using Umbraco.Cms.Web.Common.Middleware;
namespace Umbraco.Extensions;
@@ -68,6 +69,10 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IBackOfficeEnabledMarker, BackOfficeEnabledMarker>();
builder.Services.AddUnique<IBackOfficePathGenerator, UmbracoBackOfficePathGenerator>();
// Registered here rather than in AddWebComponents because the middleware depends on
// IBackOfficePathGenerator (registered just above). DI scope validation would otherwise
// fail in Delivery-only/Website-only bootstraps that never call AddBackOffice().
builder.Services.AddSingleton<UmbracoBackOfficeCacheHeadersMiddleware>();
builder.Services.AddUnique<IPhysicalFileSystem>(factory =>
{
var path = "~/";
@@ -1,5 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Api.Management.ViewModels.DataType;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
@@ -16,6 +19,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
private readonly IDataValueEditorFactory _dataValueEditorFactory;
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DataTypePresentationFactory> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
@@ -25,18 +29,46 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
/// <param name="logger">The logger.</param>
public DataTypePresentationFactory(
IDataTypeContainerService dataTypeContainerService,
PropertyEditorCollection propertyEditorCollection,
IDataValueEditorFactory dataValueEditorFactory,
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
TimeProvider timeProvider)
TimeProvider timeProvider,
ILogger<DataTypePresentationFactory> logger)
{
_dataTypeContainerService = dataTypeContainerService;
_propertyEditorCollection = propertyEditorCollection;
_dataValueEditorFactory = dataValueEditorFactory;
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
_timeProvider = timeProvider;
_logger = logger;
}
/// <summary>
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
/// </summary>
/// <param name="dataTypeContainerService">Service used to manage data type containers.</param>
/// <param name="propertyEditorCollection">A collection containing all available property editors.</param>
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
[Obsolete("Please use the constructor that takes all parameters. Scheduled for removal in Umbraco 19.")]
public DataTypePresentationFactory(
IDataTypeContainerService dataTypeContainerService,
PropertyEditorCollection propertyEditorCollection,
IDataValueEditorFactory dataValueEditorFactory,
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
TimeProvider timeProvider)
: this(
dataTypeContainerService,
propertyEditorCollection,
dataValueEditorFactory,
configurationEditorJsonSerializer,
timeProvider,
StaticServiceProvider.Instance.GetRequiredService<ILogger<DataTypePresentationFactory>>())
{
}
/// <inheritdoc />
@@ -72,7 +104,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
dataType.Key = requestModel.Id.Value;
}
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
}
@@ -82,7 +113,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
{
try
{
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
return parent is null
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
@@ -97,6 +128,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
return Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, Constants.System.Root);
}
/// <inheritdoc/>
public Task<Attempt<IDataType, DataTypeOperationStatus>> CreateAsync(UpdateDataTypeRequestModel requestModel, IDataType current)
{
if (!_propertyEditorCollection.TryGet(requestModel.EditorAlias, out IDataEditor? editor))
@@ -104,7 +136,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
}
IDataType dataType = (IDataType)current.DeepClone();
var dataType = (IDataType)current.DeepClone();
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
dataType.Name = requestModel.Name;
@@ -119,12 +151,26 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
{
var configurationObject = editor.GetConfigurationEditor()
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
if (configurationObject is IConfigureValueType configureValueType)
// Only editors whose configuration object implements IConfigureValueType derive their storage
// type from the configuration. Building the typed configuration object can throw for editors
// whose stored configuration doesn't cleanly deserialize into their configuration type; that
// must not fail the save, so fall back to the value editor's value type in that case.
try
{
return ValueTypes.ToStorageType(configureValueType.ValueType);
if (editor.GetConfigurationEditor().ToConfigurationObject(configurationData, _configurationEditorJsonSerializer)
is IConfigureValueType configureValueType)
{
return ValueTypes.ToStorageType(configureValueType.ValueType);
}
}
catch (Exception)
{
// Configuration editors are third-party and can throw anything when the stored configuration
// doesn't deserialize into their configuration type. Fall back to the value editor's value type
// rather than failing the save, but log so the misconfiguration remains observable.
_logger.LogError(
"Could not build the configuration object for editor {EditorAlias} to determine its value storage type; falling back to the value editor's value type.",
editor.Alias);
}
var valueType = editor.GetValueEditor().ValueType;
@@ -31,6 +31,12 @@ public interface IUserPresentationFactory
/// </summary>
Task<UserUpdateModel> CreateUpdateModelAsync(Guid existingUserKey, UpdateUserRequestModel updateModel);
/// <summary>
/// Creates an update model for a current user based on the provided request model.
/// </summary>
// TODO V19: Remove default implementation
Task<UserUpdateProfileModel> CreateUpdateProfileModelAsync(UpdateCurrentUserRequestModel updateModel) => throw new NotImplementedException();
/// <summary>
/// Creates a response model for the current user based on the provided user.
/// </summary>
@@ -56,10 +62,10 @@ public interface IUserPresentationFactory
/// </summary>
UserItemResponseModel CreateItemResponseModel(IUser user);
/// <summary>
/// Asynchronously creates a response model containing the calculated start nodes for the specified user.
/// </summary>
/// <param name="user">The user for whom to calculate start nodes.</param>
/// <returns>A task representing the asynchronous operation. The task result contains the calculated user start nodes response model.</returns>
/// <summary>
/// Asynchronously creates a response model containing the calculated start nodes for the specified user.
/// </summary>
/// <param name="user">The user for whom to calculate start nodes.</param>
/// <returns>A task representing the asynchronous operation. The task result contains the calculated user start nodes response model.</returns>
Task<CalculatedUserStartNodesResponseModel> CreateCalculatedUserStartNodesResponseModelAsync(IUser user);
}
@@ -1,12 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
using Umbraco.Cms.Api.Management.ViewModels.MemberGroup.Item;
using Umbraco.Cms.Api.Management.ViewModels.PublicAccess;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Security;
@@ -23,8 +23,10 @@ public class PublicAccessPresentationFactory : IPublicAccessPresentationFactory
private readonly IEntityService _entityService;
private readonly IMemberService _memberService;
private readonly IUmbracoMapper _mapper;
private readonly IMemberRoleManager _memberRoleManager;
private readonly IMemberPresentationFactory _memberPresentationFactory;
private readonly IMemberGroupService _memberGroupService;
// TODO (V19): When the obsolete constructor is removed, consider also remove the unused dependency on IMemberRoleManager.
/// <summary>
/// Initializes a new instance of the <see cref="PublicAccessPresentationFactory"/> class.
@@ -34,18 +36,37 @@ public class PublicAccessPresentationFactory : IPublicAccessPresentationFactory
/// <param name="mapper">The Umbraco mapper for mapping entities to response models.</param>
/// <param name="memberRoleManager">The member role manager for resolving member groups.</param>
/// <param name="memberPresentationFactory">The member presentation factory for creating member item response models.</param>
/// <param name="memberGroupService">The member group service for resolving member groups by name.</param>
public PublicAccessPresentationFactory(
IEntityService entityService,
IMemberService memberService,
IUmbracoMapper mapper,
IMemberRoleManager memberRoleManager,
IMemberPresentationFactory memberPresentationFactory,
IMemberGroupService memberGroupService)
{
_entityService = entityService;
_memberService = memberService;
_mapper = mapper;
_memberPresentationFactory = memberPresentationFactory;
_memberGroupService = memberGroupService;
}
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public PublicAccessPresentationFactory(
IEntityService entityService,
IMemberService memberService,
IUmbracoMapper mapper,
IMemberRoleManager memberRoleManager,
IMemberPresentationFactory memberPresentationFactory)
: this(
entityService,
memberService,
mapper,
memberRoleManager,
memberPresentationFactory,
StaticServiceProvider.Instance.GetRequiredService<IMemberGroupService>())
{
_entityService = entityService;
_memberService = memberService;
_mapper = mapper;
_memberRoleManager = memberRoleManager;
_memberPresentationFactory = memberPresentationFactory;
}
/// <inheritdoc/>
@@ -107,21 +128,15 @@ public class PublicAccessPresentationFactory : IPublicAccessPresentationFactory
.Select(_memberPresentationFactory.CreateItemResponseModel)
.ToArray();
var allGroups = _memberRoleManager.Roles.Where(x => x.Name != null).ToDictionary(x => x.Name!);
IEnumerable<UmbracoIdentityRole> identityRoles = entry.Rules
// Resolve groups via IMemberGroupService so custom implementations (e.g. backed by an external
// user store) are honoured here, rather than going directly to IMemberRoleManager/IEntityService.
MemberGroupItemResponseModel[] memberGroups = entry.Rules
.Where(rule => rule.RuleType == Constants.Conventions.PublicAccess.MemberRoleRuleType)
.Select(rule =>
rule.RuleValue is not null && allGroups.TryGetValue(rule.RuleValue, out UmbracoIdentityRole? memberRole)
? memberRole
: null)
.Select(rule => rule.RuleValue is null ? null : _memberGroupService.GetByName(rule.RuleValue))
.WhereNotNull()
.Select(group => _mapper.Map<MemberGroupItemResponseModel>(group)!)
.ToArray();
IEnumerable<IEntitySlim> groupsEntities = identityRoles.Any()
? _entityService.GetAll(UmbracoObjectTypes.MemberGroup, identityRoles.Select(x => Convert.ToInt32(x.Id)).ToArray())
: Enumerable.Empty<IEntitySlim>();
MemberGroupItemResponseModel[] memberGroups = groupsEntities.Select(x => _mapper.Map<MemberGroupItemResponseModel>(x)!).ToArray();
var responseModel = new PublicAccessResponseModel
{
Members = members,
@@ -1,5 +1,6 @@
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.RedirectUrlManagement;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Routing;
@@ -33,12 +34,12 @@ public class RedirectUrlPresentationFactory : IRedirectUrlPresentationFactory
{
var destinationUrl = source.ContentId > 0
? _publishedUrlProvider.GetUrl(source.ContentId, culture: source.Culture)
: "#";
: Constants.Routing.Unroutable;
var originalUrl = _publishedUrlProvider.GetUrlFromRoute(source.ContentId, source.Url, source.Culture);
// Even if the URL could not be extracted from the route, if we have a path as a the route for the original URL, we should display it.
if (originalUrl == "#" && source.Url.StartsWith('/'))
if (originalUrl == Constants.Routing.Unroutable && source.Url.StartsWith('/'))
{
originalUrl = source.Url;
}
@@ -389,4 +389,15 @@ public class UserPresentationFactory : IUserPresentationFactory
private static bool HasRootAccess(IEnumerable<int>? startNodeIds)
=> startNodeIds?.Contains(Constants.System.Root) is true;
/// <inheritdoc/>
public Task<UserUpdateProfileModel> CreateUpdateProfileModelAsync(UpdateCurrentUserRequestModel updateModel)
{
var model = new UserUpdateProfileModel
{
LanguageIsoCode = updateModel.LanguageIsoCode
};
return Task.FromResult(model);
}
}
@@ -41,6 +41,7 @@ public class ItemTypeMapDefinition : IMapDefinition
mapper.Define<IMediaType, MediaTypeItemResponseModel>((_, _) => new MediaTypeItemResponseModel(), Map);
mapper.Define<MediaTypeFileExtensionMatchResult, AllowedMediaTypeItemResponseModel>((_, _) => new AllowedMediaTypeItemResponseModel(), Map);
mapper.Define<IEntitySlim, MemberGroupItemResponseModel>((_, _) => new MemberGroupItemResponseModel(), Map);
mapper.Define<IMemberGroup, MemberGroupItemResponseModel>((_, _) => new MemberGroupItemResponseModel(), Map);
mapper.Define<ITemplate, TemplateItemResponseModel>((_, _) => new TemplateItemResponseModel { Alias = string.Empty }, Map);
mapper.Define<IMemberType, MemberTypeItemResponseModel>((_, _) => new MemberTypeItemResponseModel(), Map);
mapper.Define<IRelationType, RelationTypeItemResponseModel>((_, _) => new RelationTypeItemResponseModel(), Map);
@@ -105,6 +106,13 @@ public class ItemTypeMapDefinition : IMapDefinition
target.Id = source.Key;
}
// Umbraco.Code.MapAll -Flags
private static void Map(IMemberGroup source, MemberGroupItemResponseModel target, MapperContext context)
{
target.Name = source.Name ?? string.Empty;
target.Id = source.Key;
}
// Umbraco.Code.MapAll -Flags
private static void Map(ITemplate source, TemplateItemResponseModel target, MapperContext context)
{
+784 -22
View File
@@ -10888,6 +10888,150 @@
]
}
},
"/umbraco/management/api/v1/document/{id}/sort-children": {
"put": {
"tags": [
"Document"
],
"summary": "Sorts the children of a document by a field.",
"description": "Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
"operationId": "PutDocumentByIdSortChildren",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
}
}
},
"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"
}
]
}
}
}
},
"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}/unpublish": {
"put": {
"tags": [
@@ -11282,6 +11426,113 @@
]
}
},
"/umbraco/management/api/v1/document/root/sort-children": {
"put": {
"tags": [
"Document"
],
"summary": "Sorts the root-level documents by a field.",
"description": "Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.",
"operationId": "PutDocumentRootSortChildren",
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortDocumentChildrenByFieldRequestModel"
}
]
}
}
}
},
"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"
}
]
}
}
}
},
"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/sort": {
"put": {
"tags": [
@@ -19158,6 +19409,150 @@
]
}
},
"/umbraco/management/api/v1/media/{id}/sort-children": {
"put": {
"tags": [
"Media"
],
"summary": "Sorts the children of a media item by a field.",
"description": "Sorts the children of the specified parent media item by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
"operationId": "PutMediaByIdSortChildren",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
}
}
},
"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"
}
]
}
}
}
},
"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/media/{id}/validate": {
"put": {
"tags": [
@@ -19408,6 +19803,113 @@
]
}
},
"/umbraco/management/api/v1/media/root/sort-children": {
"put": {
"tags": [
"Media"
],
"summary": "Sorts the root-level media items by a field.",
"description": "Sorts the root-level media items by a system field in the given direction. Media items do not vary by culture, so any supplied culture is ignored.",
"operationId": "PutMediaRootSortChildren",
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/SortMediaChildrenByFieldRequestModel"
}
]
}
}
}
},
"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"
}
]
}
}
}
},
"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/media/sort": {
"put": {
"tags": [
@@ -27860,8 +28362,8 @@
"tags": [
"Redirect Management"
],
"summary": "Sets the redirect URL tracking status.",
"description": "Updates the redirect URL tracking configuration according to the provided status.",
"summary": "Deprecated. No longer changes the redirect URL tracking status.",
"description": "This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.",
"operationId": "PostRedirectManagementStatus",
"parameters": [
{
@@ -27907,6 +28409,7 @@
}
}
},
"deprecated": true,
"security": [
{
"Backoffice-User": [ ]
@@ -36859,9 +37362,6 @@
"oneOf": [
{
"$ref": "#/components/schemas/NoopSetupTwoFactorModel"
},
{
"$ref": "#/components/schemas/TwoFactorAuthInfo"
}
]
}
@@ -36956,9 +37456,6 @@
"oneOf": [
{
"$ref": "#/components/schemas/NoopSetupTwoFactorModel"
},
{
"$ref": "#/components/schemas/TwoFactorAuthInfo"
}
]
}
@@ -37005,6 +37502,91 @@
}
},
"/umbraco/management/api/v1/user/current/avatar": {
"delete": {
"tags": [
"User"
],
"summary": "Clears the current user's avatar.",
"description": "Removes the avatar image for the currently authenticated user.",
"operationId": "DeleteUserCurrentAvatar",
"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"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
},
"post": {
"tags": [
"User"
@@ -37448,6 +38030,124 @@
]
}
},
"/umbraco/management/api/v1/user/current/profile": {
"put": {
"tags": [
"User"
],
"summary": "Updates current user profile.",
"description": "Updates current user profile with the details from the request model.",
"operationId": "PutUserCurrentProfile",
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/UpdateCurrentUserRequestModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/UpdateCurrentUserRequestModel"
}
]
}
},
"application/*+json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/UpdateCurrentUserRequestModel"
}
]
}
}
}
},
"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"
}
]
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
}
},
"security": [
{
"Backoffice-User": [ ]
}
]
}
},
"/umbraco/management/api/v1/user/disable": {
"post": {
"tags": [
@@ -39658,6 +40358,14 @@
},
"additionalProperties": false
},
"ContentSortFieldModel": {
"enum": [
"Name",
"CreateDate",
"UpdateDate"
],
"type": "string"
},
"CopyDataTypeRequestModel": {
"type": "object",
"properties": {
@@ -49852,6 +50560,7 @@
"required": [
"allowLocalLogin",
"allowPasswordReset",
"signalR",
"umbracoCssPath",
"versionCheckPeriod"
],
@@ -49869,6 +50578,13 @@
},
"umbracoCssPath": {
"type": "string"
},
"signalR": {
"oneOf": [
{
"$ref": "#/components/schemas/SignalRClientSettingsResponseModel"
}
]
}
},
"additionalProperties": false
@@ -49944,6 +50660,54 @@
},
"additionalProperties": false
},
"SignalRClientSettingsResponseModel": {
"required": [
"skipNegotiation"
],
"type": "object",
"properties": {
"skipNegotiation": {
"type": "boolean"
}
},
"additionalProperties": false
},
"SortDocumentChildrenByFieldRequestModel": {
"required": [
"direction",
"field"
],
"type": "object",
"properties": {
"field": {
"$ref": "#/components/schemas/ContentSortFieldModel"
},
"direction": {
"$ref": "#/components/schemas/DirectionModel"
},
"culture": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"SortMediaChildrenByFieldRequestModel": {
"required": [
"direction",
"field"
],
"type": "object",
"properties": {
"field": {
"$ref": "#/components/schemas/ContentSortFieldModel"
},
"direction": {
"$ref": "#/components/schemas/DirectionModel"
}
},
"additionalProperties": false
},
"SortingRequestModel": {
"required": [
"sorting"
@@ -50895,20 +51659,6 @@
],
"type": "string"
},
"TwoFactorAuthInfo": {
"type": "object",
"properties": {
"qrCodeSetupImageUrl": {
"type": "string",
"nullable": true
},
"secret": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"UnknownTypePermissionPresentationModel": {
"required": [
"$type",
@@ -50973,6 +51723,18 @@
},
"additionalProperties": false
},
"UpdateCurrentUserRequestModel": {
"required": [
"languageIsoCode"
],
"type": "object",
"properties": {
"languageIsoCode": {
"type": "string"
}
},
"additionalProperties": false
},
"UpdateDataTypeRequestModel": {
"required": [
"editorAlias",
@@ -1,8 +1,12 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Management.Controllers.Security;
using Umbraco.Cms.Api.Management.ServerEvents;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Routing;
using Umbraco.Extensions;
@@ -12,26 +16,36 @@ namespace Umbraco.Cms.Api.Management.Routing;
/// <summary>
/// Creates routes for the back office area.
/// </summary>
public sealed class BackOfficeAreaRoutes : IAreaRoutes
public sealed class BackOfficeAreaRoutes : SignalRRoutesBase, IAreaRoutes
{
private readonly IRuntimeState _runtimeState;
/// <summary>
/// Initializes a new instance of the <see cref="BackOfficeAreaRoutes" /> class.
/// </summary>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public BackOfficeAreaRoutes(IRuntimeState runtimeState)
: this(
runtimeState,
StaticServiceProvider.Instance.GetRequiredService<IOptions<SignalRSettings>>())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="BackOfficeAreaRoutes" /> class.
/// </summary>
public BackOfficeAreaRoutes(IRuntimeState runtimeState)
=> _runtimeState = runtimeState;
public BackOfficeAreaRoutes(IRuntimeState runtimeState, IOptions<SignalRSettings> signalRSettings)
: base(runtimeState, signalRSettings)
{
}
/// <inheritdoc />
public void CreateRoutes(IEndpointRouteBuilder endpoints)
{
if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run)
if (RuntimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run)
{
MapMinimalBackOffice(endpoints);
endpoints.MapHub<BackofficeHub>(Constants.System.UmbracoPathSegment + Constants.Web.BackofficeSignalRHub);
endpoints.MapHub<ServerEventHub>(Constants.System.UmbracoPathSegment + Constants.Web.ServerEventSignalRHub);
endpoints.MapHub<BackofficeHub>(Constants.System.UmbracoPathSegment + Constants.Web.BackofficeSignalRHub, ConfigureHubEndpoint);
endpoints.MapHub<ServerEventHub>(Constants.System.UmbracoPathSegment + Constants.Web.ServerEventSignalRHub, ConfigureHubEndpoint);
}
}
@@ -1,7 +1,11 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Management.Preview;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Routing;
@@ -10,16 +14,29 @@ namespace Umbraco.Cms.Api.Management.Routing;
/// <summary>
/// Creates routes for the preview hub
/// </summary>
public sealed class PreviewRoutes : IAreaRoutes
public sealed class PreviewRoutes : SignalRRoutesBase, IAreaRoutes
{
private readonly IRuntimeState _runtimeState;
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Api.Management.Routing.PreviewRoutes"/> class, configuring preview routing based on the application's runtime state.
/// Initializes a new instance of the <see cref="PreviewRoutes"/> class, configuring preview routing based on the application's runtime state.
/// </summary>
/// <param name="runtimeState">An instance representing the current runtime state of the Umbraco application.</param>
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public PreviewRoutes(IRuntimeState runtimeState)
=> _runtimeState = runtimeState;
: this(
runtimeState,
StaticServiceProvider.Instance.GetRequiredService<IOptions<SignalRSettings>>())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="PreviewRoutes"/> class, configuring preview routing based on the application's runtime state.
/// </summary>
/// <param name="runtimeState">An instance representing the current runtime state of the Umbraco application.</param>
/// <param name="signalRSettings">The SignalR settings options.</param>
public PreviewRoutes(IRuntimeState runtimeState, IOptions<SignalRSettings> signalRSettings)
: base(runtimeState, signalRSettings)
{
}
/// <summary>
/// Creates the preview routes on the specified endpoint route builder.
@@ -27,9 +44,9 @@ public sealed class PreviewRoutes : IAreaRoutes
/// <param name="endpoints">The endpoint route builder to add routes to.</param>
public void CreateRoutes(IEndpointRouteBuilder endpoints)
{
if (_runtimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run)
if (RuntimeState.Level is RuntimeLevel.Install or RuntimeLevel.Upgrade or RuntimeLevel.Upgrading or RuntimeLevel.Run)
{
endpoints.MapHub<PreviewHub>(GetPreviewHubRoute());
endpoints.MapHub<PreviewHub>(GetPreviewHubRoute(), ConfigureHubEndpoint);
}
}
@@ -41,3 +58,4 @@ public sealed class PreviewRoutes : IAreaRoutes
/// </returns>
public string GetPreviewHubRoute() => $"/{Constants.System.UmbracoPathSegment}/{nameof(PreviewHub)}";
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Routing;
/// <summary>
/// Base class for route definitions that map SignalR hub endpoints,
/// applying shared transport configuration from <see cref="SignalRSettings"/>.
/// </summary>
public abstract class SignalRRoutesBase
{
private readonly SignalRSettings _signalRSettings;
/// <summary>
/// Initializes a new instance of the <see cref="SignalRRoutesBase"/> class.
/// </summary>
/// <param name="runtimeState">The current runtime state of the Umbraco application.</param>
/// <param name="signalRSettings">The SignalR settings options.</param>
protected SignalRRoutesBase(IRuntimeState runtimeState, IOptions<SignalRSettings> signalRSettings)
{
RuntimeState = runtimeState;
_signalRSettings = signalRSettings.Value;
}
/// <summary>
/// Gets the current runtime state of the Umbraco application.
/// </summary>
protected IRuntimeState RuntimeState { get; }
/// <summary>
/// Configures the transport options for a SignalR hub endpoint.
/// When <see cref="SignalRSettings.ClientShouldSkipNegotiation"/> is enabled,
/// restricts the endpoint to WebSocket transport only so clients can skip the negotiate round-trip.
/// </summary>
/// <param name="options">The hub endpoint dispatcher options to configure.</param>
protected void ConfigureHubEndpoint(HttpConnectionDispatcherOptions options)
{
if (_signalRSettings.ClientShouldSkipNegotiation)
{
options.Transports = HttpTransportType.WebSockets;
}
}
}
@@ -0,0 +1,64 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Security.Authorization;
/// <summary>
/// Authorizes permissions on all direct children of a node.
/// </summary>
internal static class AllChildrenAuthorizer
{
/// <summary>
/// Determines whether the user is authorized for every direct child of the given parent (or the root).
/// </summary>
/// <param name="authorizationService">The authorization service.</param>
/// <param name="entityService">The entity service used to resolve the children.</param>
/// <param name="user">The current user.</param>
/// <param name="parentKey">The parent key, or <c>null</c> to authorize the root-level children.</param>
/// <param name="objectType">The object type of the children (and parent).</param>
/// <param name="resourceFactory">Builds the permission resource to authorize a batch of child keys against.</param>
/// <param name="policy">The authorization policy to apply.</param>
/// <returns><c>true</c> if the user is authorized against all children; otherwise <c>false</c>.</returns>
public static async Task<bool> IsAuthorizedForChildrenAsync(
IAuthorizationService authorizationService,
IEntityService entityService,
ClaimsPrincipal user,
Guid? parentKey,
UmbracoObjectTypes objectType,
Func<IEnumerable<Guid>, IPermissionResource> resourceFactory,
string policy)
{
const int pageSize = 500;
var page = 0;
long total;
do
{
Guid[] childKeys = entityService
.GetPagedChildren(parentKey, [objectType], objectType, page * pageSize, pageSize, out total)
.Select(child => child.Key)
.ToArray();
if (childKeys.Length > 0)
{
AuthorizationResult authorizationResult = await authorizationService.AuthorizeResourceAsync(
user,
resourceFactory(childKeys),
policy);
if (authorizationResult.Succeeded is false)
{
return false;
}
}
page++;
}
while (page * pageSize < total);
return true;
}
}
File diff suppressed because it is too large Load Diff
@@ -113,15 +113,23 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
return ChildUserAccessEntities(children, userStartNodePaths);
}
private static List<int> GetAllowedIds(string[] userStartNodePaths, int parentId)
/// <summary>
/// For each user start node path that contains <paramref name="parentId"/>, collects the ID of
/// the segment immediately after the parent and returns the distinct set. E.g. given paths
/// ["-1,2,3,4,5", "-1,2,3,9,10"] and a parent ID of 3, the returned "next child IDs" are [4, 9].
/// </summary>
/// <param name="userStartNodePaths">The user's start node paths (comma-delimited integer IDs).</param>
/// <param name="parentId">The parent ID to locate within each path.</param>
/// <returns>The distinct "next child IDs" across all paths that contain the parent.</returns>
/// <remarks>
/// Internal rather than private so it can be unit-tested directly.
/// </remarks>
internal static List<int> GetAllowedIds(string[] userStartNodePaths, int parentId)
{
// If one or more of the user start nodes are descendants of the requested parent, find the "next child IDs" in those user start node paths
// that are the final entries in the path.
// E.g. given the user start node path "-1,2,3,4,5", if the requested parent ID is 3, the "next child ID" is 4.
var userStartNodePathIds = userStartNodePaths.Select(path => path.Split(Constants.CharArrays.Comma).Select(int.Parse).ToArray()).ToArray();
var userStartNodePathIds = userStartNodePaths.Select(path => path.GetIdsFromPath()).ToArray();
return userStartNodePathIds
.Where(ids => ids.Contains(parentId))
.Select(ids => ids[ids.IndexOf(parentId) + 1]) // Given the previous checks, the parent ID can never be the last in the user start node path, so this is safe
.Select(ids => ids[ids.IndexOf(parentId) + 1]) // Given the previous checks, the parent ID can never be the last in the user start node path, so this is safe.
.Distinct()
.ToList();
}
@@ -21,4 +21,9 @@ public class ServerConfigurationResponseModel
/// Gets or sets the relative or absolute path to the Umbraco CSS file used by the application.
/// </summary>
public string UmbracoCssPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the client-side SignalR settings.
/// </summary>
public SignalRClientSettingsResponseModel SignalR { get; set; } = new();
}
@@ -0,0 +1,10 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Server;
/// <summary>
/// Represents client-side SignalR settings returned by the server configuration endpoint.
/// </summary>
public class SignalRClientSettingsResponseModel
{
/// <summary>Gets or sets a value indicating whether the client should skip the SignalR negotiate round-trip.</summary>
public bool SkipNegotiation { get; set; }
}
@@ -0,0 +1,21 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Base request model for sorting the children of a node by a system field.
/// </summary>
public abstract class SortChildrenByFieldRequestModelBase
{
/// <summary>
/// Gets or sets the system field to sort the children by.
/// The create and update dates are node-level (not culture-specific).
/// </summary>
public required ContentSortField Field { get; init; }
/// <summary>
/// Gets or sets the direction to sort in.
/// </summary>
public required Direction Direction { get; init; }
}
@@ -0,0 +1,16 @@
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Request model for sorting the children of a document by a system field.
/// </summary>
public class SortDocumentChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
{
/// <summary>
/// Gets or sets the culture whose variant name to sort by, or <c>null</c> to sort by the invariant name.
/// Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a document that
/// does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.
/// </summary>
public string? Culture { get; init; }
}
@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Request model for sorting the children of a media item by a system field.
/// Media items do not vary by culture, so no culture is accepted.
/// </summary>
public class SortMediaChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
{
}
@@ -0,0 +1,12 @@
namespace Umbraco.Cms.Api.Management.ViewModels.User;
/// <summary>
/// Represents a request model for updating information about a current user.
/// </summary>
public class UpdateCurrentUserRequestModel
{
/// <summary>
/// Gets or sets the ISO code of the user's language.
/// </summary>
public required string LanguageIsoCode { get; set; }
}
@@ -59,7 +59,7 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
private int? _skipver;
private RoslynCompiler? _roslynCompiler;
private ModelsBuilderSettings _config;
private bool _disposedValue;
private volatile bool _disposedValue;
public InMemoryModelFactory(
Lazy<UmbracoServices> umbracoServices,
@@ -280,25 +280,34 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
}
}
// don't use an upgradeable lock here because only 1 thread at a time could enter it
try
// The factory is disposed on application shutdown (via IRegisteredObject.Stop), but in-flight
// requests can still reach this point. Bail out with the current models rather than touching
// the disposed lock. The catch below covers the small window where disposal happens after this
// check but before (or while) the lock is acquired.
if (_disposedValue)
{
_locker.EnterReadLock();
if (_hasModels)
{
return _infos;
}
}
finally
{
if (_locker.IsReadLockHeld)
{
_locker.ExitReadLock();
}
return _infos;
}
try
{
// don't use an upgradeable lock here because only 1 thread at a time could enter it
try
{
_locker.EnterReadLock();
if (_hasModels)
{
return _infos;
}
}
finally
{
if (_locker.IsReadLockHeld)
{
_locker.ExitReadLock();
}
}
_locker.EnterUpgradeableReadLock();
if (_hasModels)
@@ -359,6 +368,12 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
return _infos;
}
catch (ObjectDisposedException ex)
{
// Expected when the factory is disposed during shutdown mid-request; log so an unexpected disposal stays traceable.
_logger.LogDebug(ex, "EnsureModels interrupted by object disposal (assumed application shutdown); returning current models.");
return _infos;
}
finally
{
if (_locker.IsWriteLockHeld)
@@ -15,8 +15,9 @@ SQLite-specific EF Core provider for Umbraco CMS. Contains SQLite migrations and
This is a thin provider project that implements SQLite-specific functionality for the EF Core persistence layer:
1. **Migration Provider** - Executes SQLite-specific migrations
2. **Migration Provider Setup** - Configures DbContext to use SQLite
2. **Migration Provider Setup** - Configures DbContext to use SQLite (incl. transient-error retry)
3. **Migrations** - SQLite-specific migration files for OpenIddict tables
4. **Retrying Execution Strategy** - Retries transient SQLite lock errors on EF Core operations
### Folder Structure
@@ -30,7 +31,8 @@ Umbraco.Cms.Persistence.EFCore.Sqlite/
│ └── UmbracoDbContextModelSnapshot.cs # Current model state
├── EFCoreSqliteComposer.cs # DI registration
├── SqliteMigrationProvider.cs # IMigrationProvider impl
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
└── SqliteRetryingExecutionStrategy.cs # IExecutionStrategy for transient lock errors
```
### Relationship with Parent Project
@@ -65,7 +67,19 @@ Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite.
### SqliteMigrationProviderSetup (line 11-14)
Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly.
Configures `DbContextOptionsBuilder` with `UseSqlite`, the migrations assembly, and the
`SqliteRetryingExecutionStrategy` (see below). Invoked from
`UmbracoDbContext.ConfigureOptions` for every `UmbracoDbContext` instance, so all EF Core
access to the Umbraco database (including OpenIddict's token store) inherits the retry.
### SqliteRetryingExecutionStrategy
Custom `Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy` that retries on transient
SQLite errors (`SQLITE_BUSY`, `SQLITE_LOCKED`) using `SqliteExceptionExtensions.IsBusyOrLocked`
from the parent project. Defaults inherit `ExecutionStrategy.DefaultMaxRetryCount` (6) and
`ExecutionStrategy.DefaultMaxDelay` (30s), giving a ~56-second retry budget — see the class's
XML doc for the rationale and the unattended-upgrade escape hatch for very long migrations.
Added to resolve issue #22939 (OpenIddict token reads failing during long migrations).
---
@@ -122,7 +136,8 @@ All tables prefixed with `umbraco`:
| File | Purpose |
|------|---------|
| `SqliteMigrationProvider.cs` | Migration execution |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration (UseSqlite + retry strategy) |
| `SqliteRetryingExecutionStrategy.cs` | Retry on transient SQLite BUSY/LOCKED errors |
| `EFCoreSqliteComposer.cs` | DI registration |
| `Migrations/*.cs` | Migration files |
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Umbraco.Cms.Core;
using Umbraco.Cms.Persistence.EFCore.Migrations;
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
@@ -15,6 +14,15 @@ public class SqliteMigrationProviderSetup : IMigrationProviderSetup
/// <inheritdoc />
public void Setup(DbContextOptionsBuilder builder, string? connectionString)
{
builder.UseSqlite(connectionString, x => x.MigrationsAssembly(GetType().Assembly.FullName));
builder.UseSqlite(connectionString, x =>
{
x.MigrationsAssembly(GetType().Assembly.FullName);
// Retry transient SQLite errors (BUSY / LOCKED). See SqliteRetryingExecutionStrategy
// for the rationale — long-running migrations or schema-modifying operations can
// briefly lock the database in a way that surfaces as a hard error to concurrent
// EF Core readers (notably OpenIddict token validation). See issue #22939.
x.ExecutionStrategy(deps => new SqliteRetryingExecutionStrategy(deps));
});
}
}
@@ -0,0 +1,71 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore.Storage;
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
/// <summary>
/// EF Core execution strategy that retries on transient SQLite errors (BUSY / LOCKED).
/// </summary>
/// <remarks>
/// <para>
/// SQLite serialises writers at the database level, and schema-modifying statements briefly
/// block readers — even in WAL mode. Without retries, concurrent EF Core reads (for example
/// OpenIddict's token validation against <c>umbracoOpenIddictTokens</c>) surface those
/// transient locks as <see cref="SqliteException"/> and fail the caller's request.
/// </para>
/// <para>
/// Microsoft does not ship a built-in execution strategy for SQLite (only the SQL Server
/// equivalent), so we provide this one. It piggy-backs on <see cref="ExecutionStrategy"/>'s
/// default exponential backoff and re-uses its inherited
/// <see cref="ExecutionStrategy.DefaultMaxRetryCount"/> (6) and
/// <see cref="ExecutionStrategy.DefaultMaxDelay"/> (30 seconds), which produce a delay
/// schedule of roughly 0s, 1s, 3s, 7s, 15s, 30s — a ~56-second retry window.
/// </para>
/// <para>
/// On top of those EF Core delays, <c>SQLITE_BUSY</c> (error 5) is also retried internally
/// by Microsoft.Data.Sqlite for up to the connection's <c>Default Timeout</c> (30 seconds
/// by default) per attempt. <c>SQLITE_LOCKED</c> (error 6) is not — it returns immediately,
/// so EF Core's retry budget is the only buffer.
/// </para>
/// </remarks>
public class SqliteRetryingExecutionStrategy : ExecutionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class
/// with default retry settings inherited from <see cref="ExecutionStrategy"/>.
/// </summary>
/// <param name="dependencies">Parameter object containing service dependencies.</param>
public SqliteRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
: this(dependencies, DefaultMaxRetryCount, DefaultMaxDelay)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class.
/// </summary>
/// <param name="dependencies">Parameter object containing service dependencies.</param>
/// <param name="maxRetryCount">The maximum number of retry attempts.</param>
/// <param name="maxRetryDelay">The maximum delay between retries.</param>
public SqliteRetryingExecutionStrategy(
ExecutionStrategyDependencies dependencies,
int maxRetryCount,
TimeSpan maxRetryDelay)
: base(dependencies, maxRetryCount, maxRetryDelay)
{
}
/// <inheritdoc />
protected override bool ShouldRetryOn(Exception exception)
{
// EF Core wraps provider exceptions, so walk the inner-exception chain.
for (Exception? current = exception; current is not null; current = current.InnerException)
{
if (current is SqliteException sqlite && sqlite.IsBusyOrLocked())
{
return true;
}
}
return false;
}
}
@@ -184,17 +184,11 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
}
}
catch (SqliteException ex) when (IsBusyOrLocked(ex))
catch (SqliteException ex) when (ex.IsBusyOrLocked())
{
throw new DistributedWriteLockTimeoutException(LockId);
}
});
}
private static bool IsBusyOrLocked(SqliteException ex) =>
ex.SqliteErrorCode
is raw.SQLITE_BUSY
or raw.SQLITE_LOCKED
or raw.SQLITE_LOCKED_SHAREDCACHE;
}
}
@@ -0,0 +1,26 @@
using Microsoft.Data.Sqlite;
using SQLitePCL;
namespace Umbraco.Cms.Persistence.EFCore;
/// <summary>
/// SQLite-specific exception helpers for code running on the EF Core persistence stack.
/// </summary>
/// <remarks>
/// A parallel helper exists at <c>Umbraco.Cms.Persistence.Sqlite.Services.SqliteExceptionExtensions</c>
/// for the NPoco stack. Both stacks are independent (neither references the other) so the small
/// duplication is intentional — keeps the layering clean.
/// </remarks>
public static class SqliteExceptionExtensions
{
/// <summary>
/// Determines if the SQLite exception is a BUSY or LOCKED error.
/// </summary>
/// <param name="ex">The SQLite exception to check.</param>
/// <returns><c>true</c> if the error is BUSY, LOCKED, or LOCKED_SHAREDCACHE; otherwise <c>false</c>.</returns>
public static bool IsBusyOrLocked(this SqliteException ex) =>
ex.SqliteErrorCode
is raw.SQLITE_BUSY
or raw.SQLITE_LOCKED
or raw.SQLITE_LOCKED_SHAREDCACHE;
}
@@ -21,7 +21,7 @@
var backOfficeAssetsPath = BackOfficePathGenerator.BackOfficeAssetsPath;
var loginLogoImageAlternative = Url.RouteUrl(BackOfficeGraphicsController.LoginLogoAlternativeRouteName, new {Version= "1"});
}<!doctype html>
<html lang="@GlobalSettings.Value.DefaultUILanguage">
<html lang="en">
<head>
<meta charset="UTF-8" />
@@ -61,7 +61,7 @@
<p>Here are the <a href="https://www.enable-javascript.com/" target="_blank" rel="noopener" style="text-decoration: underline;">instructions how to enable JavaScript in your web browser</a>.</p>
</div>
</noscript>
<umb-app @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
<umb-app lang="@GlobalSettings.Value.DefaultUILanguage" @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
@if (isDebug)
{
@@ -35,7 +35,7 @@
}
<!DOCTYPE html>
<html lang="@GlobalSettings.Value.DefaultUILanguage">
<html lang="en">
<head>
<meta charset="UTF-8"/>
<base href="@backOfficePath.EnsureEndsWith('/')" />
@@ -83,6 +83,7 @@
</noscript>
<umb-auth
lang="@GlobalSettings.Value.DefaultUILanguage"
return-url="@backOfficePath"
logo-image="@loginLogoImage"
logo-image-alternative="@loginLogoImageAlternative"
@@ -16,21 +16,16 @@
</ItemGroup>
<!--
The Razor editor in VS2026 and the C# extension for VS Code uses the Razor source generator
The Razor editor in modern Visual Studio and the C# extension for VS Code use the Razor source generator
for IDE functionality. We need to add some things to make sure it works correctly, but we
only do them for design time builds, so that we don't impact regular builds or CI.
We also have an escape hatch in case it does cause issues, users can set the appropriate property
We also have an escape hatch in case it does cause issues, users can set EnableCohostEditorCompatibility=false
in their project file to disable this.
CompilerVisibleProperty is surfaced to generators via AnalyzerConfigOptionsProvider, not as a source-generator input file,
so it doesn't enter the hintName-collision codepath that AdditionalFiles does. Keeping it at evaluation time is safe.
-->
<ItemGroup Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
<!--
We have to make sure the source generator can see the .cshtml files, so make them AdditionalFiles.
-->
<AdditionalFiles Include="**\*.cshtml" />
<!--
Make sure the source generator knows where the project is, so it can compute target paths.
-->
<CompilerVisibleProperty Include="MSBuildProjectDirectory" />
</ItemGroup>
</Project>
@@ -49,4 +49,39 @@
<ContentWithTargetPath Include="@(_UmbracoFolderFiles)" Exclude="@(ContentWithTargetPath)" TargetPath="%(Identity)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Target>
<!--
The Razor source generator needs .cshtml files in @(AdditionalFiles). The Razor SDK adds them
via @(RazorGenerate), but only inside a target that runs during the build — so during cohost
design-time builds they may not be present yet, which is what PR #21861 worked around.
Doing the include at evaluation time (as PR #21861 did) causes duplicates with the SDK during
dotnet watch / hot reload design-time builds: the SDK adds the same .cshtml under a different
item Identity (slash form / relative vs absolute) and the generator then sees two inputs that
derive the same hintName, which crashes it with CS8785 (see issue #22773).
Run as a target before CoreCompile (hot-reload path) and CompileDesignTime (IDE design-time path)
so the SDK's contribution is visible in both cases. Then add only the .cshtml files that are not already
present. Both sides are normalized to %(FullPath) so items with different Identity forms still compare equal.
Set EnableCohostEditorCompatibility=false in a project to opt out entirely.
-->
<Target Name="_UmbracoEnsureRazorAdditionalFilesForCohostEditor"
BeforeTargets="CoreCompile;CompileDesignTime"
Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
<ItemGroup>
<_UmbracoCshtmlCandidate Include="**\*.cshtml" />
<_UmbracoCshtmlCandidateFull Include="@(_UmbracoCshtmlCandidate->'%(FullPath)')" />
<_UmbracoExistingAdditionalCshtmlFull
Include="@(AdditionalFiles->'%(FullPath)')"
Condition="'%(Extension)' == '.cshtml'" />
<_UmbracoCshtmlMissingFromAdditional
Include="@(_UmbracoCshtmlCandidateFull)"
Exclude="@(_UmbracoExistingAdditionalCshtmlFull)" />
<AdditionalFiles Include="@(_UmbracoCshtmlMissingFromAdditional)" />
</ItemGroup>
</Target>
</Project>
+2
View File
@@ -305,6 +305,8 @@ public class MyEntityCacheRefresher : CacheRefresherBase<MyEntityCacheRefresher>
- `Attempt.Succeed(value)` / `Attempt.Fail<T>()`
- `Attempt<Content, ContentEditingOperationStatus>` - typed result with status
> Writing or reviewing a query with a `WHERE IN` on a runtime-sized collection? See "Avoiding the SQL Server 2100-parameter limit" in `/src/Umbraco.Infrastructure/CLAUDE.md` — that's where the full helper list (`Constants.Sql.MaxParameterCount`, `InGroupsOf`, NPoco's `FetchByGroups`) and the decision rules live.
### Configuration
Configuration models in `/Configuration/Models`:
@@ -0,0 +1,12 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Defines an asynchronous handler for a <typeparamref name="TNotification" /> that should be invoked when notifications are dispatched in a distributed cache scope (e.g. to trigger a distributed cache refresher).
/// </summary>
/// <typeparam name="TNotification">The type of the notification.</typeparam>
public interface IDistributedCacheAsyncNotificationHandler<in TNotification> : INotificationAsyncHandler<TNotification>, IDistributedCacheNotificationHandler
where TNotification : INotification
{ }
@@ -23,5 +23,14 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
/// <inheritdoc />
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
=> _distributedCache.RemoveLanguageCache(entities);
{
_distributedCache.RemoveLanguageCache(entities);
// User groups cache their allowed language ids, so a deleted language must be evicted from
// them too - otherwise a stale, now-missing id lingers on the cached user group and breaks
// reads that resolve those ids. This is a deliberately coarse refresh of the entire user group
// and user caches (RefreshAll also clears IUser): we can't know which groups reference the
// language without a query, and language deletion is rare enough that a full refresh is fine.
_distributedCache.RefreshAllUserGroupCache();
}
}
+10 -1
View File
@@ -368,8 +368,17 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
}
// Ensure key is removed from set when evicted from cache
return options.RegisterPostEvictionCallback((key, _, _, _) =>
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
{
// Removed and Replaced evictions don't need pruning here: the Remove/Clear call sites already
// prune the tracking set synchronously under the write lock, and a Replaced key still has a
// live entry (the synchronous Set re-added it). Pruning here instead runs on a background
// thread and races with that re-add, dropping a key whose entry is still cached. (#23064)
if (reason is EvictionReason.Removed or EvictionReason.Replaced)
{
return;
}
try
{
if (_locker.TryEnterWriteLock(_writeLockTimeout) is false)
@@ -36,6 +36,7 @@ public interface IConfigManipulator
/// </summary>
/// <param name="disable">The value to save.</param>
/// <returns></returns>
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
Task SaveDisableRedirectUrlTrackingAsync(bool disable);
/// <summary>
@@ -16,6 +16,11 @@ public class ContentSettings
/// </summary>
internal const bool StaticResolveUrlsFromTextString = false;
/// <summary>
/// The default value for whether sorting children by a field fires per-item notifications.
/// </summary>
internal const bool StaticSortChildrenByFieldFiresNotifications = false;
/// <summary>
/// The default preview badge markup template.
/// </summary>
@@ -110,6 +115,18 @@ public class ContentSettings
[DefaultValue(StaticResolveUrlsFromTextString)]
public bool ResolveUrlsFromTextString { get; set; } = StaticResolveUrlsFromTextString;
/// <summary>
/// Gets or sets a value indicating whether sorting the children of a node by a field fires
/// per-item save/sort notifications (and therefore webhooks).
/// </summary>
/// <remarks>
/// Defaults to <c>false</c>: the children are reordered with a single set-based update and a branch
/// cache refresh, without per-item notifications. Set to <c>true</c> to restore per-item notifications
/// (and webhooks), accepting the additional performance cost on nodes with many children.
/// </remarks>
[DefaultValue(StaticSortChildrenByFieldFiresNotifications)]
public bool SortChildrenByFieldFiresNotifications { get; set; } = StaticSortChildrenByFieldFiresNotifications;
/// <summary>
/// Gets or sets a value for the collection of error pages.
/// </summary>
@@ -30,6 +30,17 @@ public class DatabaseServerMessengerSettings
/// </summary>
internal const string StaticTimeBetweenPruneOperations = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// The default timeout for a single synchronization operation.
/// </summary>
internal const string StaticSyncTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single synchronization operation, for use as a fallback when an invalid
/// <see cref="SyncTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultSyncTimeout = TimeSpan.Parse(StaticSyncTimeout);
/// <summary>
/// Gets or sets a value for the maximum number of instructions that can be processed at startup; otherwise the server
/// cold-boots (rebuilds its caches).
@@ -55,4 +66,13 @@ public class DatabaseServerMessengerSettings
/// </summary>
[DefaultValue(StaticTimeBetweenPruneOperations)]
public TimeSpan TimeBetweenPruneOperations { get; set; } = TimeSpan.Parse(StaticTimeBetweenPruneOperations);
/// <summary>
/// Gets or sets the maximum time to wait for a single synchronization operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single sync,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticSyncTimeout)]
public TimeSpan SyncTimeout { get; set; } = DefaultSyncTimeout;
}
@@ -20,6 +20,17 @@ public class DatabaseServerRegistrarSettings
/// </summary>
internal const string StaticStaleServerTimeout = "00:02:00";
/// <summary>
/// The default timeout for a single server touch operation.
/// </summary>
internal const string StaticTouchTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single server touch operation, for use as a fallback when an invalid
/// <see cref="TouchTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultTouchTimeout = TimeSpan.Parse(StaticTouchTimeout);
/// <summary>
/// Gets or sets a value for the amount of time to wait between calls to the database on the background thread.
/// </summary>
@@ -31,4 +42,13 @@ public class DatabaseServerRegistrarSettings
/// </summary>
[DefaultValue(StaticStaleServerTimeout)]
public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.Parse(StaticStaleServerTimeout);
/// <summary>
/// Gets or sets the maximum time to wait for a single server touch operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single touch,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticTouchTimeout)]
public TimeSpan TouchTimeout { get; set; } = DefaultTouchTimeout;
}
@@ -20,5 +20,12 @@ public class IndexingSettings
/// <summary>
/// Gets or sets a value for how many items to index at a time.
/// </summary>
/// <remarks>
/// This is the primary lever for the peak memory used while (re)building an index: a full page of
/// content and its property data is held in memory at once, so lowering this value reduces rebuild
/// memory at the cost of more, smaller batches. Lower it on very large sites that hit memory pressure
/// during a rebuild.
/// </remarks>
[DefaultValue(StaticBatchSize)]
public int BatchSize { get; set; } = StaticBatchSize;
}
@@ -32,6 +32,11 @@ public class LoggingSettings
/// </summary>
internal const string StaticFileNameFormatArguments = "MachineName";
/// <summary>
/// The default mode for enriching log events with a session identifier.
/// </summary>
internal const SessionIdLoggingMode StaticSessionIdLogging = SessionIdLoggingMode.SessionId;
/// <summary>
/// Gets or sets a value for the maximum age of a log file.
/// </summary>
@@ -70,4 +75,16 @@ public class LoggingSettings
/// </remarks>
[DefaultValue(StaticFileNameFormatArguments)]
public string FileNameFormatArguments { get; set; } = StaticFileNameFormatArguments;
/// <summary>
/// Gets or sets a value determining how log events are enriched with a session identifier.
/// </summary>
/// <remarks>
/// Defaults to <see cref="SessionIdLoggingMode.SessionId" /> for backward compatibility. Set to
/// <see cref="SessionIdLoggingMode.CookieHash" /> or <see cref="SessionIdLoggingMode.None" /> to avoid the
/// blocking session-store load that resolving the actual session id incurs per request when the session is
/// backed by an <c>IDistributedCache</c>.
/// </remarks>
[DefaultValue(StaticSessionIdLogging)]
public SessionIdLoggingMode SessionIdLogging { get; set; } = StaticSessionIdLogging;
}
@@ -0,0 +1,33 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Settings for scheduled publishing.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigScheduledPublishing)]
public class ScheduledPublishingSettings
{
private const string StaticPeriod = "00:01:00";
private const bool StaticAlignToClock = false; // TODO (V19): Switch this to true.
/// <summary>
/// Gets or sets a value for how often scheduled publishing runs.
/// </summary>
[DefaultValue(StaticPeriod)]
public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod);
/// <summary>
/// Gets or sets a value indicating whether scheduled publishing runs are aligned to clock boundaries
/// derived from <see cref="Period" /> (for example, on the minute, or every N seconds), rather than drifting
/// based on when the previous run completed.
/// </summary>
/// <remarks>
/// When enabled, <see cref="Period" /> must be a whole number of seconds that divides evenly into one hour
/// (for example 10, 12, 15, 20, 30 or 60 seconds) so that boundaries land on consistent clock times.
/// Boundaries are anchored to <strong>UTC</strong>, not the server's local time zone; for sub-minute and
/// whole-minute periods this is indistinguishable from local time at the second level.
/// </remarks>
[DefaultValue(StaticAlignToClock)]
public bool AlignToClock { get; set; } = StaticAlignToClock;
}
@@ -0,0 +1,29 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Determines how request logging enriches log events with a session identifier.
/// </summary>
public enum SessionIdLoggingMode
{
/// <summary>
/// Do not enrich log events with a session identifier.
/// </summary>
None = 0,
/// <summary>
/// Enrich log events with the actual ASP.NET Core session id. This is the default and matches the
/// historical behaviour, but reading the session id forces the session to be loaded from its store, which
/// is a blocking round-trip per request when the session is backed by an <c>IDistributedCache</c>.
/// </summary>
SessionId,
/// <summary>
/// Enrich log events with a one-way hash of the session cookie value. This provides the same per-session
/// correlation as <see cref="SessionId" /> without loading the session from its store, so it never incurs
/// a distributed-cache round-trip.
/// </summary>
CookieHash,
}
@@ -0,0 +1,38 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Typed configuration options for SignalR settings.
/// </summary>
/// <remarks>
/// <para>
/// When <see cref="ClientShouldSkipNegotiation"/> is enabled, all hub endpoints are restricted
/// to WebSocket transport and the client skips the negotiate round-trip. The setting is forwarded
/// to the client via the <c>/umbraco/management/api/v1/server/configuration</c> endpoint.
/// </para>
/// <para>
/// Downstream packages (e.g. Umbraco Cloud) can configure these settings via
/// <c>IConfigureOptions&lt;SignalRSettings&gt;</c> or <c>appsettings.json</c>
/// under <c>Umbraco:CMS:SignalR</c>.
/// </para>
/// </remarks>
[UmbracoOptions(Constants.Configuration.ConfigSignalR)]
public class SignalRSettings
{
internal const bool StaticClientShouldSkipNegotiation = false;
/// <summary>
/// Gets or sets a value indicating whether the client should skip the SignalR negotiate
/// round-trip and connect directly via WebSockets.
/// </summary>
/// <remarks>
/// When <c>true</c>, the server restricts all hub endpoints to the WebSocket transport only
/// (via <c>HttpConnectionDispatcherOptions.Transports</c>) and the client is instructed to
/// set <c>skipNegotiation = true</c> with <c>transport = WebSockets</c>. This eliminates the
/// negotiate HTTP request that causes failures in load-balanced deployments without sticky sessions.
/// This is safe for self-hosted SignalR but must <b>not</b> be used with Azure SignalR Service.
/// </remarks>
[DefaultValue(StaticClientShouldSkipNegotiation)]
public bool ClientShouldSkipNegotiation { get; set; } = StaticClientShouldSkipNegotiation;
}
@@ -0,0 +1,43 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
namespace Umbraco.Cms.Core.Configuration.Models.Validation;
/// <summary>
/// Validator for configuration represented as <see cref="ScheduledPublishingSettings" />.
/// </summary>
public class ScheduledPublishingSettingsValidator : ConfigurationValidatorBase, IValidateOptions<ScheduledPublishingSettings>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, ScheduledPublishingSettings options)
{
if (options.Period <= TimeSpan.Zero)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be greater than zero.");
}
if (options.AlignToClock && IsCleanDivisorOfAnHour(options.Period) == false)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be a whole number of seconds that divides evenly into one hour (3600 seconds) when {Constants.Configuration.ConfigScheduledPublishing}:AlignToClock is enabled, e.g. 10, 12, 15, 20, 30 or 60 seconds.");
}
return ValidateOptionsResult.Success;
}
private static bool IsCleanDivisorOfAnHour(TimeSpan period)
{
var totalSeconds = period.TotalSeconds;
// Must be a positive, whole number of seconds (no sub-second component).
if (totalSeconds <= 0 || totalSeconds != Math.Floor(totalSeconds))
{
return false;
}
return 3600 % (long)totalSeconds == 0;
}
}
@@ -291,6 +291,11 @@ public static partial class Constants
/// </summary>
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
/// <summary>
/// The configuration key for scheduled publishing settings.
/// </summary>
public const string ConfigScheduledPublishing = ConfigPrefix + "ScheduledPublishing";
/// <summary>
/// The configuration key for backoffice token cookie settings.
/// </summary>
@@ -302,6 +307,11 @@ public static partial class Constants
/// </summary>
public const string ConfigWebsite = ConfigPrefix + "Website";
/// <summary>
/// The configuration key for SignalR settings.
/// </summary>
public const string ConfigSignalR = ConfigPrefix + "SignalR";
/// <summary>
/// Contains constants for named options used in configuration.
/// </summary>
@@ -47,6 +47,17 @@ public static partial class Constants
public const string RuntimeModeCheck = "https://docs.umbraco.com/umbraco-cms/fundamentals/setup/server-setup/runtime-modes";
}
/// <summary>
/// Contains documentation links for data health checks.
/// </summary>
public static class Data
{
/// <summary>
/// The documentation link for untrusted database constraints check.
/// </summary>
public const string UntrustedConstraintsCheck = "https://umbra.co/healthchecks-untrusted-constraints";
}
/// <summary>
/// Contains documentation links for configuration health checks.
/// </summary>
@@ -13,6 +13,7 @@ public static partial class Constants
/// <summary>
/// Name for http client which ignores certificate errors.
/// </summary>
[Obsolete("Register a project specific named HttpClient with DangerousAcceptAnyServerCertificateValidator if this behavior is required. Scheduled for removal in Umbraco 19.")]
public const string IgnoreCertificateErrors = "Umbraco:HttpClients:IgnoreCertificateErrors";
/// <summary>
@@ -108,7 +108,7 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
var contentPath = _apiContentPathProvider.GetContentPath(content, culture);
// in some scenarios the published content is actually routable, but due to the built-in handling of i.e. lacking culture setup
// the URL provider resolves the content URL as empty string or "#". since the Delivery API handles routing explicitly,
// the URL provider resolves the content URL as empty or unrouetable. since the Delivery API handles routing explicitly,
// we can perform fallback to the content route.
if (IsInvalidContentPath(contentPath))
{
@@ -131,7 +131,7 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
private string ContentPreviewPath(IPublishedContent content) => $"{Constants.DeliveryApi.Routing.PreviewContentPathPrefix}{content.Key:D}{(_requestSettings.AddTrailingSlash ? "/" : string.Empty)}";
private static bool IsInvalidContentPath(string? path) => path.IsNullOrWhiteSpace() || "#".Equals(path);
private static bool IsInvalidContentPath(string? path) => path.IsNullOrWhiteSpace() || Constants.Routing.Unroutable.Equals(path);
private IPublishedContent? GetRoot(IPublishedContent content, bool isPreview)
{
@@ -57,6 +57,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<SecuritySettings>, SecuritySettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<ScheduledPublishingSettings>, ScheduledPublishingSettingsValidator>();
// Register configuration sections.
// TODO (V18): Remove the registrations of UserPasswordConfigurationSettings and MemberPasswordConfigurationSettings.
@@ -102,8 +103,10 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<CacheSettings>()
.AddUmbracoOptions<SystemDateMigrationSettings>()
.AddUmbracoOptions<DistributedJobSettings>()
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
.AddUmbracoOptions<WebsiteSettings>();
.AddUmbracoOptions<WebsiteSettings>()
.AddUmbracoOptions<SignalRSettings>();
// Configure connection string and ensure it's updated when the configuration changes
builder.Services.AddSingleton<IConfigureOptions<ConnectionStrings>, ConfigureConnectionStrings>();
@@ -405,7 +405,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -454,7 +454,8 @@
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
@@ -463,7 +463,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -452,7 +452,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
<key alias="clickJackingCheckHeaderFound">
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
<key alias="clickJackingCheckHeaderNotFound">
@@ -403,7 +403,8 @@
0: Comma delimitted list of failed folder paths
-->
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
<!-- The following key get these tokens passed in:
0: Comma delimitted list of headers found
-->
@@ -75,29 +75,40 @@ public static class PublishedContentExtensions
[Obsolete("Please use GetUrlSegment() on IDocumentUrlService instead. Scheduled for removal in Umbraco 18.")]
public static string? UrlSegment(this IPublishedContent content, IVariationContextAccessor? variationContextAccessor, string? culture = null)
{
if (content == null)
ArgumentNullException.ThrowIfNull(content);
// The obsolete accessor only meaningfully applies to documents — the documented replacement is
// IDocumentUrlService, and media/members never had user-facing URL segments.
if (content.ItemType != PublishedItemType.Content)
{
throw new ArgumentNullException(nameof(content));
return null;
}
// invariant has invariant value (whatever the requested culture)
if (!content.ContentType.VariesByCulture())
string effectiveCulture = content.ContentType.VariesByCulture() is false
? string.Empty
: culture ?? variationContextAccessor?.VariationContext?.Culture ?? string.Empty;
// Variant content with no resolved culture has no associated segment; avoid an unnecessary
// ILanguageService.GetAsync("") lookup inside the service.
if (content.ContentType.VariesByCulture() && effectiveCulture.Length == 0)
{
return content.Cultures.TryGetValue(string.Empty, out PublishedCultureInfo? invariantInfos)
? invariantInfos.UrlSegment
return null;
}
// Use IDocumentUrlService to get the URL segment, aligning with the non-obsolete recommended approach.
// Fall back to in-memory lookup when the service isn't usable — either DI hasn't been bootstrapped
// (unit tests) or the service hasn't been initialised (integration tests not running full Umbraco
// startup). In production both hold.
IDocumentUrlService? documentUrlService = StaticServiceProvider.Instance?.GetService<IDocumentUrlService>();
if (documentUrlService is null || documentUrlService.IsInitialized is false)
{
return content.Cultures.TryGetValue(effectiveCulture, out PublishedCultureInfo? infos)
? infos.UrlSegment
: null;
}
// handle context culture for variant
if (culture == null)
{
culture = variationContextAccessor?.VariationContext?.Culture ?? string.Empty;
}
// get
return culture != string.Empty && content.Cultures.TryGetValue(culture, out PublishedCultureInfo? infos)
? infos.UrlSegment
: null;
var isDraft = content.IsDraft(effectiveCulture.Length == 0 ? null : effectiveCulture);
return documentUrlService.GetUrlSegment(content.Key, effectiveCulture, isDraft);
}
#endregion
@@ -153,7 +164,9 @@ public static class PublishedContentExtensions
// parent key is null if content is at root
return parentKey.HasValue
? publishedStatusFilteringService.FilterAvailable([parentKey.Value], null).FirstOrDefault()
#pragma warning disable CS0618 // Type or member is obsolete (justification: temporary means to avoid breaking changes in the PublishedContentExtensions)
? publishedStatusFilteringService.Unfiltered([parentKey.Value]).FirstOrDefault()
#pragma warning restore CS0618 // Type or member is obsolete
: null;
}
@@ -2217,9 +2230,9 @@ public static class PublishedContentExtensions
// with a non-existing published node, will get cache misses and call the DB
// making it a very slow operation.
return publishedStatusFilteringService
.FilterAvailable(childrenKeys, culture)
.OrderBy(x => x.SortOrder);
// INavigationQueryService.TryGetChildrenKeys returns keys already ordered by SortOrder
// and FilterAvailable preserves enumeration order, so no further OrderBy is needed.
return publishedStatusFilteringService.FilterAvailable(childrenKeys, culture);
}
private static IEnumerable<IPublishedContent> EnumerateDescendantsOrSelfInternal(
@@ -2261,8 +2274,7 @@ public static class PublishedContentExtensions
INavigationQueryService navigationQueryService,
IPublishedStatusFilteringService publishedStatusFilteringService,
bool orSelf,
string? contentTypeAlias = null,
string? culture = null)
string? contentTypeAlias = null)
{
if (orSelf)
{
@@ -2281,7 +2293,9 @@ public static class PublishedContentExtensions
yield break;
}
IEnumerable<IPublishedContent> ancestors = publishedStatusFilteringService.FilterAvailable(ancestorsKeys, culture);
#pragma warning disable CS0618 // Type or member is obsolete (justification: temporary means to avoid breaking changes in the PublishedContentExtensions)
IEnumerable<IPublishedContent> ancestors = publishedStatusFilteringService.Unfiltered(ancestorsKeys);
#pragma warning restore CS0618 // Type or member is obsolete
foreach (IPublishedContent ancestor in ancestors)
{
yield return ancestor;
@@ -730,6 +730,10 @@ public static partial class StringExtensions
/// </summary>
/// <param name="fileName">The file name to convert.</param>
/// <returns>A friendly name with the extension stripped, underscores and dashes converted to spaces, and title case applied.</returns>
/// <remarks>
/// Mirrored client-side in <c>src/Umbraco.Web.UI.Client/src/packages/media/media/utils/to-friendly-name.function.ts</c>;
/// keep the two implementations in sync.
/// </remarks>
public static string ToFriendlyName(this string fileName)
{
// strip the file extension
@@ -21,11 +21,16 @@ public static partial class StringExtensions
#pragma warning restore IDE1006 // Naming Styles
/// <summary>
/// Converts a path string to an array of node IDs in reverse order (deepest to shallowest).
/// Converts a path string to an array of node IDs, in path order (shallowest to deepest).
/// </summary>
/// <param name="path">The path string, expected as a comma-delimited collection of integers.</param>
/// <returns>An array of integers matching the provided path, in reverse order.</returns>
public static int[] GetIdsFromPathReversed(this string path)
/// <returns>An array of integers matching the provided path.</returns>
/// <remarks>
/// Parsing uses <see cref="CultureInfo.InvariantCulture"/> because paths persist the root
/// marker as ASCII "-1", which fails to parse under cultures whose
/// <see cref="NumberFormatInfo.NegativeSign"/> is not the ASCII hyphen-minus.
/// </remarks>
public static int[] GetIdsFromPath(this string path)
{
ReadOnlySpan<char> pathSpan = path.AsSpan();
@@ -44,14 +49,22 @@ public static partial class StringExtensions
}
}
var result = new int[nodeIds.Count];
var resultIndex = 0;
for (int i = nodeIds.Count - 1; i >= 0; i--)
{
result[resultIndex++] = nodeIds[i];
}
return [.. nodeIds];
}
return result;
/// <summary>
/// Converts a path string to an array of node IDs in reverse order (deepest to shallowest).
/// </summary>
/// <param name="path">The path string, expected as a comma-delimited collection of integers.</param>
/// <returns>An array of integers matching the provided path, in reverse order.</returns>
/// <remarks>
/// See <see cref="GetIdsFromPath"/> for parsing semantics.
/// </remarks>
public static int[] GetIdsFromPathReversed(this string path)
{
int[] ids = path.GetIdsFromPath();
Array.Reverse(ids);
return ids;
}
/// <summary>
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
private HealthCheckStatus CheckUmbracoApplicationUrl()
{
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
var url = settings.UmbracoApplicationUrl;
string resultMessage;
StatusResultType resultType;
var success = false;
if (url.IsNullOrWhiteSpace())
if (url.IsNullOrWhiteSpace() is false)
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
resultType = StatusResultType.Success;
}
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
{
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
resultType = StatusResultType.Error;
}
else
{
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
resultType = StatusResultType.Success;
success = true;
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
resultType = StatusResultType.Warning;
}
return new HealthCheckStatus(resultMessage)
{
ResultType = resultType,
ReadMoreLink = success
ReadMoreLink = resultType == StatusResultType.Success
? null
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
};
+63 -32
View File
@@ -36,7 +36,14 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// <summary>
/// Gets the dictionary of shadow nodes tracking file and directory changes.
/// </summary>
private Dictionary<string, ShadowNode> Nodes => _nodes ??= new Dictionary<string, ShadowNode>();
/// <remarks>
/// Uses <see cref="StringComparer.OrdinalIgnoreCase"/> so the shadow exposes case-insensitive
/// path semantics (matching Windows file system behavior) while preserving the original case
/// of paths. Preserving case is required for <see cref="Complete"/>: the stored key is also
/// used to locate the shadow file via <c>_sfs.GetFullPath</c>, which on case-sensitive
/// file systems (e.g. Linux) must match the case the file was actually written with.
/// </remarks>
private Dictionary<string, ShadowNode> Nodes => _nodes ??= new Dictionary<string, ShadowNode>(StringComparer.OrdinalIgnoreCase);
/// <inheritdoc />
public IEnumerable<string> GetDirectories(string path)
@@ -66,7 +73,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
var normPath = NormPath(path);
if (recursive)
{
Nodes[normPath] = new ShadowNode(true, true);
Nodes[normPath] = new ShadowNode(true, true, normPath);
var remove = Nodes.Where(x => IsDescendant(normPath, x.Key)).ToList();
foreach (KeyValuePair<string, ShadowNode> kvp in remove)
{
@@ -84,7 +91,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Directory is not empty.");
}
Nodes[path] = new ShadowNode(true, true);
Nodes[normPath] = new ShadowNode(true, true, normPath);
var remove = Nodes.Where(x => IsChild(normPath, x.Key)).ToList();
foreach (KeyValuePair<string, ShadowNode> kvp in remove)
{
@@ -131,7 +138,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
if (sd.IsDelete)
{
Nodes[dirPath] = new ShadowNode(false, true);
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
}
}
else
@@ -146,12 +153,13 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
Nodes[dirPath] = new ShadowNode(false, true);
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
}
}
_sfs.AddFile(path, stream, overrideIfExists);
Nodes[normPath] = new ShadowNode(false, false);
var canonicalPath = sf?.CanonicalPath ?? path;
_sfs.AddFile(canonicalPath, stream, overrideIfExists);
Nodes[normPath] = new ShadowNode(false, false, canonicalPath);
}
/// <inheritdoc />
@@ -178,7 +186,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
if (Nodes.TryGetValue(NormPath(path), out ShadowNode? sf))
{
return sf.IsDir || sf.IsDelete ? Stream.Null : _sfs.OpenFile(path);
return sf.IsDir || sf.IsDelete ? Stream.Null : _sfs.OpenFile(sf.CanonicalPath);
}
return Inner.OpenFile(path);
@@ -192,7 +200,8 @@ internal sealed partial class ShadowFileSystem : IFileSystem
return;
}
Nodes[NormPath(path)] = new ShadowNode(true, false);
var normPath = NormPath(path);
Nodes[normPath] = new ShadowNode(true, false, normPath);
}
/// <inheritdoc />
@@ -226,7 +235,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
if (sd.IsDelete)
{
Nodes[dirPath] = new ShadowNode(false, true);
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
}
}
else
@@ -241,13 +250,15 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
Nodes[dirPath] = new ShadowNode(false, true);
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
}
}
_sfs.MoveFile(normSource, normTarget, overrideIfExists);
Nodes[normSource] = new ShadowNode(true, false);
Nodes[normTarget] = new ShadowNode(false, false);
var sourceCanonical = sf?.CanonicalPath ?? normSource;
var targetCanonical = tf?.CanonicalPath ?? normTarget;
_sfs.MoveFile(sourceCanonical, targetCanonical, overrideIfExists);
Nodes[normSource] = new ShadowNode(true, false, sourceCanonical);
Nodes[normTarget] = new ShadowNode(false, false, targetCanonical);
}
/// <inheritdoc />
@@ -269,7 +280,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
if (Nodes.TryGetValue(NormPath(path), out ShadowNode? sf))
{
return sf.IsDir || sf.IsDelete ? string.Empty : _sfs.GetFullPath(path);
return sf.IsDir || sf.IsDelete ? string.Empty : _sfs.GetFullPath(sf.CanonicalPath);
}
return Inner.GetFullPath(path);
@@ -291,7 +302,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
return _sfs.GetLastModified(path);
return _sfs.GetLastModified(sf.CanonicalPath);
}
/// <inheritdoc />
@@ -307,7 +318,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
return _sfs.GetCreated(path);
return _sfs.GetCreated(sf.CanonicalPath);
}
/// <inheritdoc />
@@ -323,7 +334,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
return _sfs.GetSize(path);
return _sfs.GetSize(sf.CanonicalPath);
}
/// <inheritdoc />
@@ -348,7 +359,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
if (sd.IsDelete)
{
Nodes[dirPath] = new ShadowNode(false, true);
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
}
}
else
@@ -363,12 +374,13 @@ internal sealed partial class ShadowFileSystem : IFileSystem
throw new InvalidOperationException("Invalid path.");
}
Nodes[dirPath] = new ShadowNode(false, true);
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
}
}
_sfs.AddFile(path, physicalPath, overrideIfExists, copy);
Nodes[normPath] = new ShadowNode(false, false);
var canonicalPath = sf?.CanonicalPath ?? path;
_sfs.AddFile(canonicalPath, physicalPath, overrideIfExists, copy);
Nodes[normPath] = new ShadowNode(false, false, canonicalPath);
}
/// <summary>
@@ -393,11 +405,11 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
if (Inner.CanAddPhysical)
{
Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Key)); // overwrite, move
Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Value.CanonicalPath)); // overwrite, move
}
else
{
using (Stream stream = _sfs.OpenFile(kvp.Key))
using (Stream stream = _sfs.OpenFile(kvp.Value.CanonicalPath))
{
Inner.AddFile(kvp.Key, stream, true);
}
@@ -441,11 +453,15 @@ internal sealed partial class ShadowFileSystem : IFileSystem
}
/// <summary>
/// Normalizes a path to lowercase with forward slashes.
/// Normalizes a path's directory separators to forward slashes.
/// </summary>
/// <param name="path">The path to normalize.</param>
/// <returns>The normalized path.</returns>
private static string NormPath(string path) => path.ToLowerInvariant().Replace("\\", "/");
/// <remarks>
/// Case is preserved. Case-insensitive matching is handled by <see cref="Nodes"/>'s
/// <see cref="StringComparer.OrdinalIgnoreCase"/> comparer.
/// </remarks>
private static string NormPath(string path) => path.Replace("\\", "/");
/// <summary>
/// Determines whether the input path is a direct child of the specified path.
@@ -456,7 +472,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// <remarks>Values can be "" (root), "foo", "foo/bar"...</remarks>
private static bool IsChild(string path, string input)
{
if (input.StartsWith(path) == false || input.Length < path.Length + 2)
if (input.StartsWith(path, StringComparison.OrdinalIgnoreCase) == false || input.Length < path.Length + 2)
{
return false;
}
@@ -466,7 +482,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
return false;
}
var pos = input.IndexOf("/", path.Length + 1, StringComparison.OrdinalIgnoreCase);
var pos = input.IndexOf('/', path.Length + 1);
return pos < 0;
}
@@ -478,7 +494,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// <returns><c>true</c> if input is a descendant of path; otherwise, <c>false</c>.</returns>
private static bool IsDescendant(string path, string input)
{
if (input.StartsWith(path) == false || input.Length < path.Length + 2)
if (input.StartsWith(path, StringComparison.OrdinalIgnoreCase) == false || input.Length < path.Length + 2)
{
return false;
}
@@ -495,12 +511,14 @@ internal sealed partial class ShadowFileSystem : IFileSystem
{
foreach (var file in Inner.GetFiles(path))
{
Nodes[NormPath(file)] = new ShadowNode(true, false);
var normFile = NormPath(file);
Nodes[normFile] = new ShadowNode(true, false, normFile);
}
foreach (var dir in Inner.GetDirectories(path))
{
Nodes[NormPath(dir)] = new ShadowNode(true, true);
var normDir = NormPath(dir);
Nodes[normDir] = new ShadowNode(true, true, normDir);
if (recurse)
{
Delete(dir, true);
@@ -612,10 +630,12 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// </summary>
/// <param name="isDelete">Whether this node represents a deletion.</param>
/// <param name="isdir">Whether this node represents a directory.</param>
public ShadowNode(bool isDelete, bool isdir)
/// <param name="canonicalPath">The original-case path tracked by this node.</param>
public ShadowNode(bool isDelete, bool isdir, string canonicalPath)
{
IsDelete = isDelete;
IsDir = isdir;
CanonicalPath = canonicalPath;
}
/// <summary>
@@ -628,6 +648,17 @@ internal sealed partial class ShadowFileSystem : IFileSystem
/// </summary>
public bool IsDir { get; }
/// <summary>
/// Gets the original-case path tracked by this node. For existing-file nodes this is
/// the path used the first time the file was staged in the current shadow scope.
/// </summary>
/// <remarks>
/// All operations against the inner shadow file system (<c>_sfs</c>) must use this
/// path so that re-staging the same logical path with a different case still reaches
/// the same on-disk file on case-sensitive file systems (e.g. Linux).
/// </remarks>
public string CanonicalPath { get; }
/// <summary>
/// Gets a value indicating whether this node represents an existing item (not deleted).
/// </summary>
@@ -0,0 +1,22 @@
namespace Umbraco.Cms.Core.Models.ContentEditing;
/// <summary>
/// Represents a system field that a node's children can be sorted by.
/// </summary>
public enum ContentSortField
{
/// <summary>
/// Sort by the node's name.
/// </summary>
Name,
/// <summary>
/// Sort by the date the node was created.
/// </summary>
CreateDate,
/// <summary>
/// Sort by the date the node was last updated.
/// </summary>
UpdateDate,
}
@@ -454,7 +454,24 @@ public static class ContentRepositoryExtensions
/// Clears all publish culture information from the content item.
/// </summary>
/// <param name="content">The content item to clear publish information from.</param>
public static void ClearPublishInfos(this IContent content) => content.PublishCultureInfos = null;
public static void ClearPublishInfos(this IContent content)
{
if (content.PublishCultureInfos is null)
{
return;
}
// Pass each published culture through ClearPublishInfo([culture]) to ensure correct change tracking.
var cultures = content.PublishCultureInfos.Values.Select(c => c.Culture).ToArray();
foreach (var culture in cultures)
{
content.ClearPublishInfo(culture);
}
// Following #22799 the explicit calls to `ClearPublishInfo` for each culture cause the unpublish in all cultures.
// `PublishCultureInfos` is set to null purely to retain previous behaviour at a property level.
content.PublishCultureInfos = null;
}
/// <summary>
/// Returns false if the culture is already unpublished
@@ -8,7 +8,25 @@ namespace Umbraco.Cms.Core.Models.Navigation;
/// </summary>
public sealed class NavigationNode
{
private ConcurrentHashSet<Guid> _children;
private static readonly Comparison<(Guid Key, int SortOrder)> _sortBySortOrder =
static (a, b) => a.SortOrder.CompareTo(b.SortOrder);
private readonly ConcurrentHashSet<Guid> _children;
/// <summary>
/// Cached snapshot of <see cref="Children"/> ordered by each child's <c>SortOrder</c>.
/// </summary>
/// <remarks>
/// Built lazily by <see cref="GetOrderedChildren"/> on first access and invalidated
/// (set to <c>null</c>) by <see cref="AddChild"/> / <see cref="RemoveChild"/> /
/// <see cref="InvalidateOrderedChildren"/>. Reads are lock-free on the fast path; the
/// build and invalidation paths take <see cref="_orderedChildrenLock"/> so concurrent
/// first-access threads agree on a single canonical array and an in-flight build
/// cannot finish after a concurrent invalidation has cleared it.
/// </remarks>
private Guid[]? _orderedChildren;
private readonly Lock _orderedChildrenLock = new();
/// <summary>
/// Gets the unique key of this navigation node.
@@ -53,6 +71,17 @@ public sealed class NavigationNode
/// Updates the sort order of this node.
/// </summary>
/// <param name="newSortOrder">The new sort order value.</param>
/// <remarks>
/// The parent node's cached ordered-children list (if any) is now stale because it sorts
/// by child <c>SortOrder</c>. Callers that hold a reference to the parent should call
/// <see cref="InvalidateOrderedChildren"/> on it; <see cref="NavigationNode"/> does not
/// hold a reference to its parent <see cref="NavigationNode"/> so cannot invalidate it
/// itself.
/// </remarks>
// TODO (V19): Make internal. The contract requires the caller to invalidate the parent's
// ordered-children cache (InvalidateOrderedChildren is internal, so external callers cannot
// satisfy that contract and would silently observe stale ordering on subsequent reads).
// Internal callers in ContentNavigationServiceBase already do the invalidation correctly.
public void UpdateSortOrder(int newSortOrder) => SortOrder = newSortOrder;
/// <summary>
@@ -74,6 +103,8 @@ public sealed class NavigationNode
child.SortOrder = _children.Count;
_children.Add(childKey);
InvalidateOrderedChildren();
}
/// <summary>
@@ -91,5 +122,91 @@ public sealed class NavigationNode
_children.Remove(childKey);
child.Parent = null;
InvalidateOrderedChildren();
}
/// <summary>
/// Returns this node's children ordered by <c>SortOrder</c>.
/// </summary>
/// <param name="navigationStructure">The navigation structure dictionary containing all nodes; needed to look up each child's current <c>SortOrder</c>.</param>
/// <returns>An immutable, sort-order-presorted snapshot of the children. The result is cached and reused across calls until the children set or a child's <c>SortOrder</c> is mutated.</returns>
/// <remarks>
/// Lock-free fast path: a non-null cached array is returned without acquiring the lock.
/// If the cache is empty, <see cref="BuildOrderedChildren"/> is called under the lock to
/// build (with double-checked re-read) and store the canonical array.
/// </remarks>
internal IReadOnlyList<Guid> GetOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
{
// Volatile.Read provides the acquire fence that pairs with the release fence on the
// lock-protected stores in BuildOrderedChildren / InvalidateOrderedChildren. On weak
// memory architectures (e.g. ARM64) a plain read can observe writes out of order with
// the lock release, so without this barrier a reader could in principle see a torn or
// unpublished reference; on x86/x64 the TSO model already gives acquire semantics so
// this compiles to a normal load. Matches the lock-free read idiom in System.Lazy<T>
// and LazyInitializer.EnsureInitialized.
Guid[]? cached = Volatile.Read(ref _orderedChildren);
if (cached is not null)
{
return cached;
}
return BuildOrderedChildren(navigationStructure);
}
/// <summary>
/// Invalidates the cached ordered-children snapshot.
/// </summary>
/// <remarks>
/// Called by <see cref="AddChild"/> and <see cref="RemoveChild"/> automatically. Must be
/// called externally when a child's <c>SortOrder</c> changes (the parent's cache sorts by
/// child <c>SortOrder</c> and so is stale after such an update).
/// </remarks>
internal void InvalidateOrderedChildren()
{
lock (_orderedChildrenLock)
{
_orderedChildren = null;
}
}
private Guid[] BuildOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
{
lock (_orderedChildrenLock)
{
// Double-check under the lock — another thread may have built the cache while we
// were waiting to acquire it.
Guid[]? cached = _orderedChildren;
if (cached is not null)
{
return cached;
}
if (_children.Count == 0)
{
_orderedChildren = [];
return _orderedChildren;
}
var sorted = new List<(Guid Key, int SortOrder)>(_children.Count);
foreach (Guid childKey in _children)
{
if (navigationStructure.TryGetValue(childKey, out NavigationNode? childNode))
{
sorted.Add((childKey, childNode.SortOrder));
}
}
sorted.Sort(_sortBySortOrder);
var result = new Guid[sorted.Count];
for (var i = 0; i < sorted.Count; i++)
{
result[i] = sorted[i].Key;
}
_orderedChildren = result;
return result;
}
}
}
@@ -0,0 +1,12 @@
namespace Umbraco.Cms.Core.Models;
/// <summary>
/// Represents the model used for updating a current user.
/// </summary>
public class UserUpdateProfileModel
{
/// <summary>
/// Gets or sets the ISO code of the user's preferred language.
/// </summary>
public required string LanguageIsoCode { get; set; }
}
@@ -16,6 +16,19 @@ public interface IContentRepository<in TId, TEntity> : IReadWriteQueryRepository
/// </summary>
int RecycleBinId { get; }
/// <summary>
/// Updates the sort order of the specified nodes so that each node's sort order matches its
/// position in the supplied (already ordered) collection, in a single set-based update.
/// </summary>
/// <param name="orderedNodeIds">The node identifiers in their desired order.</param>
/// <remarks>
/// This persists the sort order directly and does not load the entities or fire any notifications;
/// callers are responsible for any required cache refresh and auditing.
/// </remarks>
// TODO (V19): Remove the default implementation.
void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
=> throw new NotImplementedException();
/// <summary>
/// Gets versions.
/// </summary>
@@ -28,6 +28,27 @@ public interface IDocumentCacheService
/// <returns>The published content, or <c>null</c> if not found.</returns>
Task<IPublishedContent?> GetByIdAsync(int id, bool? preview = null);
/// <summary>
/// Attempts to retrieve a content item from the in-memory converted-content cache without
/// touching the distributed cache or the database.
/// </summary>
/// <param name="key">The unique key of the content.</param>
/// <param name="preview">Whether to consider unpublished content.</param>
/// <param name="content">When this method returns, contains the cached published content if a hit was made; otherwise <c>null</c>.</param>
/// <returns><c>true</c> if the content was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
/// <remarks>
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedContentCache.GetById(bool, Guid)</c>)
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
/// the caller falls back to the existing async path. The default implementation always
/// returns <c>false</c> so the caller takes the async path.
/// </remarks>
// TODO (V19): Remove the default implementation.
bool TryGetCached(Guid key, bool preview, out IPublishedContent? content)
{
content = null;
return false;
}
/// <summary>
/// Seeds the cache with initial content data.
/// </summary>
@@ -26,6 +26,26 @@ public interface IMediaCacheService
/// <returns>The published media content, or <c>null</c> if not found.</returns>
Task<IPublishedContent?> GetByIdAsync(int id);
/// <summary>
/// Attempts to retrieve a media item from the in-memory converted-content cache without
/// touching the distributed cache or the database.
/// </summary>
/// <param name="key">The unique key of the media.</param>
/// <param name="content">When this method returns, contains the cached published media if a hit was made; otherwise <c>null</c>.</param>
/// <returns><c>true</c> if the media was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
/// <remarks>
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedMediaCache.GetById(bool, Guid)</c>)
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
/// the caller falls back to the existing async path. The default implementation always
/// returns <c>false</c> so the caller takes the async path.
/// </remarks>
// TODO (V19): Remove the default implementation.
bool TryGetCached(Guid key, out IPublishedContent? content)
{
content = null;
return false;
}
/// <summary>
/// Determines whether media with the specified identifier exists in the cache.
/// </summary>
@@ -88,8 +88,8 @@ public class ContentFinderByRedirectUrl : IContentFinder
}
IPublishedContent? content = umbracoContext.Content?.GetById(redirectUrl.ContentId);
var url = content == null ? "#" : content.Url(_publishedUrlProvider, redirectUrl.Culture);
if (url.StartsWith("#"))
var url = content == null ? Constants.Routing.Unroutable : content.Url(_publishedUrlProvider, redirectUrl.Culture);
if (url.StartsWith(Constants.Routing.Unroutable))
{
if (_logger.IsEnabled(LogLevel.Debug))
{
@@ -47,7 +47,7 @@ public interface IPublishedUrlProvider
/// If the published content is multi-lingual, gets the url for the specified culture or,
/// when no culture is specified, the current culture.
/// </para>
/// <para>If the provider is unable to provide a url, it returns "#".</para>
/// <para>If the provider is unable to provide a url, it returns <see cref="Constants.Routing.Unroutable"/>.</para>
/// </remarks>
string GetUrl(IPublishedContent content, UrlMode mode = UrlMode.Default, string? culture = null, Uri? current = null);
@@ -123,7 +123,7 @@ public class NewDefaultUrlProvider : IUrlProvider
// although we are passing in culture here, if any node in this path is invariant, it ignores the culture anyways so this is ok
var route = GetLegacyRouteFormatById(key, culture);
if (route == null || route == "#")
if (route == null || route == Constants.Routing.Unroutable)
{
continue;
}
@@ -184,7 +184,7 @@ public class NewDefaultUrlProvider : IUrlProvider
var isDraft = _umbracoContextAccessor.GetRequiredUmbracoContext().InPreviewMode;
if (isDraft is false && string.IsNullOrWhiteSpace(culture) is false && content.Cultures.Any() && content.IsInvariantOrHasCulture(culture) is false)
{
route = "#";
route = Constants.Routing.Unroutable;
}
else
{
@@ -206,7 +206,7 @@ public class NewDefaultUrlProvider : IUrlProvider
UrlMode mode,
string? culture)
{
if (string.IsNullOrWhiteSpace(route) || route.Equals("#"))
if (string.IsNullOrWhiteSpace(route) || route.Equals(Constants.Routing.Unroutable))
{
if (_logger.IsEnabled(LogLevel.Debug))
{
+2 -2
View File
@@ -913,7 +913,7 @@ public class PublishedRouter : IPublishedRouter
}
var redirectId = request.PublishedContent.Value(_publishedValueFallback, Constants.Conventions.Content.Redirect, defaultValue: -1);
var redirectUrl = "#";
var redirectUrl = Constants.Routing.Unroutable;
if (redirectId > 0)
{
redirectUrl = _publishedUrlProvider.GetUrl(redirectId);
@@ -931,7 +931,7 @@ public class PublishedRouter : IPublishedRouter
}
}
if (redirectUrl != "#")
if (redirectUrl != Constants.Routing.Unroutable)
{
request.SetRedirect(redirectUrl);
}
@@ -68,7 +68,7 @@ public class PublishedUrlInfoProvider : IPublishedUrlInfoProvider
var url = _publishedUrlProvider.GetUrl(content.Key, culture: culture);
// Handle "could not get URL"
if (url is "#" or "#ex")
if (url is Constants.Routing.Unroutable or Constants.Routing.UrlProviderException)
{
// For invariant content, a missing URL just means there's no domain
// for this culture — not a problem worth reporting.
+4 -4
View File
@@ -112,13 +112,13 @@ namespace Umbraco.Cms.Core.Routing
/// <para>The URL is absolute or relative depending on <c>mode</c> and on <c>current</c>.</para>
/// <para>If the published content is multi-lingual, gets the URL for the specified culture or,
/// when no culture is specified, the current culture.</para>
/// <para>If the provider is unable to provide a URL, it returns "#".</para>
/// <para>If the provider is unable to provide a URL, it returns <see cref="Constants.Routing.Unroutable"/>.</para>
/// </remarks>
public string GetUrl(IPublishedContent? content, UrlMode mode = UrlMode.Default, string? culture = null, Uri? current = null)
{
if (content == null || content.ContentType.ItemType == PublishedItemType.Element)
{
return "#";
return Constants.Routing.Unroutable;
}
if (mode == UrlMode.Default)
@@ -144,7 +144,7 @@ namespace Umbraco.Cms.Core.Routing
UrlInfo? url = _urlProviders.Select(provider => provider.GetUrl(content, mode, culture, current))
.FirstOrDefault(u => u is not null);
return url?.Url?.ToString() ?? "#"; // legacy wants this
return url?.Url?.ToString() ?? Constants.Routing.Unroutable; // legacy wants this
}
/// <inheritdoc />
@@ -155,7 +155,7 @@ namespace Umbraco.Cms.Core.Routing
var url = provider == null
? route // what else?
: provider.GetUrlFromRoute(route, id, umbracoContext.CleanedUmbracoUrl, Mode, culture)?.Url?.ToString();
return url ?? "#";
return url ?? Constants.Routing.Unroutable;
}
#endregion
@@ -146,18 +146,18 @@ public static class UrlProviderExtensions
catch (Exception ex)
{
logger.LogError(ex, "GetUrl exception.");
url = "#ex";
url = Constants.Routing.UrlProviderException;
}
switch (url)
{
// deal with 'could not get the URL'
case "#":
case Constants.Routing.Unroutable:
result.Add(HandleCouldNotGetUrl(content, culture, contentService, textService));
break;
// deal with exceptions
case "#ex":
case Constants.Routing.UrlProviderException:
result.Add(UrlInfo.AsMessage(textService.Localize("content", "getUrlException"), UrlProviderAlias, culture));
break;
@@ -183,7 +183,7 @@ public static class UrlProviderExtensions
private static UrlInfo HandleCouldNotGetUrl(IContent content, string culture, IContentService contentService, ILocalizedTextService textService)
{
// document has a published version yet its URL is "#" => a parent must be
// document has a published version yet its URL is unrouteable => a parent must be
// unpublished, walk up the tree until we find it, and report.
IContent? parent = content;
do
@@ -105,7 +105,15 @@ internal sealed class ContentEditingService
/// <inheritdoc />
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(ContentCreateModel createModel, Guid userKey)
=> await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey));
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, await GetCulturesToValidate(createModel.Variants.Select(variant => variant.Culture), userKey));
}
private async Task<IEnumerable<string?>?> GetCulturesToValidate(IEnumerable<string?>? cultures, Guid userKey)
{
@@ -332,6 +340,15 @@ internal sealed class ContentEditingService
Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
=> await HandleSortByFieldAsync(parentKey, field, direction, culture, userKey);
private async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCulturesAndPropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
Guid contentTypeKey,
@@ -384,8 +401,8 @@ internal sealed class ContentEditingService
protected override OperationResult? Delete(IContent content, int userId) => ContentService.Delete(content, userId);
/// <inheritdoc />
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: null);
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: ordering);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
@@ -394,6 +411,13 @@ internal sealed class ContentEditingService
return OperationResultToOperationStatus(result);
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
private async Task<ContentEditingOperationStatus> Save(IContent content, Guid userKey)
{
try
@@ -389,7 +389,7 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
// at this point the parent MUST exist - unless someone starts using this move method
// e.g. for blueprints (which should be handled elsewhere).
TContent parentContent = ContentService.GetById(parentKey.Value) ?? throw new InvalidOperationException("The content parent ID was validated, but the parent was not found");
if (parentContent.Path.Split(Constants.CharArrays.Comma).Select(int.Parse).Contains(content.Id) is true)
if (parentContent.Path.GetIdsFromPath().Contains(content.Id))
{
return Attempt.FailWithStatus<TContent?, ContentEditingOperationStatus>(ContentEditingOperationStatus.ParentInvalid, content);
}
@@ -458,6 +458,10 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
{
// these are the only result states currently expected from the invoked IContentService operations
OperationResultType.Success => ContentEditingOperationStatus.Success,
// a no-op (e.g. sorting children when nothing needs reordering) is a successful outcome, not an error
OperationResultType.NoOperation => ContentEditingOperationStatus.Success,
OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification,
OperationResultType.FailedCannot => ContentEditingOperationStatus.CannotDeleteWhenReferenced,
@@ -619,6 +623,25 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
return filteredContentTypes.Any();
}
/// <summary>
/// Validates that content of the requested type is allowed to be created under the requested parent, applying the
/// same "allowed at root", "allowed as child" and content type filter rules that are enforced when the content is
/// actually created. This allows the validation endpoints to be consistent with creation.
/// </summary>
/// <param name="createModel">The content creation model.</param>
/// <returns>The operation status; <see cref="ContentEditingOperationStatus.Success"/> when creation is allowed.</returns>
protected async Task<ContentEditingOperationStatus> ValidateCreationAllowedAsync(ContentCreationModelBase createModel)
{
TContentType? contentType = ContentTypeService.Get(createModel.ContentTypeKey);
if (contentType is null)
{
return ContentEditingOperationStatus.ContentTypeNotFound;
}
(int? _, ContentEditingOperationStatus operationStatus) = await TryGetAndValidateParentIdAsync(createModel.ParentKey, contentType);
return operationStatus;
}
private void UpdateNames(ContentEditingModelBase contentEditingModelBase, TContent content, TContentType contentType)
{
if (contentType.VariesByCulture())
@@ -86,9 +86,10 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
/// <param name="parentId">The parent identifier.</param>
/// <param name="pageIndex">The zero-based page index.</param>
/// <param name="pageSize">The page size.</param>
/// <param name="ordering">The ordering to apply, or <c>null</c> to use the default (sort order).</param>
/// <param name="total">The total number of children.</param>
/// <returns>The paged children.</returns>
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total);
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total);
/// <summary>
/// Handles the sorting operation asynchronously.
@@ -111,16 +112,7 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.NotFound;
}
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out var total);
var children = new List<TContent>((int)total);
children.AddRange(page);
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out _);
children.AddRange(page);
}
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
try
{
@@ -138,4 +130,102 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.SortingInvalid;
}
}
/// <summary>
/// Handles sorting a parent's children by a system field asynchronously.
/// </summary>
/// <param name="parentKey">The optional parent key.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
/// <param name="userKey">The user key performing the operation.</param>
/// <returns>The operation status.</returns>
protected async Task<ContentEditingOperationStatus> HandleSortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
{
var contentId = parentKey.HasValue
? ContentService.GetById(parentKey.Value)?.Id
: Constants.System.Root;
if (contentId.HasValue is false)
{
return ContentEditingOperationStatus.NotFound;
}
Ordering ordering = BuildOrdering(field, direction, culture);
// The database does the ordering (matching the list view and the order shown in the sort UI).
if (ContentSettings.SortChildrenByFieldFiresNotifications)
{
// Opt-in path: load the children and persist via the standard sort, firing per-item
// save/sort notifications (and therefore webhooks), at the cost of loading every child.
List<TContent> orderedChildren = LoadAllChildren(contentId.Value, ordering);
if (orderedChildren.Count == 0)
{
return ContentEditingOperationStatus.Success;
}
return Sort(orderedChildren, await GetUserIdAsync(userKey));
}
// Default path: persist the resulting order with a single set-based update and a branch cache
// refresh, without loading every child or firing per-item notifications.
List<int> orderedChildIds = LoadOrderedChildIds(contentId.Value, ordering);
if (orderedChildIds.Count == 0)
{
// Nothing to sort - the order is trivially correct.
return ContentEditingOperationStatus.Success;
}
return SortChildrenInBulk(contentId.Value, orderedChildIds, await GetUserIdAsync(userKey));
}
/// <summary>
/// Persists the supplied (already ordered) child identifiers as the new sort order, without loading
/// the children or firing per-item notifications.
/// </summary>
/// <param name="parentId">The parent identifier, or the root identifier for root-level sorting.</param>
/// <param name="orderedChildIds">The child identifiers in their desired order.</param>
/// <param name="userId">The user performing the operation.</param>
/// <returns>The operation status.</returns>
protected abstract ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId);
private List<int> LoadOrderedChildIds(int contentId, Ordering ordering)
=> LoadAllChildren(contentId, ordering, child => child.Id);
private List<TContent> LoadAllChildren(int contentId, Ordering? ordering)
=> LoadAllChildren(contentId, ordering, child => child);
// Pages through all children, projecting each page with the selector so callers that only need a
// lightweight value (e.g. the id) don't retain every loaded child.
private List<TResult> LoadAllChildren<TResult>(int contentId, Ordering? ordering, Func<TContent, TResult> selector)
{
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out var total);
var results = new List<TResult>((int)total);
results.AddRange(page.Select(selector));
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out _);
results.AddRange(page.Select(selector));
}
return results;
}
private static Ordering BuildOrdering(ContentSortField field, Direction direction, string? culture)
=> field switch
{
// Name is variant - the culture selects the variant name to order by (invariant content and media
// ignore it). Create and update dates are node-level, so the culture does not apply.
ContentSortField.Name => Ordering.By("name", direction, culture),
ContentSortField.CreateDate => Ordering.By("createDate", direction),
ContentSortField.UpdateDate => Ordering.By("updateDate", direction),
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort field."),
};
}

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