Compare commits

...
Author SHA1 Message Date
Laura NetoandGitHub df16c114b6 OpenApi: Lowercase document name on registration to match AddOpenApi internal behaviour (closes #23210) (#23212)
* Lowercase OpenAPI document name on registration to match AddOpenApi internal behaviour

AddOpenApi lowercases the document name when registering its keyed services, so
ReplaceOpenApiSchemaService must receive the same lowercased key or the lookup
throws. BackOfficeOpenApiDocumentBuilder now computes a normalised registration
name and uses it for all DI calls, while keeping DocumentName in its original
casing. ShouldInclude matches [MapToApi] case-insensitively to align with how
documents are registered, and the UI dropdown label falls back to DocumentName
(original casing) rather than the lowercased registration key.
AddUmbracoOpenApiDocument applies the same normalisation for its apiName parameter.

* Add regression tests for mixed-case OpenAPI document name registration

Covers the bug scenario where AddBackOfficeOpenApiDocument with a mixed-case
name and WithJsonOptions threw InvalidOperationException at startup, and verifies
that ShouldInclude matches [MapToApi] case-insensitively.
2026-06-26 13:59:27 +02:00
fb1e16ff36 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:23:21 +02:00
Niels Lyngsø d889de6af0 Merge branch 'v17/dev' 2026-06-26 09:19:08 +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
774b2d4822 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:08:14 +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
Laura NetoandGitHub ba6ec7abcf Re-enable package validation (#23197) 2026-06-25 08:43:56 +00:00
Laura Neto 5d4d3a51ea Merge branch 'release/18.0' 2026-06-25 07:16:30 +01:00
Jacob Overgaard c6c5a9e7e6 Merge remote-tracking branch 'origin/v17/dev' 2026-06-24 11:51:40 +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
Laura Neto c087ce9fb5 Bump the version of the Umbraco.TheStarterKit to 18.0.0 in the UmbracoProject template 2026-06-24 11:23:37 +02:00
Jacob OvergaardandClaude Opus 4.8 cda880bb62 test(hybridcache): use v18 IDatabaseCacheRepository method names in stale-set race test
DocumentHybridCacheStaleSetRaceTests came from the v17 PR #23169 and merged
forward into main (v18) unchanged, but on v18 IDatabaseCacheRepository renamed
GetContentSourceAsync -> GetDocumentSourceAsync and
GetContentSourceForPublishStatesAsync -> GetDocumentSourceForPublishStatesAsync.
The mock setups still referenced the v17 names, failing the build with CS1061.

Rename the two Moq setups to the v18 method names so the test compiles and the
mocks actually intercept the calls DocumentCacheService makes. v17 keeps the
Content* names and is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:06:16 +02:00
Jacob Overgaard b3e6477df9 Merge remote-tracking branch 'origin/v17/dev' 2026-06-23 14:33:28 +02:00
Jacob Overgaard 8f3ff26005 Merge remote-tracking branch 'origin/release/18.0' 2026-06-23 14:32:19 +02: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
Jacob Overgaard 8dd8820fa3 build(deps): bumps @umbraco-ui/uui to 2.0.0 2026-06-23 11:54:00 +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
Laura Neto 6b76230da5 Bump version to 18.0.0. 2026-06-23 09:38:59 +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 2e3628dad3 Bump version to 18.0.0-rc4. 2026-06-19 18:50:40 +02:00
Andy Butland 5a20452e7a Bump version to 17.5.0-rc3. 2026-06-19 18:44:26 +02:00
0e14ace89a Element Picker: Adds valueSummary display and value resolver (#23154)
* feat(elements): add value summary for Element Picker property editor

Adds a valueSummary extension so picked element names appear in collection
view columns. Includes a value-type constant, batch resolver, variant-aware
element, and a resolver unit test (11 cases).

* fix(elements): address PR review feedback on element picker value summary

- Call removeUmbControllerByAlias when removing a stale resolver so the
  named observer controller is released from the element's controller list
- Call setData on existing resolvers when _value refreshes so renames are
  reflected without recreating the resolver
- Remove redundant valueResolver re-export from resolver file (barrel handles it)


---------

Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-06-19 14:11:12 +00:00
Andy Butland adeddeb148 Complete bump version to 17.5.0-rc2. 2026-06-19 15:51:10 +02:00
c9059c7b07 UFM: Add umbElementName component (#23162)
* feat(ufm): add umbElementName UFM component

Adds a new UFM component that resolves Element display names from element
keys, mirroring the umbContentName component pattern. Uses the variant-aware
UmbElementItemDataResolver (via UmbElementItemRepository) for proper
culture/variant handling and (Untitled) fallback.

Also exports UmbElementItemDataResolver from the public
@umbraco-cms/backoffice/element entry point, matching the pattern used by
the documents package.

* fix(ufm): clear stale value and destroy resolvers in element-name element

Clear this.value when the render context produces no usable input, preventing
stale names from lingering when the context changes. Also destroy each
UmbElementItemDataResolver after getName() to avoid accumulating controller
registrations on the host element.

* Update src/Umbraco.Web.UI.Client/src/packages/ufm/components/element-name/element-name.element.ts

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

* test(ufm): add umbElementName parsing test to marked-ufm.test.ts

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-19 13:26:20 +00:00
Andy Butland 1ee24595d0 Fixed build error with integration tests. 2026-06-19 14:24:41 +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 4ac8a397d0 Merge branch 'v17/dev'
# Conflicts:
#	src/Umbraco.Web.UI.Client/src/packages/content/content/types.ts
2026-06-19 13:58:25 +02: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
Andy Butland 0f4b784c23 Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2026-06-19 13:40:22 +02:00
Andy Butland ebc0ef36f5 Merge branch 'v17/dev' 2026-06-19 13:40:09 +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
Jacob OvergaardandClaude Opus 4.8 cb23c84c3d External login: wait for app-entry-points before the login provider decision (#23167)
* External login: wait for app-entry-points before the login provider decision

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:28:23 +02:00
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
Niels Lyngsø fc3b13c85c 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:24:56 +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 8bcd5990c2 Merge branch 'v17/dev' 2026-06-17 20:00:48 +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
Jesper MadsenandJacob Overgaard 1dfcd9332a Let the external login button show "sign in with {providername}" in languages (#23135) 2026-06-17 15:57:40 +02:00
Jesper MadsenandGitHub b2b45b016d Let the external login button show "sign in with {providername}" in languages (#23135) 2026-06-17 15:56:59 +02:00
Jacob Overgaard ef610c7d23 Merge remote-tracking branch 'origin/release/18.0' 2026-06-17 15:51:52 +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
Jacob OvergaardandClaude Opus 4.8 d84186061c fix(tests): point DomainCacheServiceTests mocks at GetAllAsync
IDomainService.GetAll was removed in #22629; DomainCacheServiceTests was
added later in #23084 against a stale base and still mocked the removed
method, breaking the Release build on release/18.0. Production
DomainCacheService already calls GetAllAsync, so update the four mock
setups to match.

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

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

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

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

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

* Build: source npmPrereleaseVersion via dependencies, not dependsOn

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

* Build: align Deploy_Npm with Umbraco Deploy publish pattern

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 14:00:19 +02:00
7fdf9c12f3 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 13:59:17 +02:00
Ronald BarendseandAndy Butland e2252a8634 Tests: Remove AutoFixture from the shipped Umbraco.Cms.Tests package (#23141)
Tests: Remove AutoFixture from Umbraco.Cms.Tests package
2026-06-17 09:50:01 +02:00
Ronald BarendseandGitHub 190e3d373a Tests: Remove AutoFixture from the shipped Umbraco.Cms.Tests package (#23141)
Tests: Remove AutoFixture from Umbraco.Cms.Tests package
2026-06-17 09:47:22 +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
Laura NetoandGitHub 84a1ca8335 SonarCloud: Improve workflow (#23080)
* SonarCloud: allow unit test failures without failing the analysis

Test failures should not block SonarCloud analysis - coverage data is
still collected by dotnet-coverage regardless of test outcome. The
regular CI pipeline is the correct gate for test pass/fail.

* SonarCloud: install Java 21 explicitly and skip JRE provisioning

- Add actions/setup-java@v5 (temurin-21) so JAVA_HOME always points to Java 21
- Pass sonar.scanner.skipJreProvisioning=true in the begin command since Java 21 is installed explicitly, removing the need for the scanner to download a JRE at runtime

* SonarCloud: clear SONARQUBE_SCANNER_PARAMS after begin

Prevents the End analysis step from re-applying sonar params that begin already wrote to the analysis config, eliminating the "Ignoring property from env variable" warning.

* SonarCloud: always cancel in-progress runs on new push

* TEMP: add failing test to verify pipeline resilience — revert before merge

* Revert "TEMP: add failing test to verify pipeline resilience — revert before merge"

This reverts commit 828a7510cb.

* Revert "SonarCloud: clear SONARQUBE_SCANNER_PARAMS after begin"

This reverts commit 1911b65db1.

* Make SonarCloud workflow resilient to build and test failures

* Fix inaccurate warning message when unit tests fail

* Revert build step resilience, keep test failure warning

* Improve test failure warning with coverage file check

* Temporary: add failing test to verify SonarCloud workflow resilience

* Revert "Temporary: add failing test to verify SonarCloud workflow resilience"

This reverts commit 71ecd61034.
2026-06-16 09:31:50 +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 Butland b940b27ad2 Updated OpenApi.json. 2026-06-16 10:09:54 +02:00
Andy Butland d416d352e0 Merge branch 'v17/dev' 2026-06-16 09:26:33 +02: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
Nhu DinhandGitHub a15d340608 E2E: QA Added acceptance tests for published element extensions (#23030)
* Added api helper for creating element type with compositions

* Added api helper for creating template with displaying element picker

* Added tests for published element extensions

* Make tests run in the pipeline

* Fixed suggestions

* Fixed comment

* Reverted npm command
2026-06-16 03:29:45 +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 37e2458475 Merge branch 'release/18.0' of https://github.com/umbraco/Umbraco-CMS into release/18.0 2026-06-15 16:20:46 +02:00
Andy Butland 2461853b11 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

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

* Use Lock object.
2026-06-15 16:20:11 +02:00
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
Nicklas KramerandGitHub 9fe9469e3a Integration Tests: Adjusting Status Codes for Failing Tests (#23125)
Adjusting expected status codes
2026-06-15 15:39:47 +02:00
16c97d613f Elements: Invalidate the id/key map when an element container is deleted (closes #23072) (#23074)
* Refresh the element container cache on delete to ensure the id/key map is invalidated.

* Rename and additional asserts in test.

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

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

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

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

* Addressed code review feedback.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:16:31 +02:00
Nhu DinhandGitHub 5a6b18307d E2E: QA Added acceptance tests for broken publish path in legacy routing (#23090)
* Added ui helper for does not contain text

* Added constant for cannot be routed in content

* Added ui helper for verify document does not contain link

* Added api helper for unpublish document and publish chain of document

* Added api helper for updating document type

* Added tests for handling broken publish path in backoffice

* Added tests for handling broken publish path in delivery API

* Make tests run in the pipeline

* Clean up

* Fixed comments

* Reverted npm command
2026-06-15 09:55:26 +00:00
Nhu DinhandGitHub 3ee7b142b3 E2E: Fixed failing acceptance tests for user group, backoffice search and content picker (#23108)
* Updated backoffice search element tests to match the test helper changes

* Added step to delete user group before deleting language

* Updated tests for element folder permission due to recent changes

* Fix failing tests for content picker and element with element picker due to UI changes
2026-06-15 08:57:13 +00:00
Andy Butland ae92b0b0a9 Fixed failing unit test. 2026-06-15 07:07:51 +02:00
Andy Butland 8503762431 Merge branch 'v17/dev' 2026-06-15 06:49:06 +02: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 97a005e709 Merge branch 'release/18.0' 2026-06-15 06:42:11 +02:00
Andy Butland 30b4370044 Merge branch 'v17/dev' 2026-06-15 06:40:59 +02:00
Andy Butland cffac2a990 Dependencies: Update MessagePack to 3.1.7 to address security advisories (#23113)
Update MessagePack dependency to 3.1.7.
2026-06-15 06:37:13 +02:00
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
Kenn JacobsenandGitHub 6883c6fcfd Tags: Expand ITagService to handle Elements (#23117) 2026-06-15 06:28:52 +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
Andy Butland e8586493e2 Merge branch 'v17/dev' 2026-06-13 15:42:41 +02:00
d5d0ce68aa Property editors: Add configuration and validation of allowed types for element picker, document picker, media picker (#23026)
* add allowed type for element picker

* update validation and unit test

* remove redundant code

* update tests name

* revert code GetReferences

* remove un-using code and add more check value

* remove redundant param

* add allowed type for content picker, update validation

* add min max validation into element and its unit test

* update media picker validation

* split validation runner into other class

* update SystemTextJsonSerializerBase back to old code

* update unit tests

* Resolved some code warnings.

* Remove accidentally committed file

* Introduce ITypedValidator and obsolete ITypeJsonValidator to better reflect validators that may or may not contain JSON editor values.

* Extraced ParseAllowedContentTypeKeys into a common helper.

* Aligned parameters on AllowedTypeValidator.

* Align parsing of allowed type Ids on client between document and media pickers.

* Restored validation of media where provided key can't be retrieved.

* Aligned document, media and element configuration labels and weights.

* Added additional unit tests.

* Addressed code review comments.

* Resolved further code warnings and code tidy.

* Resolve potential binary breaking change concern with obsolete ITypedJsonValidator.

* Reuse shared DocumentTypePicker for picker allowed-types config.

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-13 13:30:34 +00:00
Zeegaan 764d4eb1d7 bump version 2026-06-11 14:02:32 +09:00
de61491093 Tests: Update DomainCacheServiceTests to mock GetAllAsync instead of removed GetAll (#23097)
update unit tests for domain cache service

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2026-06-10 12:14:01 +02: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
Jacob Overgaard deafc20db9 Merge remote-tracking branch 'origin/v17/dev' 2026-06-09 13:20:53 +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
6d1487ef4e Global Search: Localize global search manifest labels and names (#23091)
* add localization for search manifest

* only localization for label

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2026-06-09 08:35:15 +00:00
Jacob Overgaard 46aa184e10 Merge remote-tracking branch 'origin/v17/dev' 2026-06-09 10:31:49 +02: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
Jacob Overgaard c1ba303fdc Merge remote-tracking branch 'origin/v17/dev' 2026-06-09 10:30:22 +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 ButlandandGitHub 5dd28e7a13 Tests: Fix failing Management API integration tests (closes #23076) (#23081)
* Include currently missing management API tests in the CI build.

* Fixed failing tests.

* Revert the pipeline updates.

* Addressed code review feedback.
2026-06-07 08:40:23 +02:00
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
Niels LyngsøandGitHub 87b24912c7 V18: Beta UI adjustments (#22869) 2026-06-05 15:10:54 +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
a3424eb40d 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:58:20 +02:00
Andy Butland f6c70e8429 Bump version to 18.0.0-rc3. 2026-06-05 06:46:44 +02:00
Andy Butland 68d03ae7c4 Merge branch 'release/18.0' 2026-06-05 06:42:24 +02:00
Laura NetoandGitHub 1dbcf1037a Delivery API - Open API: return inline {} schema for unconstrained property types (#23066)
* Delivery API: return inline {} schema for unconstrained property types

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

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

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

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

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

* Simplify comments

* Delivery API: guard unconstrained type check with JsonTypeInfoKind.None
2026-06-04 17:01:49 +02:00
Andy ButlandandGitHub 27909ed18e Elements: Hide element actions from the document notifications dialog (closes #23053) (#23059)
Remove element actions from the notification dialog.
2026-06-04 14:57:32 +02:00
Jacob Overgaard 91c9b79926 Merge remote-tracking branch 'origin/v17/dev' 2026-06-04 10:41:08 +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
Erik-Jan WestendorpandGitHub 5ade6ae6ec Localization: Add Dutch translations for create and delete actions (#23039)
Update nl.ts
2026-06-04 08:37:12 +02:00
Andy Butland 7ca8d3d872 Merge branch 'v17/dev' 2026-06-04 07:59: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 fceb2f421f Merge branch 'v17/dev' 2026-06-04 06:45:50 +02:00
Andy Butland 54827c4c92 Background Jobs: Resolve server role so recurring jobs run when no application URL is configured (#23033)
Resolve server role when no application URL is configured.
2026-06-04 06:44:51 +02:00
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
Andreas ZerbstandGitHub 245bc336a5 E2E: QA: Add acceptance test for backoffice search (#22730)
* Added tests

* Cleaned up

* Updated command

* Fixes based on comments

* Split tests

* Updated helpers

* Fixed constant helper after merge

* Use correct helper

* Cleaned up

* Update smokeTest command in package.json
2026-06-03 19:49:12 +00:00
Andy Butland c5efd65e23 Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2026-06-03 18:49:28 +02:00
Andy Butland cb9ac904f6 Merge branch 'v17/dev' 2026-06-03 18:49:12 +02:00
Laura NetoandGitHub cc38e5724b SonarCloud: Simplify to single workflow, skip fork PRs (#23054) 2026-06-03 18:37:07 +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
Jacob Overgaard 7158aec145 Merge branch 'release/18.0' 2026-06-03 13:06:55 +02:00
Laura NetoandGitHub 214fd03241 OpenAPI: Disable XML documentation source generator (closes #23018) (#23045)
Disable OpenAPI XML documentation source generator

Due to the amount of XML documentation in this solution, the OpenAPI XML documentation source generator produces too many lines of code in a single method (GenerateCacheEntries), which causes a StackOverflowException when running on IIS. The fix disables the analyzer globally via Directory.Build.props.
2026-06-03 12:28:51 +02:00
a3f65b2920 Redirects: Adding notification for redirect save and deletion (#22985)
* Creating notification objects and status

* Adjusting service and tracker to support cancelable notifications

* Adding Operation Status Results to base controller.

* Adding notification support to the delete controller.

* Integration tests

* Changes in accordance to CR

* Changes in accordance to code review

* Fixed further use of obsolete methods in tests.

* Added comment explaining why messages on create or update cancellation are suppressed.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-03 09:37:31 +00:00
Jacob OvergaardandGitHub 787f66be3d Dependencies: Bumps @umbraco-ui/uui from 2.0.0-rc.1 to 2.0.0-rc.2 (#23052)
build(deps): bumps @umbraco-ui/uui from 2.0.0-rc.1 to 2.0.0-rc.2
2026-06-03 09:00:01 +00:00
Erik-Jan WestendorpandAndy Butland cd23fc75c5 Localisation: Translate the "Library" section header into other languages (#23043)
* Translate library to Dutch and Spanish

* Add translations for other cultures.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-03 09:36:43 +02:00
230b5db528 Localisation: Translate the "Library" section header into other languages (#23043)
* Translate library to Dutch and Spanish

* Add translations for other cultures.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-03 07:35:06 +00:00
Nhu DinhandGitHub 2372c40056 E2E: QA Added acceptance tests for element folder permission (#22905)
* Added constant setting for element folder permission

* Added api helper for element folder

* Added api helper for user group with element folder permission

* Added ui helper for element folder permission in user group

* Added tests for element folder permission

* Added locator for restore element

* Added api helper for Combined element + element folder permission methods

* Added more tests for restore element folder

* Make tests run in the pipeline

* Fixed comments

* Reverted npm command
2026-06-03 03:19:02 +00:00
75d2b0129d E2E: QA Added acceptance tests for Element Type settings (#23005)
* Updated createEmptyElementType

* Updated tests due to test helper changes

* Added ui helper for not applicable message for element type

* Added tests for showing message for non-applicable Element Type settings

* Added tests for preventing disabling isElement when elements of that type exist

* Make tests run in the pipeline

* Fixed comments

* Update tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeSettingsTab.spec.ts

Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>

* Reverted npm command

---------

Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>
2026-06-03 03:13:52 +00:00
Jacob Overgaard b410e060b6 Merge remote-tracking branch 'origin/v17/dev' 2026-06-02 16:32:14 +02: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
Laura NetoandGitHub a86777a8f2 Build: Add SonarCloud CI workflow (#22960)
* Add SonarCloud CI workflow

Adds a manual-dispatch GitHub Actions workflow for SonarQube Cloud
analysis (build, unit test coverage, scan). Moves file_header_template
and SA1636/SA1633 suppression from .editorconfig comments and
.globalconfig into the active .editorconfig .NET language conventions
section, removing the duplicated suppression from .globalconfig.

* Remove branch filter from pull_request trigger in SonarCloud workflow

Runs analysis on all PRs regardless of target branch.

* Adjust sonarcloud gh action based on feedback

* Add .sonarqube to .gitignore

* Attempt to split build and analysis in order to be able to run in PRs from forks

* Adjust SonarCloud workflows

* Rename SonarCloud workflows to reflect their actual purpose

* Remove sonar.coverage.exclusions

* Include .github in sonar analysis

* Include build directory in sonar analysis

* Apply sonarcloud workflow fixes from test branch

* Remove setup-dotnet step from upload workflow

* Use default branch from context instead of hardcoded main in analysis workflow

* Update checkout action to v6 in upload workflow

* Add actions: read permission to upload workflow

* Enable SCM integration in upload workflow
2026-06-02 14:49:10 +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 4e815d7e9d Merge remote-tracking branch 'origin/v17/dev' 2026-06-02 12:46:33 +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
Nhu DinhandGitHub 318843793f E2E: QA Updated acceptance tests for removing the Element Picker from elements to reflect the recent changes (#23037)
* Added comments for the out-of-date tests

* Fixed element with element picker tests due to response changes
2026-06-02 10:20:40 +00:00
Sven GeusensandAndy Butland 0adb5d21f5 Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

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

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

---------

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

* Assert user start node references cleared after container delete

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

* Guard against null entity in PersistDeletedItem override

Mirrors the ArgumentNullException guard in the base
EntityContainerRepository.PersistDeletedItem so a null argument throws
the same exception type.
2026-06-01 10:36:11 +02:00
Andy ButlandandGitHub 3f0e0747fa Management API: Declare multipart/form-data on the Create Temporary File endpoint (closes #23017) (#23025)
Add explicit Consumes to management API endpoint that accepts IFormFile.
2026-06-01 09:32:48 +02:00
Laura Neto f3471e961f Bump version to 18.0.0-rc2 2026-05-28 09:56:34 +02:00
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
432 changed files with 15374 additions and 2035 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.
+4 -12
View File
@@ -70,18 +70,6 @@ trim_trailing_whitespace = true
[*.less]
trim_trailing_whitespace = false
##########################################
# File Header (Uncomment to support file headers)
# https://docs.microsoft.com/visualstudio/ide/reference/add-file-header
##########################################
# [*.{cs,csx,cake,vb,vbx}]
file_header_template = Copyright (c) Umbraco.\nSee LICENSE for more details.
# SA1636: File header copyright text should match
# Justification: .editorconfig supports file headers. If this is changed to a value other than "none", a stylecop.json file will need to added to the project.
# dotnet_diagnostic.SA1636.severity = none
##########################################
# .NET Language Conventions
# https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions
@@ -136,6 +124,10 @@ dotnet_code_quality_unused_parameters = all:warning
dotnet_style_operator_placement_when_wrapping = end_of_line
# https://github.com/dotnet/roslyn/pull/40070
dotnet_style_prefer_simplified_interpolation = true:warning
# File header preferences
file_header_template = Copyright (c) Umbraco.\nSee LICENSE for more details.
dotnet_diagnostic.SA1633.severity = none # Suppressed until we decide to enforce it
dotnet_diagnostic.SA1636.severity = none # Suppressed since we are using StyleCop
# C# Code Style Settings
# https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#c-code-style-settings
+99
View File
@@ -0,0 +1,99 @@
name: "SonarQube Cloud - Analysis"
# This workflow runs the full SonarCloud analysis with the SONAR_TOKEN secret.
# It is skipped for fork PRs since secrets are not available in that context.
on:
push:
branches:
- main
- "v*/dev"
- "v*/main"
- "release/*"
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
permissions:
contents: read
env:
SONAR_PROJECT_KEY: umbraco_Umbraco-CMS
SONAR_ORGANIZATION: umbraco
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Build and analyze
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup .NET from global.json
uses: actions/setup-dotnet@v5
- name: Setup Java 21
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
- name: Cache SonarQube packages
uses: actions/cache@v5
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Install tools
run: |
dotnet tool install --global dotnet-sonarscanner
dotnet tool install --global dotnet-coverage
- name: Load sonar params
run: echo "SONARQUBE_SCANNER_PARAMS=$(jq -c . .github/workflows/sonarcloud/sonar-params.json)" >> $GITHUB_ENV
- name: Begin analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
dotnet-sonarscanner begin \
/k:"$SONAR_PROJECT_KEY" \
/o:"$SONAR_ORGANIZATION" \
/d:sonar.token="$SONAR_TOKEN" \
/d:sonar.scanner.skipJreProvisioning=true
- name: Restore
run: dotnet restore umbraco.sln
- name: Build solution
run: GITHUB_ENV=/dev/null dotnet build umbraco.sln --no-restore -clp:ErrorsOnly # prevent sonar MSBuild integration from writing malformed values to $GITHUB_ENV
- name: Run unit tests with coverage
id: tests
continue-on-error: true
run: |
dotnet-coverage collect \
"dotnet test tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj --no-build" \
--output TestResults/coverage.xml \
--output-format xml
- name: Warn on test failure
if: steps.tests.outcome == 'failure'
run: |
if [ -f TestResults/coverage.xml ]; then
echo "::warning::Unit tests failed - SonarCloud analysis will proceed with the collected coverage data"
else
echo "::warning::Unit tests failed and no coverage data was collected"
fi
- name: End analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN"
@@ -0,0 +1,7 @@
{
"sonar.cs.vscoveragexml.reportsPaths": "TestResults/coverage.xml",
"sonar.inclusions": "src/**,templates/**,tools/**,tests/**,.github/**,build/**",
"sonar.exclusions": "**/bin/**,**/obj/**,**/node_modules/**,**/lang/*.ts,**/mocks/**,**/wwwroot/**,**/dist-cms/**,**/*.generated.cs,src/Umbraco.Web.UI/umbraco/**,src/Umbraco.Cms.Persistence.EFCore.*/Migrations/**,src/Umbraco.Web.UI.Client/src/packages/core/backend-api/**,**/.nuget/**",
"sonar.test.inclusions": "tests/**,**/*.test.ts,**/*.spec.ts",
"sonar.typescript.tsconfigPaths": "src/Umbraco.Web.UI.Client/tsconfig.json,src/Umbraco.Web.UI.Client/tsconfig.node.json,src/Umbraco.Web.UI.Login/tsconfig.json"
}
+3
View File
@@ -121,3 +121,6 @@ trace.zip
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
/src/Umbraco.Cms/appsettings-schema.json
.playwright-mcp/
# SonarQube local analysis cache
.sonarqube/
-1
View File
@@ -48,7 +48,6 @@ dotnet_analyzer_diagnostic.category-StyleCop.CSharp.OrderingRules.severity = sug
dotnet_analyzer_diagnostic.category-StyleCop.CSharp.MaintainabilityRules.severity = suggestion
dotnet_analyzer_diagnostic.category-StyleCop.CSharp.LayoutRules.severity = suggestion
dotnet_diagnostic.SA1636.severity = none # SA1636: File header copyright text should match
dotnet_diagnostic.SA1101.severity = none # PrefixLocalCallsWithThis - stylecop appears to be ignoring dotnet_style_qualification_for_*
dotnet_diagnostic.SA1309.severity = none # FieldNamesMustNotBeginWithUnderscore
+2
View File
@@ -558,6 +558,8 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
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
+11 -1
View File
@@ -40,7 +40,7 @@
<!-- Package Validation -->
<PropertyGroup>
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
<EnablePackageValidation>false</EnablePackageValidation> <!-- TODO (V18): Set to true once this version is released. -->
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>18.0.0</PackageValidationBaselineVersion>
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
@@ -64,4 +64,14 @@
</_ProjectReferencesWithVersions>
</ItemGroup>
</Target>
<!-- Workaround for https://github.com/umbraco/Umbraco-CMS/issues/23018
Due to the amount of XML documentation in this solution, the OpenAPI XML documentation source generator produces
too many lines of code causing a StackOverflowException when running on IIS. For that reason we disable the analyzer.
See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/openapi-comments?view=aspnetcore-10.0#disabling-xml-documentation-support -->
<Target Name="DisableCompileTimeOpenApiXmlGenerator" BeforeTargets="CoreCompile" Condition="'$(IsPackable)' != 'false' or '$(IsTestProject)' == 'true'">
<ItemGroup>
<Analyzer Remove="@(Analyzer)" Condition="'%(Filename)' == 'Microsoft.AspNetCore.OpenApi.SourceGenerators'" />
</ItemGroup>
</Target>
</Project>
+4 -4
View File
@@ -49,15 +49,15 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0" />
<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="1.1.3" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
<PackageVersion Include="ncrontab" Version="3.4.0" />
@@ -95,4 +95,4 @@
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.7" />
</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:
+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 }}
@@ -59,6 +59,7 @@ public static class UmbracoBuilderApiExtensions
string? jsonOptionsName = null)
where TConfigureOptions : ConfigureUmbracoOpenApiOptionsBase
{
apiName = apiName.ToLowerInvariant();
builder.Services.AddOpenApi(apiName);
builder.Services.ConfigureOptions<TConfigureOptions>();
builder.Services.AddOpenApiDocumentToUi(apiName, apiTitle);
@@ -2,9 +2,9 @@ using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Common.Attributes;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
@@ -116,12 +116,20 @@ public sealed class BackOfficeOpenApiDocumentBuilder
/// <param name="builder">The Umbraco builder to register services against.</param>
internal void Build(IUmbracoBuilder builder)
{
// AddOpenApi lowercases the document name when registering its keyed services (https://github.com/dotnet/aspnetcore/blob/v10.0.9/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs#L64),
// so we must normalise here to keep AddOpenApiDocumentToUi and ReplaceOpenApiSchemaService in sync.
string lowercasedDocumentName = DocumentName.ToLowerInvariant();
builder.Services.AddOpenApi(
DocumentName,
lowercasedDocumentName,
options =>
{
// ShouldInclude matches [MapToApi] case-insensitively to align with how documents are registered.
options.ShouldInclude = apiDescription =>
apiDescription.ActionDescriptor.HasMapToApiAttribute(DocumentName);
apiDescription.ActionDescriptor.EndpointMetadata
?.OfType<MapToApiAttribute>()
.Any(a => a.ApiName.Equals(DocumentName, StringComparison.OrdinalIgnoreCase))
?? false;
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
@@ -158,12 +166,12 @@ public sealed class BackOfficeOpenApiDocumentBuilder
if (_includedInUi)
{
builder.Services.AddOpenApiDocumentToUi(DocumentName, _uiTitle ?? _title);
builder.Services.AddOpenApiDocumentToUi(lowercasedDocumentName, _uiTitle ?? _title ?? DocumentName);
}
if (_httpJsonOptionsFactory is not null)
{
builder.Services.ReplaceOpenApiSchemaService(DocumentName, _httpJsonOptionsFactory);
builder.Services.ReplaceOpenApiSchemaService(lowercasedDocumentName, _httpJsonOptionsFactory);
}
}
}
@@ -1,5 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
using System.Text.Json.Serialization.Metadata;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.OpenApi;
@@ -341,6 +343,12 @@ public sealed class ContentTypeSchemaTransformer : IOpenApiSchemaTransformer, IO
var schemaId = GetSchemaId(jsonTypeInfo);
// Types that produce 'true' in JSON Schema (unconstrained: JsonNode, object, custom-converter types) should be inline {} rather than named components.
if (jsonTypeInfo.Kind == JsonTypeInfoKind.None && jsonTypeInfo.GetJsonSchemaAsNode().GetValueKind() == JsonValueKind.True)
{
return new OpenApiSchema();
}
// If this is one of the types we handle, and we already started generating it, return a placeholder
// to avoid circular reference issues.
// In the document transformer, these placeholders will be replaced with the actual schemas.
@@ -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);
}
}
@@ -2,6 +2,7 @@ using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
@@ -32,11 +33,13 @@ public class DeleteByKeyRedirectUrlManagementController : RedirectUrlManagementC
[MapToApiVersion("1.0")]
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Deletes a redirect URL.")]
[EndpointDescription("Deletes a redirect URL identified by the provided Id.")]
public Task<IActionResult> DeleteByKey(CancellationToken cancellationToken, Guid id)
{
_redirectUrlService.Delete(id);
return Task.FromResult<IActionResult>(Ok());
RedirectUrlOperationStatus status = _redirectUrlService.DeleteWithStatus(id);
return Task.FromResult(RedirectUrlOperationStatusResult(status));
}
}
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
@@ -14,4 +16,31 @@ namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
[Authorize(Policy = AuthorizationPolicies.SectionAccessContent)]
public class RedirectUrlManagementControllerBase : ManagementApiControllerBase
{
/// <summary>
/// Maps a <see cref="RedirectUrlOperationStatus"/> to an appropriate <see cref="IActionResult"/>.
/// </summary>
/// <param name="status">The operation status to map.</param>
/// <returns>An <see cref="IActionResult"/> describing the outcome of the operation.</returns>
protected IActionResult RedirectUrlOperationStatusResult(RedirectUrlOperationStatus status) =>
OperationStatusResult(status, problemDetailsBuilder => status switch
{
RedirectUrlOperationStatus.Success => Ok(),
RedirectUrlOperationStatus.NotFound => NotFound(problemDetailsBuilder
.WithTitle("The redirect URL could not be found")
.Build()),
RedirectUrlOperationStatus.CancelledByNotification => BadRequest(problemDetailsBuilder
.WithTitle("Cancelled by notification")
.WithDetail("A notification handler prevented the redirect URL operation.")
.Build()),
RedirectUrlOperationStatus.Unknown => StatusCode(
StatusCodes.Status500InternalServerError,
problemDetailsBuilder
.WithTitle("Unknown error. Please see the log for more details.")
.Build()),
_ => StatusCode(
StatusCodes.Status500InternalServerError,
problemDetailsBuilder
.WithTitle("Unknown redirect URL operation status.")
.Build()),
});
}
@@ -32,6 +32,7 @@ public class CreateTemporaryFileController : TemporaryFileControllerBase
[HttpPost("")]
[MapToApiVersion("1.0")]
[Consumes("multipart/form-data")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Creates a temporary file.")]
@@ -1,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;
File diff suppressed because it is too large Load Diff
@@ -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;
}
}
@@ -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
{
}
@@ -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 @@ public class ActionElementContainerDelete : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerMove : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerNew : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementContainerUpdate : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementCopy : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementDelete : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementMove : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
+1 -1
View File
@@ -21,7 +21,7 @@ public class ActionElementNew : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementPublish : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementRollback : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -21,7 +21,7 @@ public class ActionElementUpdate : IAction
public string Alias => ActionAlias;
/// <inheritdoc />
public bool ShowInNotifier => true;
public bool ShowInNotifier => false;
/// <inheritdoc />
public bool CanBePermissionAssigned => true;
@@ -467,6 +467,20 @@ public static class DistributedCacheExtensions
#endregion
#region ElementContainerCacheRefresher
/// <summary>
/// Invalidates the id/key map for the specified deleted element containers (folders).
/// </summary>
/// <param name="dc">The distributed cache.</param>
/// <param name="deletedContainers">The element containers that were deleted.</param>
public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable<EntityContainer> deletedContainers)
=> dc.RefreshByPayload(
ElementContainerCacheRefresher.UniqueId,
deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key)));
#endregion
#region Published Snapshot
/// <summary>
@@ -0,0 +1,43 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping
/// is evicted from <see cref="Services.IIdKeyMap"/> on every server.
/// </summary>
/// <remarks>
/// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an
/// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node
/// itself, so without this handler the container's stale id/key mapping survives until the next app
/// restart (see #23072).
/// </remarks>
public sealed class ElementContainerDeletedDistributedCacheNotificationHandler
: DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification>
{
private readonly DistributedCache _distributedCache;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class.
/// </summary>
/// <param name="distributedCache">The distributed cache.</param>
public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache)
=> _distributedCache = distributedCache;
/// <inheritdoc />
protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state)
{
EntityContainer[] elementContainers = entities
.Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer)
.ToArray();
if (elementContainers.Length == 0)
{
return;
}
_distributedCache.RemoveElementContainerCache(elementContainers);
}
}
@@ -18,5 +18,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)
@@ -0,0 +1,109 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Provides cache refresh functionality for element containers (folders).
/// </summary>
/// <remarks>
/// A deleted container's node id is never reused, so its key→id mapping in <see cref="IIdKeyMap"/> must be
/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and
/// the element tree's children query returns nothing until the next app restart. This refresher only evicts
/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader
/// invalidation performed by <see cref="ElementCacheRefresher"/>.
/// </remarks>
public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase<ElementContainerCacheRefresherNotification, ElementContainerCacheRefresher.JsonPayload>
{
private readonly IIdKeyMap _idKeyMap;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresher"/> class.
/// </summary>
public ElementContainerCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
IIdKeyMap idKeyMap,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory)
: base(appCaches, serializer, eventAggregator, factory)
=> _idKeyMap = idKeyMap;
#region Json
/// <summary>
/// Represents a JSON-serializable payload identifying an element container that changed.
/// </summary>
public class JsonPayload
{
/// <summary>
/// Initializes a new instance of the <see cref="JsonPayload"/> class.
/// </summary>
/// <param name="id">The unique integer identifier for the container.</param>
/// <param name="key">The unique GUID key associated with the container.</param>
public JsonPayload(int id, Guid key)
{
Id = id;
Key = key;
}
/// <summary>
/// Gets the unique integer identifier for the container.
/// </summary>
public int Id { get; }
/// <summary>
/// Gets the unique GUID key associated with the container.
/// </summary>
public Guid Key { get; }
}
#endregion
#region Define
/// <summary>
/// Represents a unique identifier for the cache refresher.
/// </summary>
public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21");
/// <inheritdoc/>
public override Guid RefresherUniqueId => UniqueId;
/// <inheritdoc/>
public override string Name => "Element Container Cache Refresher";
#endregion
#region Refresher
/// <inheritdoc/>
public override void Refresh(JsonPayload[] payloads)
{
foreach (JsonPayload payload in payloads)
{
// Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync.
_idKeyMap.ClearCache(payload.Id);
}
base.Refresh(payloads);
}
// These events should never trigger. Everything should be PAYLOAD/JSON.
/// <inheritdoc/>
public override void RefreshAll() => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(int id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(Guid id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Remove(int id) => throw new NotSupportedException();
#endregion
}
@@ -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>
@@ -109,6 +114,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,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>
@@ -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.
builder
@@ -100,6 +101,7 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<CacheSettings>()
.AddUmbracoOptions<SystemDateMigrationSettings>()
.AddUmbracoOptions<DistributedJobSettings>()
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
.AddUmbracoOptions<WebsiteSettings>()
.AddUmbracoOptions<SignalRSettings>();
@@ -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>
@@ -402,6 +402,7 @@
<key alias="invalidMediaType">The chosen media type is invalid.</key>
<key alias="invalidContentType">The chosen content is of invalid type.</key>
<key alias="missingContent">The chosen content does not exist.</key>
<key alias="missingMedia">The chosen media does not exist.</key>
<key alias="multipleMediaNotAllowed">Multiple selected media is not allowed.</key>
<key alias="notOneOfOptions">The value '%0%' is not one of the available options.</key>
<key alias="multipleNotOneOfOptions">The values '%0%' are not found in the the available options.</key>
@@ -463,7 +464,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
-->
@@ -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
@@ -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,
};
@@ -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,
}
@@ -24,4 +24,9 @@ public enum TaggableObjectTypes
/// Represents member entities (user accounts).
/// </summary>
Member,
/// <summary>
/// Represents element entities.
/// </summary>
Element,
}
@@ -0,0 +1,19 @@
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the Element Container Cache Refresher.
/// </summary>
public class ElementContainerCacheRefresherNotification : CacheRefresherNotification
{
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresherNotification"/> class.
/// </summary>
/// <param name="messageObject">The refresher payload.</param>
/// <param name="messageType">Type of the cache refresher message, <see cref="MessageType"/>.</param>
public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType)
: base(messageObject, messageType)
{
}
}
@@ -0,0 +1,30 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// Notification published after one or more redirect URLs have been deleted.
/// </summary>
public class RedirectUrlDeletedNotification : DeletedNotification<IRedirectUrl>
{
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlDeletedNotification" /> class with a single redirect URL.
/// </summary>
/// <param name="target">The redirect URL that was deleted.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlDeletedNotification(IRedirectUrl target, EventMessages messages)
: base(target, messages)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlDeletedNotification" /> class with multiple redirect URLs.
/// </summary>
/// <param name="target">The redirect URLs that were deleted.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlDeletedNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
: base(target, messages)
{
}
}
@@ -0,0 +1,34 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// Notification published before one or more redirect URLs are deleted.
/// </summary>
/// <remarks>
/// This notification is cancelable, allowing handlers to prevent the delete operation
/// by setting <see cref="ICancelableNotification.Cancel" /> to <c>true</c>.
/// </remarks>
public class RedirectUrlDeletingNotification : DeletingNotification<IRedirectUrl>
{
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlDeletingNotification" /> class with a single redirect URL.
/// </summary>
/// <param name="target">The redirect URL being deleted.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlDeletingNotification(IRedirectUrl target, EventMessages messages)
: base(target, messages)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlDeletingNotification" /> class with multiple redirect URLs.
/// </summary>
/// <param name="target">The redirect URLs being deleted.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlDeletingNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
: base(target, messages)
{
}
}
@@ -0,0 +1,30 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// Notification published after a redirect URL has been saved.
/// </summary>
public class RedirectUrlSavedNotification : SavedNotification<IRedirectUrl>
{
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlSavedNotification" /> class.
/// </summary>
/// <param name="target">The redirect URL that was saved.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlSavedNotification(IRedirectUrl target, EventMessages messages)
: base(target, messages)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlSavedNotification" /> class.
/// </summary>
/// <param name="target">The redirect URLs that were saved.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlSavedNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
: base(target, messages)
{
}
}
@@ -0,0 +1,30 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// Notification published before a redirect URL is saved.
/// </summary>
public class RedirectUrlSavingNotification : SavingNotification<IRedirectUrl>
{
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlSavingNotification" /> class.
/// </summary>
/// <param name="target">The redirect URL being saved.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlSavingNotification(IRedirectUrl target, EventMessages messages)
: base(target, messages)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlSavingNotification" /> class.
/// </summary>
/// <param name="target">The redirect URLs being saved.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlSavingNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
: base(target, messages)
{
}
}
@@ -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>
@@ -0,0 +1,34 @@
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Parses the comma-separated content type keys stored in a picker's "allowed content types" configuration value
/// (e.g. <see cref="ContentPickerConfiguration.AllowedContentTypeIds"/> or <see cref="ElementPickerConfiguration.AllowedContentTypeIds"/>).
/// </summary>
internal static class AllowedContentTypeKeysParser
{
/// <summary>
/// Parses the configured value into the set of allowed content type keys.
/// </summary>
/// <param name="configValue">The comma-separated configuration value. Non-GUID entries are ignored.</param>
/// <returns>The set of allowed content type keys, or an empty set when nothing is configured.</returns>
public static HashSet<Guid> Parse(string? configValue)
{
if (configValue.IsNullOrWhiteSpace())
{
return [];
}
var result = new HashSet<Guid>();
foreach (var entry in configValue.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries))
{
if (Guid.TryParse(entry, out Guid guid))
{
result.Add(guid);
}
}
return result;
}
}
@@ -8,4 +8,10 @@ public class ContentPickerConfiguration : IIgnoreUserStartNodesConfig
/// <inheritdoc />
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
public bool IgnoreUserStartNodes { get; set; }
/// <summary>
/// Gets or sets the content type filter for allowed selections.
/// </summary>
[ConfigurationField("allowedContentTypes")]
public string? AllowedContentTypeIds { get; set; }
}
@@ -1,12 +1,16 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Nodes;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors.Validation;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
@@ -70,13 +74,21 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="ioHelper">The IO helper.</param>
/// <param name="attribute">The data editor attribute.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
/// <param name="contentService">The content service.</param>
/// <param name="localizedTextService">The localized text service.</param>
public ContentPickerPropertyValueEditor(
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute)
DataEditorAttribute attribute,
ICoreScopeProvider coreScopeProvider,
IContentService contentService,
ILocalizedTextService localizedTextService)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
{
Validators.Add(new TypedValidatorRunner<string, ContentPickerConfiguration>(
new AllowedTypeValidator(localizedTextService, contentService, coreScopeProvider)));
}
/// <inheritdoc />
@@ -134,4 +146,61 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
return guidUdi.Guid;
}
}
/// <summary>
/// Validates that the selected content matches the allowed content types configured for the property editor.
/// </summary>
/// <param name="localizedTextService">The localized text service.</param>
/// <param name="contentService">The content service.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
internal sealed class AllowedTypeValidator(ILocalizedTextService localizedTextService, IContentService contentService, ICoreScopeProvider coreScopeProvider)
: ITypedValidator<string, ContentPickerConfiguration>
{
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
string? value,
ContentPickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
if (string.IsNullOrEmpty(value) ||
configuration is null ||
Guid.TryParse(value, out Guid id) is false)
{
return [];
}
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
// No filter configured — all content types are allowed.
if (allowedContentTypeKeys.Count == 0)
{
return [];
}
using ICoreScope scope = coreScopeProvider.CreateCoreScope();
Guid? key = contentService.GetById(id)?.ContentType?.Key;
scope.Complete();
if (key is null)
{
return [new ValidationResult(
localizedTextService.Localize(
"validation",
"missingContent"),
["value"])];
}
if (allowedContentTypeKeys.Contains(key.Value) is false)
{
return [new ValidationResult(
localizedTextService.Localize(
"validation",
"invalidObjectType"),
["value"])];
}
return [];
}
}
}
@@ -8,4 +8,32 @@ public class ElementPickerConfiguration : IIgnoreUserStartNodesConfig
/// <inheritdoc />
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
public bool IgnoreUserStartNodes { get; set; }
/// <summary>
/// Gets or sets the validation limits for the number of elements allowed.
/// </summary>
[ConfigurationField("validationLimit")]
public NumberRange? ValidationLimit { get; set; }
/// <summary>
/// Gets or sets the content type filter for allowed selections.
/// </summary>
[ConfigurationField("allowedContentTypes")]
public string? AllowedContentTypeIds { get; set; }
/// <summary>
/// Represents a numeric range with optional minimum and maximum values.
/// </summary>
public class NumberRange
{
/// <summary>
/// Gets or sets the minimum value of the range.
/// </summary>
public int? Min { get; set; }
/// <summary>
/// Gets or sets the maximum value of the range.
/// </summary>
public int? Max { get; set; }
}
}
@@ -1,13 +1,19 @@
using Umbraco.Cms.Core.IO;
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors.Validation;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Element picker property editor that stores element keys
/// Element picker property editor that stores element keys.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.ElementPicker,
@@ -17,6 +23,11 @@ public class ElementPickerPropertyEditor : DataEditor
{
private readonly IIOHelper _ioHelper;
/// <summary>
/// Initializes a new instance of the <see cref="ElementPickerPropertyEditor" /> class.
/// </summary>
/// <param name="dataValueEditorFactory">The data value editor factory.</param>
/// <param name="ioHelper">The IO helper.</param>
public ElementPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper)
: base(dataValueEditorFactory)
{
@@ -28,21 +39,44 @@ public class ElementPickerPropertyEditor : DataEditor
protected override IConfigurationEditor CreateConfigurationEditor() =>
new ElementPickerConfigurationEditor(_ioHelper);
/// <inheritdoc/>
protected override IDataValueEditor CreateValueEditor() =>
DataValueEditorFactory.Create<ElementPickerPropertyValueEditor>(Attribute!);
/// <summary>
/// Provides the value editor for the element picker property editor.
/// </summary>
internal sealed class ElementPickerPropertyValueEditor : DataValueEditor, IDataValueReference
{
private readonly IJsonSerializer _jsonSerializer;
/// <summary>
/// Initializes a new instance of the <see cref="ElementPickerPropertyValueEditor" /> class.
/// </summary>
/// <param name="shortStringHelper">The short string helper.</param>
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="ioHelper">The IO helper.</param>
/// <param name="attribute">The data editor attribute.</param>
/// <param name="localizedTextService">The localized text service.</param>
/// <param name="elementService">The element service.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
public ElementPickerPropertyValueEditor(
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute)
DataEditorAttribute attribute,
ILocalizedTextService localizedTextService,
IElementService elementService,
ICoreScopeProvider coreScopeProvider)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
=> _jsonSerializer = jsonSerializer;
{
_jsonSerializer = jsonSerializer;
Validators.Add(new TypedValidatorRunner<List<string>, ElementPickerConfiguration>(
new MinMaxValidator(localizedTextService),
new AllowedTypeValidator(localizedTextService, elementService, coreScopeProvider)));
}
/// <inheritdoc/>
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
{
var asString = value as string ?? value?.ToString();
@@ -63,4 +97,144 @@ public class ElementPickerPropertyEditor : DataEditor
}
}
}
/// <summary>
/// Validator to ensure that the number of selected elements is within the configured min/max limits, if any.
/// </summary>
internal sealed class MinMaxValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
/// <summary>
/// Initializes a new instance of the <see cref="MinMaxValidator" /> class.
/// </summary>
/// <param name="localizedTextService">The localized text service.</param>
public MinMaxValidator(ILocalizedTextService localizedTextService)
=> _localizedTextService = localizedTextService;
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
List<string>? value,
ElementPickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
var validationResults = new List<ValidationResult>();
if (configuration is null || configuration.ValidationLimit is null)
{
return validationResults;
}
if (configuration.ValidationLimit.Min is int min and > 0 && (value is null || value.Count < min))
{
validationResults.Add(new ValidationResult(
_localizedTextService.Localize(
"validation",
"entriesShort",
[min.ToString(), (min - (value?.Count ?? 0)).ToString()]),
["value"]));
}
if (value is null)
{
return validationResults;
}
if (configuration.ValidationLimit.Max is int max and > 0 && value.Count > max)
{
validationResults.Add(new ValidationResult(
_localizedTextService.Localize(
"validation",
"entriesExceed",
[max.ToString(), (value.Count - max).ToString()]),
["value"]));
}
return validationResults;
}
}
/// <summary>
/// Validator to ensure that all selected elements are of an allowed content type, if any are configured.
/// </summary>
internal sealed class AllowedTypeValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
private readonly IElementService _elementService;
private readonly ICoreScopeProvider _coreScopeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="AllowedTypeValidator" /> class.
/// </summary>
/// <param name="localizedTextService">The localized text service.</param>
/// <param name="elementService">The element service.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
public AllowedTypeValidator(
ILocalizedTextService localizedTextService,
IElementService elementService,
ICoreScopeProvider coreScopeProvider)
{
_localizedTextService = localizedTextService;
_elementService = elementService;
_coreScopeProvider = coreScopeProvider;
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
List<string>? value,
ElementPickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
if (value is null || value.Count == 0 || configuration is null)
{
return [];
}
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
// No filter configured — all element types are allowed.
if (allowedContentTypeKeys.Count == 0)
{
return [];
}
Guid[] elementIds = value
.Where(v => Guid.TryParse(v, out _))
.Select(Guid.Parse)
.Distinct()
.ToArray();
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
IElement[] elements = _elementService.GetByIds(elementIds).ToArray();
scope.Complete();
// Compare against the distinct requested keys (not the raw value count, which may include
// duplicates or non-GUID entries) so existing elements aren't incorrectly reported as missing.
if (elements.Length != elementIds.Length)
{
return [
new ValidationResult(
_localizedTextService.Localize("validation", "missingContent"),
["value"])
];
}
foreach (IElement element in elements)
{
if (allowedContentTypeKeys.Contains(element.ContentType.Key) is false)
{
return
[
new ValidationResult(
_localizedTextService.Localize("validation", "invalidObjectType"),
["value"])
];
}
}
return [];
}
}
}
@@ -67,7 +67,7 @@ internal sealed class EntityDataPickerPropertyEditor : DataEditor
/// <summary>
/// Validates the min/max configuration for the entity data picker property editor.
/// </summary>
internal sealed class MinMaxValidator : ITypedJsonValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
internal sealed class MinMaxValidator : ITypedValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
@@ -9,17 +9,13 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// </summary>
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
public interface ITypedJsonValidator<TValue, TConfiguration>
[Obsolete("Use ITypedValidator instead; the validator contract is not JSON-specific. Scheduled for removal in Umbraco 20.")]
public interface ITypedJsonValidator<TValue, TConfiguration> : ITypedValidator<TValue, TConfiguration>
{
/// <summary>
/// Validates the specified value against the configuration.
/// </summary>
/// <param name="value">The deserialized value to validate.</param>
/// <param name="configuration">The data type configuration.</param>
/// <param name="valueType">The value type.</param>
/// <param name="validationContext">The property validation context.</param>
/// <returns>A collection of validation results.</returns>
public abstract IEnumerable<ValidationResult> Validate(
// Re-declared (rather than purely inherited from ITypedValidator) so the ITypedJsonValidator.Validate member
// remains present for binary compatibility with consumers compiled against this interface in v15-v17.
// TODO (V20): remove together with this interface.
new IEnumerable<ValidationResult> Validate(
TValue? value,
TConfiguration? configuration,
string? valueType,
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.Models.Validation;
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
/// A validator that operates on an already-typed value and configuration.
/// <remarks>
/// Used together with an <see cref="IValueValidator"/> runner that materializes the typed value: see
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> for value editors whose value is already typed, and
/// <see cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/> for JSON based value editors, where the value is deserialized once before validation.
/// </remarks>
/// </summary>
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
public interface ITypedValidator<TValue, TConfiguration>
{
/// <summary>
/// Validates the specified value against the configuration.
/// </summary>
/// <param name="value">The typed value to validate.</param>
/// <param name="configuration">The data type configuration.</param>
/// <param name="valueType">The value type.</param>
/// <param name="validationContext">The property validation context.</param>
/// <returns>A collection of validation results.</returns>
IEnumerable<ValidationResult> Validate(
TValue? value,
TConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext);
}
@@ -6,26 +6,47 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
/// <para>
/// An aggregate validator for JSON based value editors, to avoid doing multiple deserialization.
/// An aggregate <see cref="IValueValidator"/> for JSON based value editors. Deserializes the editor value into
/// <typeparamref name="TValue"/> once (avoiding repeated deserialization), casts the configuration once, and passes both
/// to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
/// </para>
/// <para>
/// Will deserialize once, and cast the configuration once, and pass those values to each <see cref="ITypedJsonValidator{TValue,TConfiguration}"/>, aggregating the results.
/// Use this runner when the editor value reaching validation is raw JSON that must be deserialized before validation —
/// typically an array of complex objects, such as a media picker storing crop data, which the backoffice JSON object
/// converter leaves as un-typed JSON nodes rather than a typed CLR value.
/// </para>
/// <para>
/// When the editor value is already the typed CLR value (so only a cast is needed, with no deserialization) use
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> instead. That is the only difference between the two runners:
/// this one deserializes, the other casts.
/// </para>
/// </summary>
/// <typeparam name="TValue">The type of the expected value.</typeparam>
/// <typeparam name="TConfiguration">The type of the expected configuration</typeparam>
/// <seealso cref="TypedValidatorRunner{TValue,TConfiguration}"/>
public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
where TValue : class
{
private readonly IJsonSerializer _jsonSerializer;
private readonly ITypedJsonValidator<TValue, TConfiguration>[] _validators;
private readonly ITypedValidator<TValue, TConfiguration>[] _validators;
/// <summary>
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
/// </summary>
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="validators">The collection of validators to run.</param>
[Obsolete("Use the constructor accepting ITypedValidator instances. Scheduled for removal in Umbraco 20.")]
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedJsonValidator<TValue, TConfiguration>[] validators)
: this(jsonSerializer, (ITypedValidator<TValue, TConfiguration>[])validators)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
/// </summary>
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="validators">The collection of validators to run.</param>
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedValidator<TValue, TConfiguration>[] validators)
{
_jsonSerializer = jsonSerializer;
_validators = validators;
@@ -51,7 +72,7 @@ public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
return validationResults;
}
foreach (ITypedJsonValidator<TValue, TConfiguration> validator in _validators)
foreach (ITypedValidator<TValue, TConfiguration> validator in _validators)
{
validationResults.AddRange(validator.Validate(deserializedValue, configuration, valueType, validationContext));
}
@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.Models.Validation;
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
/// <para>
/// An aggregate <see cref="IValueValidator"/> that casts the editor value once and passes it, along with the cast
/// configuration, to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
/// </para>
/// <para>
/// Use this runner when the editor value reaching validation is already the typed CLR value (<typeparamref name="TValue"/>),
/// so a cast is all that is needed — for example a content picker (value is a <see cref="string"/>) or an element picker
/// (value is a <c>List&lt;string&gt;</c>, since the backoffice JSON object converter resolves an array of scalars into a typed list).
/// </para>
/// <para>
/// When the editor value is instead raw JSON that must be deserialized into <typeparamref name="TValue"/> before validation —
/// typically an array of complex objects, such as a media picker storing crop data — use <see cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/>
/// instead. That is the only difference between the two runners: this one casts, the other deserializes.
/// </para>
/// </summary>
/// <typeparam name="TValue">The type of the expected value.</typeparam>
/// <typeparam name="TConfiguration">The type of the expected configuration.</typeparam>
/// <seealso cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/>
public class TypedValidatorRunner<TValue, TConfiguration> : IValueValidator
where TValue : class
{
private readonly ITypedValidator<TValue, TConfiguration>[] _validators;
/// <summary>
/// Initializes a new instance of the <see cref="TypedValidatorRunner{TValue, TConfiguration}"/> class.
/// </summary>
/// <param name="validators">The collection of validators to run.</param>
public TypedValidatorRunner(params ITypedValidator<TValue, TConfiguration>[] validators)
=> _validators = validators;
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
object? value,
string? valueType,
object? dataTypeConfiguration,
PropertyValidationContext validationContext)
{
if (dataTypeConfiguration is not TConfiguration configuration)
{
return [];
}
if (value is not null and not TValue)
{
return [];
}
var typedValue = value as TValue;
return _validators
.SelectMany(v => v.Validate(typedValue, configuration, valueType, validationContext))
.ToList();
}
}
@@ -103,11 +103,19 @@ internal sealed class ContentEditingService
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(
ContentCreateModel createModel,
Guid userKey)
=> await ValidateCulturesAndPropertiesAsync(
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidateCulturesAndPropertiesAsync(
createModel,
createModel.ContentTypeKey,
createModel.Variants.Select(variant => variant.Culture),
userKey);
}
/// <inheritdoc />
public async Task<Attempt<ContentCreateResult, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey)
@@ -211,6 +219,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<ContentEditingOperationStatus> UpdateTemplateAsync(IContent content, Guid? templateKey)
{
if (templateKey == null)
@@ -258,8 +275,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)
@@ -268,6 +285,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
@@ -500,6 +500,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,
@@ -661,6 +665,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())
@@ -90,9 +90,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.
@@ -115,16 +116,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
{
@@ -142,4 +134,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."),
};
}
+44 -1
View File
@@ -1652,7 +1652,13 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
{
scope.WriteLock(Constants.Locks.ContentTree);
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded content (e.g. loaded with loadTemplates: false or without property data),
// and saving those directly would wipe the template and property data (#23120).
// GetByIds returns items in the requested order, preserving the caller's ordering that drives the sort.
IContent[] reloaded = GetByIds(itemsA.Select(x => x.Id).ToArray()).ToArray();
OperationResult ret = Sort(scope, reloaded, userId, evtMsgs);
scope.Complete();
return ret;
}
@@ -1690,6 +1696,43 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
}
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the content repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IContent[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new ContentTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IContent? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new ContentTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, EventMessages eventMessages)
{
var sortingNotification = new ContentSortingNotification(itemsA, eventMessages);
@@ -95,6 +95,18 @@ public interface IContentEditingService
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</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 unique identifier of the user performing the action.</param>
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, string? culture, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Deletes a content item whether it is in the recycle bin or not.
/// </summary>
@@ -418,6 +418,22 @@ public interface IContentService : IPublishableContentService<IContent>
/// <returns>The operation result.</returns>
OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child document identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{int}?, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
#endregion
#region Publish Document
@@ -118,6 +118,18 @@ public interface IMediaEditingService
/// </returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="userKey">The unique identifier of the user performing the operation.</param>
/// <returns>The operation status indicating the operation outcome.</returns>
/// <remarks>Media items never vary by culture, so children are always ordered by the invariant name.</remarks>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Permanently deletes a media item from the recycle bin.
/// </summary>
@@ -338,6 +338,22 @@ public interface IMediaService : IContentServiceBase<IMedia>
/// <returns>True if sorting succeeded, otherwise False</returns>
bool Sort(IEnumerable<IMedia> items, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child media identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{IMedia}, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
/// <summary>
/// Creates an <see cref="IMedia" /> object using the alias of the <see cref="IMediaType" />
/// that this Media should based on.
@@ -1,5 +1,5 @@
using System.Threading.Tasks;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -15,26 +15,105 @@ public interface IRedirectUrlService : IService
/// <param name="contentKey">The content unique key.</param>
/// <param name="culture">The culture.</param>
/// <remarks>Is a proper Umbraco route eg /path/to/foo or 123/path/tofoo.</remarks>
[Obsolete("Use RegisterWithStatus to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
void Register(string url, Guid contentKey, string? culture = null);
/// <summary>
/// Registers a redirect URL.
/// </summary>
/// <param name="oldUrl">The previous Umbraco URL route the redirect is being created from.</param>
/// <param name="contentKey">The content unique key.</param>
/// <param name="culture">The culture.</param>
/// <returns>
/// An <see cref="Attempt{TResult,TStatus}" /> containing the registered redirect URL on success, or
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
/// canceled the operation.
/// </returns>
// TODO (V20): Remove the default implementation, and rename this back to "Register" when the obsolete Register overload is removed.
Attempt<IRedirectUrl?, RedirectUrlOperationStatus> RegisterWithStatus(string oldUrl, Guid contentKey, string? culture = null)
{
#pragma warning disable CS0618 // Type or member is obsolete
Register(oldUrl, contentKey, culture);
#pragma warning restore CS0618 // Type or member is obsolete
return Attempt.SucceedWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.Success, null);
}
/// <summary>
/// Deletes all redirect URLs for a given content.
/// </summary>
/// <param name="contentKey">The content unique key.</param>
[Obsolete("Use DeleteContentRedirectUrlsWithStatus to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
void DeleteContentRedirectUrls(Guid contentKey);
/// <summary>
/// Deletes all redirect URLs for a given content, returning the operation status.
/// </summary>
/// <param name="contentKey">The content unique key.</param>
/// <returns>
/// <see cref="RedirectUrlOperationStatus.Success" /> on success, or
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
/// canceled the operation.
/// </returns>
// TODO (V20): Remove the default implementation when the obsolete DeleteContentRedirectUrls overload is removed.
RedirectUrlOperationStatus DeleteContentRedirectUrlsWithStatus(Guid contentKey)
{
#pragma warning disable CS0618 // Type or member is obsolete
DeleteContentRedirectUrls(contentKey);
#pragma warning restore CS0618 // Type or member is obsolete
return RedirectUrlOperationStatus.Success;
}
/// <summary>
/// Deletes a redirect URL.
/// </summary>
/// <param name="redirectUrl">The redirect URL to delete.</param>
[Obsolete("Use DeleteWithStatus(IRedirectUrl) to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
void Delete(IRedirectUrl redirectUrl);
/// <summary>
/// Deletes a redirect URL, returning the operation status.
/// </summary>
/// <param name="redirectUrl">The redirect URL to delete.</param>
/// <returns>
/// <see cref="RedirectUrlOperationStatus.Success" /> on success, or
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
/// canceled the operation.
/// </returns>
// TODO (V20): Remove the default implementation when the obsolete Delete(IRedirectUrl) overload is removed.
RedirectUrlOperationStatus DeleteWithStatus(IRedirectUrl redirectUrl)
{
#pragma warning disable CS0618 // Type or member is obsolete
Delete(redirectUrl);
#pragma warning restore CS0618 // Type or member is obsolete
return RedirectUrlOperationStatus.Success;
}
/// <summary>
/// Deletes a redirect URL.
/// </summary>
/// <param name="id">The redirect URL identifier.</param>
[Obsolete("Use DeleteWithStatus(Guid) to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
void Delete(Guid id);
/// <summary>
/// Deletes a redirect URL by its identifier, returning the operation status.
/// </summary>
/// <param name="id">The redirect URL identifier.</param>
/// <returns>
/// <see cref="RedirectUrlOperationStatus.Success" /> on success,
/// <see cref="RedirectUrlOperationStatus.NotFound" /> if no redirect URL with the given identifier exists, or
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
/// canceled the operation.
/// </returns>
// TODO (V20): Remove the default implementation when the obsolete Delete(Guid) overload is removed.
RedirectUrlOperationStatus DeleteWithStatus(Guid id)
{
#pragma warning disable CS0618 // Type or member is obsolete
Delete(id);
#pragma warning restore CS0618 // Type or member is obsolete
return RedirectUrlOperationStatus.Success;
}
/// <summary>
/// Deletes all redirect URLs.
/// </summary>
+18
View File
@@ -54,6 +54,18 @@ public interface ITagService : IService
/// </summary>
IEnumerable<TaggedEntity> GetTaggedMembersByTag(string tag, string? group = null, string? culture = null);
/// <summary>
/// Gets all elements tagged with any tag in the specified group.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null) => [];
/// <summary>
/// Gets all elements tagged with the specified tag.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null) => [];
/// <summary>
/// Gets all tags.
/// </summary>
@@ -100,6 +112,12 @@ public interface ITagService : IService
/// </summary>
IEnumerable<ITag> GetAllMemberTags(string? group = null, string? culture = null);
/// <summary>
/// Gets all element tags.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null) => [];
/// <summary>
/// Gets all tags attached to an entity via a property.
/// </summary>
@@ -87,7 +87,15 @@ internal sealed class MediaEditingService
/// <inheritdoc />
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
}
/// <inheritdoc />
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
@@ -169,6 +177,13 @@ internal sealed class MediaEditingService
public async Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
// Media never varies by culture, so children are always ordered by the invariant name.
=> await HandleSortByFieldAsync(parentKey, field, direction, culture: null, userKey);
/// <inheritdoc />
protected override IMedia New(string name, int parentId, IMediaType mediaType)
=> new Models.Media(name, parentId, mediaType);
@@ -191,8 +206,8 @@ internal sealed class MediaEditingService
=> ContentService.Delete(media, userId).Result;
/// <inheritdoc />
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, filter: null, ordering: ordering);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
@@ -203,6 +218,13 @@ internal sealed class MediaEditingService
: ContentEditingOperationStatus.CancelledByNotification;
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
/// <summary>
/// Saves a media item to the repository.
/// </summary>
+46
View File
@@ -1309,6 +1309,15 @@ namespace Umbraco.Cms.Core.Services
{
scope.WriteLock(Constants.Locks.MediaTree);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded media (e.g. without property data), and saving those directly would
// wipe the property data (#23120). Preserve the caller's ordering, which drives the sort.
var reloadedById = GetByIds(itemsA.Select(x => x.Id)).ToDictionary(x => x.Id);
itemsA = itemsA
.Select(x => reloadedById.TryGetValue(x.Id, out IMedia? media) ? media : null)
.WhereNotNull()
.ToArray();
var savingNotification = new MediaSavingNotification(itemsA, messages);
if (scope.Notifications.PublishCancelable(savingNotification))
{
@@ -1347,6 +1356,43 @@ namespace Umbraco.Cms.Core.Services
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.MediaTree);
_mediaRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the media repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IMedia[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new MediaTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IMedia? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new MediaTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
/// <summary>
/// Checks the data integrity of the media tree and optionally fixes detected issues.
/// </summary>
@@ -0,0 +1,27 @@
namespace Umbraco.Cms.Core.Services.OperationStatus;
/// <summary>
/// Represents the status of a redirect URL operation.
/// </summary>
public enum RedirectUrlOperationStatus
{
/// <summary>
/// The operation completed successfully.
/// </summary>
Success,
/// <summary>
/// The operation was cancelled by a notification handler.
/// </summary>
CancelledByNotification,
/// <summary>
/// The operation failed because the redirect URL could not be found.
/// </summary>
NotFound,
/// <summary>
/// An unknown error occurred during the operation.
/// </summary>
Unknown,
}
+103 -5
View File
@@ -1,8 +1,10 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -25,45 +27,141 @@ internal sealed class RedirectUrlService : RepositoryService, IRedirectUrlServic
_redirectUrlRepository = redirectUrlRepository;
/// <inheritdoc/>
[Obsolete("Use RegisterWithStatus instead. Scheduled for removal in Umbraco 20.")]
public void Register(string url, Guid contentKey, string? culture = null)
=> RegisterWithStatus(url, contentKey, culture);
/// <inheritdoc/>
public Attempt<IRedirectUrl?, RedirectUrlOperationStatus> RegisterWithStatus(string oldUrl, Guid contentKey, string? culture = null)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IRedirectUrl? redir = _redirectUrlRepository.Get(url, contentKey, culture);
IRedirectUrl? redir = _redirectUrlRepository.Get(oldUrl, contentKey, culture);
if (redir != null)
{
redir.CreateDateUtc = DateTime.UtcNow;
}
else
{
redir = new RedirectUrl { Key = Guid.NewGuid(), Url = url, ContentKey = contentKey, Culture = culture };
redir = new RedirectUrl { Key = Guid.NewGuid(), Url = oldUrl, ContentKey = contentKey, Culture = culture };
}
// Use a detached EventMessages instance so a handler cancelling the save does not surface a
// notification in the backoffice. Redirect creation is a silent side-effect of publishing, so a
// cancellation is not something the editor triggered or can act on - unlike deletion (an explicit
// editor action), where the sibling methods deliberately use EventMessagesFactory.Get() instead.
var eventMessages = new EventMessages();
var savingNotification = new RedirectUrlSavingNotification(redir, eventMessages);
if (scope.Notifications.PublishCancelable(savingNotification))
{
scope.Complete();
return Attempt.FailWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.CancelledByNotification, redir);
}
_redirectUrlRepository.Save(redir);
scope.Notifications.Publish(new RedirectUrlSavedNotification(redir, eventMessages)
.WithStateFrom(savingNotification));
scope.Complete();
return Attempt.SucceedWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.Success, redir);
}
/// <inheritdoc/>
public void Delete(IRedirectUrl redirectUrl)
[Obsolete("Use DeleteWithStatus(IRedirectUrl) instead. Scheduled for removal in Umbraco 20.")]
public void Delete(IRedirectUrl redirectUrl) => DeleteWithStatus(redirectUrl);
/// <inheritdoc/>
public RedirectUrlOperationStatus DeleteWithStatus(IRedirectUrl redirectUrl)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
EventMessages eventMessages = EventMessagesFactory.Get();
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrl, eventMessages);
if (scope.Notifications.PublishCancelable(deletingNotification))
{
scope.Complete();
return RedirectUrlOperationStatus.CancelledByNotification;
}
_redirectUrlRepository.Delete(redirectUrl);
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrl, eventMessages)
.WithStateFrom(deletingNotification));
scope.Complete();
return RedirectUrlOperationStatus.Success;
}
/// <inheritdoc/>
public void Delete(Guid id)
[Obsolete("Use DeleteWithStatus(Guid) instead. Scheduled for removal in Umbraco 20.")]
public void Delete(Guid id) => DeleteWithStatus(id);
/// <inheritdoc/>
public RedirectUrlOperationStatus DeleteWithStatus(Guid id)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IRedirectUrl? redirectUrl = _redirectUrlRepository.Get(id);
if (redirectUrl is null)
{
scope.Complete();
return RedirectUrlOperationStatus.NotFound;
}
EventMessages eventMessages = EventMessagesFactory.Get();
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrl, eventMessages);
if (scope.Notifications.PublishCancelable(deletingNotification))
{
scope.Complete();
return RedirectUrlOperationStatus.CancelledByNotification;
}
_redirectUrlRepository.Delete(id);
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrl, eventMessages)
.WithStateFrom(deletingNotification));
scope.Complete();
return RedirectUrlOperationStatus.Success;
}
/// <inheritdoc/>
public void DeleteContentRedirectUrls(Guid contentKey)
[Obsolete("Use DeleteContentRedirectUrlsWithStatus instead. Scheduled for removal in Umbraco 20.")]
public void DeleteContentRedirectUrls(Guid contentKey) => DeleteContentRedirectUrlsWithStatus(contentKey);
/// <inheritdoc/>
public RedirectUrlOperationStatus DeleteContentRedirectUrlsWithStatus(Guid contentKey)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IRedirectUrl[] redirectUrls = _redirectUrlRepository.GetContentUrls(contentKey).ToArray();
if (redirectUrls.Length == 0)
{
scope.Complete();
return RedirectUrlOperationStatus.Success;
}
EventMessages eventMessages = EventMessagesFactory.Get();
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrls, eventMessages);
if (scope.Notifications.PublishCancelable(deletingNotification))
{
scope.Complete();
return RedirectUrlOperationStatus.CancelledByNotification;
}
_redirectUrlRepository.DeleteContentUrls(contentKey);
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrls, eventMessages)
.WithStateFrom(deletingNotification));
scope.Complete();
return RedirectUrlOperationStatus.Success;
}
/// <inheritdoc/>
+27
View File
@@ -101,6 +101,24 @@ public class TagService : RepositoryService, ITagService
}
}
/// <inheritdoc />
public IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Element, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTaggedEntitiesByTag(TaggableObjectTypes.Element, tag, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetAllTags(string? group = null, string? culture = null)
{
@@ -162,6 +180,15 @@ public class TagService : RepositoryService, ITagService
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTagsForEntityType(TaggableObjectTypes.Element, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetTagsForProperty(int contentId, string propertyTypeAlias, string? group = null, string? culture = null)
{
@@ -1,4 +1,4 @@
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A background job that will be executed by an available server. With a single server setup this will always be the same.
@@ -16,6 +16,19 @@ public interface IDistributedBackgroundJob
/// </summary>
TimeSpan Period { get; }
/// <summary>
/// Gets a value indicating whether the job's runs should be aligned to clock boundaries derived from <see cref="Period" />.
/// </summary>
/// <remarks>
/// When <c>true</c>, the job becomes runnable on the next clock boundary that is a multiple of <see cref="Period" />
/// (measured from a fixed <strong>UTC</strong> origin, so boundaries fall on round clock times such as on the minute
/// or every N seconds) rather than at <c>LastRun + Period</c>.
/// For predictable boundaries <see cref="Period" /> should divide evenly into one hour.
/// The scheduler may cache this value when it first evaluates registered jobs; changing it at runtime may require an application restart.
/// Defaults to <c>false</c>, preserving the original drift-from-completion behaviour.
/// </remarks>
bool AlignToClock => false;
/// <summary>
/// Run the job.
/// </summary>

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