Compare commits

...
Author SHA1 Message Date
Sven Geusens 31f0ad4490 Revert "remove dotnet docfx global flag"
This reverts commit b6307fb359.
2026-01-13 16:56:59 +01:00
Sven Geusens b6307fb359 remove dotnet docfx global flag 2026-01-13 16:26:15 +01:00
Sven Geusens d37b5a2edb Use newer version of docfx by installing it as a dotnet tool instead of trough chocolatey 2026-01-13 14:22:00 +01:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>nielslyngsoeNiels Lyngsø
dc459bca67 Add loading indicator to composition picker modal (#21086)
* Initial plan

* Add loading indicator to composition picker modal

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

* Document implementation and verify changes

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

* Address code review feedback - add accessibility and documentation

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nielslyngsoe <6791648+nielslyngsoe@users.noreply.github.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2026-01-13 13:14:40 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>nielslyngsoeMads Rasmussen
2a7478e7f4 Add loading indicator to data type picker flow modal (#21085)
* Initial plan

* Add loading indicator to data-type-picker-flow-modal

- Added _isLoading state variable
- Updated #getDataTypes() to set loading state with try-finally
- Added uui-loader component in #renderGrid() when loading
- Created test file with basic tests
- Fixed linter warnings

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

* Refactor: Replace inline styles with CSS class for loader

- Added .loader-container CSS class
- Removed inline styles from loader div
- Improves maintainability and follows best practices

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

* Improve tests and revert unrelated package-lock.json changes

- Test observable behavior (loader element) instead of private properties
- Added tests for loader visibility during loading states
- Added test for loader removal after loading completes
- Reverted unintended changes to Umbraco.Web.UI.Login/package-lock.json

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

* Refactor: Extract helper function in tests for cleaner code

- Added setLoadingState helper function to reduce code duplication
- Updated comments to reflect testing reactive state property
- Improved test readability and maintainability

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

* delete test file not testing anything

* show loading indicator in search field instead

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nielslyngsoe <6791648+nielslyngsoe@users.noreply.github.com>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-01-13 13:05:59 +00:00
Niels LyngsøandGitHub 7b5a087ea5 Sidebar: Make scale grab-area smaller (#21228)
make split view scale area smaller
2026-01-13 13:57:54 +01:00
Andy ButlandandGitHub fefb9ca7aa Docs: Add branch naming convention details to CLAUDE.md (#21311)
* Update Claude memory files to provide further instruction on branch naming convention.

* Resolved points raised in code review.
2026-01-13 13:50:15 +01:00
Andy ButlandandGitHub 8f584e1a49 Tests: Fix intermittent failure in RecycleBinMediaProtectionHelperTests (#21332)
Use thread-safe collections in unit tests verifying functionality that uses parallel processing.
2026-01-13 10:20:35 +01:00
de02456cf8 E2E: QA Added acceptance tests for removing not-found items (#21371)
* Added tests for removing a not-found member picker

* Added tests for adding thumbnail to block

* Updated tests to match the test helper changes

* Refactor code to avoid duplication

* Added tests for removing a thumbnail from a block list/grid and refactor code

* Bumped version

* Bumped version and make tests run in the pipeline

* Update smokeTest command to use '@smoke' filter

---------

Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>
2026-01-13 03:57:04 +00:00
f47a456515 Datatype Collection: Add fallback icon for datatype (#21269)
Add fallback icon for datatype

Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-01-12 15:22:41 +01:00
engjlr 234907aedb Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2026-01-12 13:57:57 +01:00
CopilotJacob Overgaardcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>nielslyngsoeNiels LyngsøEngiber Lozadaengjlr
e3ff3ea3b9 Add loader and error handling to MFA modal (#21089)
* Initial plan

* Add loader and error handling to MFA modal

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

* Use localized error messages in MFA modal

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

* Refactor render logic into separate #renderContent method

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

* Revert src/Umbraco.Web.UI.Login/package-lock.json to main branch version

* Removed unused variable.

* Center the loader in MFA modal.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nielslyngsoe <6791648+nielslyngsoe@users.noreply.github.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
Co-authored-by: engjlr <enl@umbraco.dk>
2026-01-12 13:51:59 +01:00
Jacob OvergaardandClaude Sonnet 4.5 6050bdd40f Tree pickers: Implement noAccess property UI handling for user start nodes
- Add noAccess observable to document and media tree item contexts
- Add visual styling (grayed out, italic, cursor: not-allowed) for noAccess items in tree views
- Implement click prevention to block navigation when noAccess is true
- Update document and media picker input contexts with type-safe guards to prevent selection of noAccess items
- Create type guard utilities (isDocumentTreeItem, isMediaTreeItem) in separate utils files
- Items with noAccess are shown for navigation but cannot be selected or opened

This implements Task 63363 for Feature 63060 "Handle Start Nodes"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 10:52:10 +01:00
8c23cb2ffd Block Grid: Resolve translation keys for group names (closes #20696) (#21362)
* Block Grid: Resolve translation keys for group names (closes #20696)

Add localization support for block group names in two locations:
- Block Grid area type permission combobox options
- Block catalogue modal group headers

Translation keys (e.g., #content_isPublished) used as group names
are now properly resolved instead of displaying the raw key.

* fix: moves group.name to mapper so search works

* fix: localizes block types in permissions element too

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-12 08:16:08 +00:00
faba205025 Collections: fix create action causing full page navigation (#21366)
Fix collection create action causing full page navigation instead of SPA routing

When a collection create action had an href, clicking it would trigger a full
browser navigation instead of using history.pushState for SPA routing. This was
caused by event.stopPropagation() being called before the early return when an
href was present, preventing the global ensureAnchorHistory() listener from
intercepting the click event.

The fix moves stopPropagation() to only execute when we're actually handling
the click via execute() (no href), allowing href-based navigation to bubble
to the window-level router listener for proper SPA navigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-12 07:55:55 +01:00
Chris HoustonandGitHub 35c940364c Code Quality: Fix SA1500, SA1111 and SA1134 StyleCop warnings (#21369)
style: fix SA1500, SA1111, SA1134 StyleCop warnings

Fixed 194 StyleCop analyzer warnings across the codebase:

- SA1500: Move opening braces to their own line for multi-line statements (80 warnings)
- SA1111: Move closing parenthesis to same line as last parameter (50 warnings)
- SA1134: Place each attribute on its own line (64 warnings)

Also added XML documentation to any public APIs within the edited files.
2026-01-12 07:06:56 +01:00
Andy ButlandandGitHub f4ff1da043 Cache: Resolve thread safety issues in RepositoryCacheKeys and FullDataSetRepositoryCachePolicy (closes #21350) (#21355)
* Verify and resolve thread safety issues in RepositoryCacheKeys.

* Verify and resolve thread safety issues in FullDataSetRepositoryCachePolicy.
2026-01-12 06:58:13 +01:00
Niels Lyngsø 30aea87e28 correct comments 2026-01-09 21:33:01 +01:00
Niels LyngsøandGitHub 93229fd457 Mobile navigation: enable horizontal scroll on small screens (#21354)
* make backoffice minimum 800px wide

* minimum 920px
2026-01-09 14:19:47 +00:00
4f29bcd8d6 Fix #20769, added support for clip-text for UmbTableColumn (#20808)
Co-authored-by: Markus Johansson <markus@obviuse.se>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-01-09 13:26:42 +00:00
Niels LyngsøandGitHub 80dddc430c declare events in the global event interface map (#21349) 2026-01-09 13:12:41 +01:00
5a33a55f21 Block workspace: Enforce "AllowEditInvariantFromNonDefault" in variant blocks (closes #20633) (#20868)
* implement preventEditInvariantFromNonDefault for variant blocks

* remove unused

* refactor to enforce both document and block case from the document module

* remove implementation from doc workspace

* prevent editing invariant blocks from non default

* remove unused imports

* more explicit class names

* Refactor invariant edit guard rule creation

Extracted the creation of the invariant property edit guard rule into a reusable _createRule method in the controller base class. Updated block and document workspace controllers to use this method

* Refactor invariant edit rule logic into base controller

Moved the logic for observing properties and variant options and applying property guard rules into a new _observeAndApplyRule method in the base controller. Updated document block and workspace controllers to use this shared method, reducing code duplication and improving maintainability.

* Refactor invariant block edit check into helper methods

Extracted logic for checking invariant blocks and default language datasets into private async methods for better readability and maintainability. This refactor also corrects a context check typo and improves error handling.

* remove unused

---------

Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2026-01-09 09:51:05 +00:00
Mads RasmussenandGitHub 4276e903d7 Fix MSW typescript compile errors (#21319)
Refactor mock handlers to use typed request/response models
2026-01-09 08:50:16 +01:00
Andy ButlandandGitHub c5098f6df3 Multi-node picker: Validate content type when filter is configured but object type is not (closes #21338) (#21342)
Validate content type for multi-node picker when content types are defined but the object type is not (consider as being for a document).
2026-01-09 06:48:42 +01:00
aa0d636503 Entity Signs: rounded infobox top corners (#21341)
* correct styling for entity sign infobox top corners

* Used shorthand notation for padding.

---------

Co-authored-by: engjlr <enl@umbraco.dk>
2026-01-08 17:47:14 +00:00
aka James4uandGitHub a0e1908d2b fix(slider): enforce Maximum Value configuration (closes #21323) (#21339)
- Fixed inverted logic in #parseNumber method
- Changed Number.isFinite(num) ? undefined : num to Number.isFinite(num) ? num : undefined
- Previously, valid max values (e.g., 50) were being ignored and defaulting to 100
- Now correctly parses and uses the configured Maximum Value from data type settings

This ensures the Maximum Value setting in Slider datatype configuration
is properly enforced in the slider UI component.
2026-01-08 17:49:12 +01:00
3e57bb8128 Multi-link picker: Allow drag and drop into empty link picker (closes #21295) (#21325)
* fix(backoffice): allow drag and drop into empty link picker (closes #21295)

* refactor: use classMap to avoid empty class attribute

* refactor: use margin/padding trick for drop zone

* refactor: use CSS :has() selector instead of class

* also enable it for the Document input

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
2026-01-08 15:24:42 +01:00
Dirk SeefeldandGitHub 0094c19780 Gitignore: Exclude acceptance test results from git (#21336)
* exclude acceptance test results from git

* reorder .gitignore
2026-01-08 13:01:03 +00:00
Andy ButlandandGitHub ed162feddf Server Events: Route document updated event when public access entries are modified (closes #21237) (#21310)
* Route server events for public access updates to indicate an update to the protected document.

* Add unit tests for all ServerEventSender notification handlers.

* Adds additional test recommended in code review.

* Addressed parameter name issue raised in code review.
2026-01-08 09:31:27 +01:00
Andreas ZerbstandGitHub 28e9607910 QA: E2E skip running sqlite acceptance test on nightly pipeline (#21331)
* Added skip condition for SQLite

* Added missing comma

* Added missing bracket

* Followed naming convention of parameters and added comments
2026-01-08 08:32:02 +01:00
Andy Butland b8d02067fe Merge branch 'release/17.1'
# Conflicts:
#	src/Umbraco.Web.UI.Client/package.json
#	version.json
2026-01-08 06:36:50 +01:00
3fd311bce8 E2E: QA added temporary waits to flaky acceptance (#21324)
* Added wait

* Additional fixes

* More fixes

---------

Co-authored-by: Nhu Dinh <150406148+nhudinh0309@users.noreply.github.com>
2026-01-08 03:13:54 +00:00
calmandGitHub 55b77157ec Backoffice entity actions: prevent entity actions dropdown from closing on first click (#21322)
fix(backoffice): prevent entity actions dropdown from closing on first open (closes #21320)
2026-01-07 17:04:12 +01:00
calmandAndreas Zerbst 2832436a0e Fix login validation messages not showing on submit (#21306)
Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>
(cherry picked from commit 4df4ee9c31)
2026-01-07 16:06:51 +01:00
906cbb50d8 Removed margin-top to address the loader icon shifting when entering … (#21301)
* Removed margin-top to address the loader icon shifting when entering text

* Space

---------

Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-01-07 13:39:08 +00:00
Engiber LozadaandGitHub 93dff1a7a3 Entity Sings: Add localization keys to core entity signs. (#21302)
Improving localization support for core entity signs.
2026-01-07 14:26:31 +01:00
Niels Lyngsø 962db17628 lint fixes 2026-01-07 13:36:57 +01:00
4df4ee9c31 Fix login validation messages not showing on submit (#21306)
Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>
2026-01-07 13:34:57 +01:00
Andreas ZerbstandGitHub c37fab8e4e E2E: QA Update acceptance to use new assertion pattern (#21297)
* Updated tests

* Updated culture

* Updated tests to use new assertion helpers

* Updated helpers

* Updated tests

* Updated tests

* Updated tests

* Bumped version

* Bumped version
2026-01-07 12:19:02 +00:00
bfc8053c11 Docker: Fix Docker template healthcheck, bind mounts, and HTTPS support (fixes #21278) (#21299)
* Fix test entrypoint

* Fix healthcheck.sh

* Ensure bind mounts gets created on host

* Fix bind mounts permission issues

* Generate self signed cert for dev

* Only generate cert for localhost

* Fixup docker compose file

* Update templates/UmbracoProject/entrypoint.sh

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

* Update templates/UmbracoProject/Dockerfile

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

* Fix APP_UID runtime availability and optimize chown performance

- Export APP_UID as ENV so it's available at container runtime
  (ARG values from .NET base image are only available at build time)
- Only run chown -R when directory ownership differs from APP_UID
  to avoid slow recursive operations on large directories

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 12:56:58 +01:00
Andy ButlandandGitHub 462d63b055 Media: Fix files not deleted from disk when recycle bin protection is enabled (#21309)
* Fix bug where with recycle bin media protection on, the files on disk aren't deleted when the recycle bin is emptied.

* Fix display of media URL when in recycle bin and protection of trashed media is enabled.
2026-01-07 11:20:09 +00:00
50b2c8e03b Performance: Only flush ID/Key map in ContentCacheRefresher on content deletion (#21283)
* Clean-up of ContentCacheRefresher: resolved warnings and tidied up code and comments.

* Only flush the ID/Key map when content is deleted.

* Update src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-07 12:14:36 +01:00
Andy Butland 2564d04941 Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2026-01-07 11:40:47 +01:00
Andy Butland ca3091e92e Fixed version of Microsoft.Extensions.Caching.Hybrid from incorrect one added in earlier commit. 2026-01-07 11:40:33 +01:00
Niels Lyngsø 036225a76d early return if manager is not present 2026-01-07 11:39:49 +01:00
307f3be505 User group: add description to user group (discussions/14986) (#21057)
* Adding description to user groups

* Add description to dto and test and umbraco plan

* update unit test for user group

* remove change from link picker

* edit icon ui for user group

* Fixed table exists check in migration.

* update description in table user groups

* update user group editor css

* update description default

* remove description column element

* add ignore large method

* remove codesence

* update user group descriptions default

* Added description to constructor of ReadOnlyUserGroup.

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>
2026-01-07 11:17:04 +01:00
Niels LyngsøandNiels Lyngsø b5fb17682c Hotfix: refactor of #21221 (#21293)
* refactor reload method to entity-detail

* refactor property value transfering when variation changes
2026-01-07 10:53:11 +01:00
Andy ButlandandNiels Lyngsø 83882a94bd Bumped version to 17.1.0.
# Conflicts:
#	src/Umbraco.Web.UI.Client/package.json
#	version.json
2026-01-07 10:53:11 +01:00
0c5156c7b3 Content: Fix property variation change breaking document save via Infinite Editing (closes #21195) (#21221)
When changing a property's variation setting (Shared/Invariant ↔ Variant) via Infinite Editing,
the document would fail to save with a 404 error.

This fix:
- Adds value migration fallback logic in UmbPropertyValuePresetVariantBuilderController
  to find values when culture/segment doesn't match exactly
- Overrides reload() in UmbContentDetailWorkspaceContextBase to process incoming data
  through _processIncomingData() for proper value transformation
- Detects property variation changes and triggers document reload to migrate values

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 10:52:52 +01:00
Kenn JacobsenandGitHub c859b2883d Members: Fix IMemberService.GetByKeysAsync() (#21312)
Fix IMemberService.GetByKeysAsync()
2026-01-07 07:54:20 +00:00
2aa49a3004 Performance: Avoid database lookup in UserIdKeyResolver for super-user (#21281)
* Avoid an unnecessary look-up for the super-user when resolving ID from key and vice versa.
Utilise an async database methods given the enclosing method is async.

* Applied suggestions from code review.

* Revered async amend (caused pipeline failures with integration tests).

* Apply suggestions from code review

Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>

---------

Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>
2026-01-07 08:37:59 +01:00
Andy ButlandandGitHub 54a1364a88 Dependencies: Update Microsoft packages to 10.0.1 and pin vulnerable transitive dependencies (closes #21122) (#21285)
* Added transitive dependency references to specific libraries where direct dependencies depend on vulnerable versions.

* Enable CentralPackageTransitivePinningEnabled.

* Update MS dependencies to 10.0.1 patch versions.

* Removed System.Text.Encodings.Web.

* Add TODOs for pinned dependency removal.
2026-01-07 06:58:12 +00:00
603788a039 Extension insights: Hide icon from table collection (#21268)
Hide icon from table collection

Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-01-06 15:00:24 +00:00
dependabot[bot]andJacob Overgaard c8454684d0 Bump qs
Bumps the npm_and_yarn group with 1 update in the /src/Umbraco.Web.UI.Client directory: [qs](https://github.com/ljharb/qs).


Updates `qs` from 6.14.0 to 6.14.1
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.0...v6.14.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-06 15:46:22 +01:00
Niels LyngsøandGitHub 21736159af Hotfix: refactor of #21221 (#21293)
* refactor reload method to entity-detail

* refactor property value transfering when variation changes
2026-01-06 15:45:33 +01:00
848df27482 Auto close focus leave (#20700)
* working on a auto closing ... modal when focus leaves

* remove appsetting for local development

* rewrites the focus function to check if the shadow dom exsits before trying to set focus

* Fix JSON formatting in appsettings.Development.template.json

* Simplify focus logic in entity action list

Refactored the focus method to directly focus the first menu item after it is rendered, removing unnecessary nested updateComplete checks and focusing logic for the label button.

* Remove unused 'nothing' import and minor formatting

* Call ext.component?.focus() instead of chaining updateComplete promises.

* Update entity-action-list.element.ts

---------

Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
Co-authored-by: engjlr <enl@umbraco.dk>
2026-01-06 13:43:25 +00:00
6ed4075eeb Added localize label for entity sign bundle (#21252)
* Added localize for entity sign bundle label

* Updated how entity-sign-bundle.element handles label localize

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

* Removed unnecessary conditional check for localize entity-sign-bundle

* Moved localize into render method for entity-sign-bundle.element

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-01-06 14:00:48 +01:00
0da1fa116e Markdown Conversion: Remove hard dependency on deprecated library and replace with IMarkdownToHtmlConverter abstraction (closes #21238 and #19500) (#21242)
* Add IMarkdownToHtmlConverter abstraction with Markdig and HeyRed implementations

- Add IMarkdownToHtmlConverter interface in Umbraco.Core.Strings
- Add MarkdigMarkdownToHtmlConverter using the Markdig library (new default)
- Add HeyRedMarkdownToHtmlConverter using HeyRed.MarkdownSharp (deprecated, for backwards compatibility)
- Update HealthChecks.MarkdownToHtmlConverter to use the new abstraction
- Update MarkdownEditorValueConverter to use the new abstraction
- Add unit tests for both markdown converter implementations
- Add unit tests for HealthChecks.MarkdownToHtmlConverter syntax highlighting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* Apply suggestion from code review.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-06 08:43:47 +01:00
Andy Butland 4e2fdfc269 Bumped version to 17.1.0. 2026-01-06 07:52:42 +01:00
Nhu DinhandGitHub 51cec0e41f E2E: QA Updated acceptance tests for max length validation message (#21230)
* Updated max length validation message

* Fixed format
2026-01-05 17:26:14 +07:00
b563561d2a Management API: Scope notification headers and add document inclusion abstraction (closes #21231, #21241 and #21240) (#21244)
* Adds a check to ensure notifications are only added to relevant CMS management API endpoints.

* Add null check for tagging actions to handle null management API GroupName.

* Provide abstraction for the DocInclusionPredicate when configuring SwaggerGenOptions.

* Apply suggestions from code review

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-05 11:16:41 +01:00
c003751466 Content Type Cache: Clear ContentTypeCommonRepository cache when data types change (closes #21261) (#21289)
Cache: Clear ContentTypeCommonRepository cache when data types change

When a data type's EditorAlias is changed (e.g., BlockList to SingleBlock), the ContentTypeCommonRepository 5-minute cache was not being cleared. This caused content types to return stale PropertyEditorAlias values until server restart, leading to validation using the wrong property editor validators.

The fix adds IContentTypeCommonRepository as a dependency to DataTypeCacheRefresher and calls ClearCache() in RefreshInternal(), matching the pattern already used in ContentTypeCacheRefresher and TemplateCacheRefresher.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:02:36 +09:00
caa01b5122 Code quality: resolve build warnings CS0169 and CS0649 (#21258)
* Fixed the 22 warnings.

Also updated the XML documentation where required.

* Updating XML docs.

* Noted version in obsoletion message.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-01-04 11:52:25 +00:00
Chris HoustonandGitHub b14a7ca3e3 Code Quality: Fix ASP0019 warnings by replacing Headers.Add with Headers.Append (#21260)
Use Append for response headers

- Replaces Add with Append when adding response headers to conform to IHeaderDictionary usage.
- Applies across unauthorized handling, redirects, and custom header signaling.
- Updates tests to use Append consistently.
- Removes ASP0019 from warnings in project configs to reflect new approach.
2025-12-31 12:00:15 +01:00
Chris HoustonandGitHub 0803ab8bda Build Warnings: Suppress ASPDEPR003 warnings in DevelopmentMode.Backoffice (#21259)
Silences deprecation warning in dev mode

Silences deprecated runtime-compile warning in dev mode
Keeps development-time runtime compilation enabled for in-memory dev setup
Relates to ASPDEPR003
2025-12-31 11:29:55 +01:00
18fafaa0f3 Content: Fix property variation change breaking document save via Infinite Editing (closes #21195) (#21221)
When changing a property's variation setting (Shared/Invariant ↔ Variant) via Infinite Editing,
the document would fail to save with a 404 error.

This fix:
- Adds value migration fallback logic in UmbPropertyValuePresetVariantBuilderController
  to find values when culture/segment doesn't match exactly
- Overrides reload() in UmbContentDetailWorkspaceContextBase to process incoming data
  through _processIncomingData() for proper value transformation
- Detects property variation changes and triggers document reload to migrate values

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 16:34:47 +01:00
Andy ButlandandZeegaan 2d35e32920 HybridCache: Clear published content cache on content type change (#21225)
* Clear the cacje level published content cache on content type change.

* Apply suggestions from code review

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit 396ebdd48d)
2025-12-22 15:45:06 +09:00
343 changed files with 7077 additions and 6669 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ This guide describes each step to make your first contribution:
Create a new branch based on `main` and name it after the issue you're fixing. For example: `v15/bugfix/18132-rte-tinymce-onchange-value-check`.
Please follow this format for branches: `v{major}/{feature|bugfix|task}/{issue}-{description}`.
Please follow this format for branches: `v{major}/{feature|bugfix|task|qa|improvement}/{issue}-{description}`.
This is a development branch for the particular issue you're working on, in this case, a bug-fix for issue number `18132` that affects Umbraco v.15.
+3 -2
View File
@@ -96,6 +96,9 @@ tools/docfx/
/tests/Umbraco.Tests.Integration/Views/
/tests/Umbraco.Tests.UnitTests/[Uu]mbraco/[Dd]ata/TEMP/
/BenchmarkDotNet.Artifacts/
playwright-report
trace.zip
/tests/Umbraco.Tests.AcceptanceTest/results
# Ignore auto-generated schema
/src/Umbraco.Cms.Targets/tasks/
@@ -108,5 +111,3 @@ tools/docfx/
/tests/Umbraco.Tests.Integration/appsettings-schema.*.json
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
/src/Umbraco.Cms/appsettings-schema.json
playwright-report
trace.zip
+27 -2
View File
@@ -142,8 +142,33 @@ Web.UI → Web.Common → Infrastructure → Core
### Branching Strategy
- **Main branch**: `main` (protected)
- **Branch naming**:
- See `.github/CONTRIBUTING.md` for full guidelines
- **Branch naming convention**: `v<version>/<type>/<description>`
**Format**: `v{major-version}/{type}/{kebab-case-description}`
**Version**: Read from `version.json` in the repository root. Use the major version number (e.g., `v17` for version 17.x.x).
**Types**:
| Type | Use Case |
|------|----------|
| `feature` | New feature being introduced to the product |
| `bugfix` | Fix to an existing issue with the product |
| `qa` | Adding or updating unit, integration, or end-to-end tests |
| `improvement` | Update to something that already exists but isn't broken (UI finessing, refactoring) |
| `task` | Update that doesn't directly impact product behavior (dependency updates, build pipeline) |
**Description**: A short, kebab-case description (a few words). This should be prefixed with the GitHub issue number if the update is related to resolving a tracked issue.
**Examples**:
```
v17/bugfix/12345-correct-display-of-pending-migrations
v17/feature/add-webhook-support
v17/improvement/optimize-content-cache
v17/qa/add-media-service-tests
v17/task/update-ef-core-dependency
```
See `.github/CONTRIBUTING.md` for full guidelines.
### Pull Request Process
+29 -20
View File
@@ -2,6 +2,7 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<!-- Global packages (private, build-time packages for all projects) -->
<ItemGroup>
@@ -12,27 +13,27 @@
</ItemGroup>
<!-- Microsoft packages -->
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="10.0.1" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.0.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.1.0" />
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
</ItemGroup>
<!-- Umbraco packages -->
@@ -50,6 +51,7 @@
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
<PackageVersion Include="MailKit" Version="4.14.1" />
<PackageVersion Include="Markdig" Version="0.44.0" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
@@ -78,5 +80,12 @@
</ItemGroup>
<!-- Transitive pinned versions (only required because our direct dependencies have vulnerable versions of transitive dependencies) -->
<ItemGroup>
<!-- Dazinator.Extensions.FileProviders references vulnerable versions of the following: -->
<!-- TODO (V18): Remove these pinned dependencies when the Dazinator.Extensions.FileProviders dependency is removed. -->
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<!-- Markdown references vulnerable version of the following: -->
<!-- TODO (V19): Remove these pinned dependencies when the Markdown dependency is removed. -->
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
</ItemGroup>
</Project>
+8
View File
@@ -196,6 +196,14 @@ Copyright: 2013-2024 .NET Foundation and Contributors
---
Markdig: A fast, powerful, CommonMark compliant, extensible Markdown processor for .NET
URL: https://github.com/xoofx/markdig
License: BSD-2-Clause license
Copyright: 2018+, Alexandre Mutel. All rights reserved.
---
Markdown: A library for parsing and compiling Markdown
URL: https://github.com/hey-red/Markdown
+1 -1
View File
@@ -215,7 +215,7 @@ stages:
inputs:
targetType: inline
script: |
choco install docfx --version=2.59.4 -y
dotnet tool install -g docfx
if ($lastexitcode -ne 0){
throw ("Error installing DocFX")
}
+25 -2
View File
@@ -28,6 +28,12 @@ parameters:
type: boolean
default: false
# Can we slow our tests down when running on SQLite? That way we might be able to avoid DB locks
- name: skipSqliteAcceptanceTests
displayName: Skip SQLite acceptance tests
type: boolean
default: true
variables:
nodeVersion: 20
solution: umbraco.sln
@@ -342,7 +348,7 @@ stages:
- job:
displayName: E2E Tests (SQLite)
timeoutInMinutes: 180
condition: ${{ eq(parameters.skipDefaultConfigAcceptanceTests, false) }}
condition: ${{ and(eq(parameters.skipDefaultConfigAcceptanceTests, false), eq(parameters.skipSqliteAcceptanceTests, false)) }}
variables:
# Connection string
CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=Umbraco;Mode=Memory;Cache=Shared;Foreign Keys=True;Pooling=True
@@ -581,6 +587,23 @@ stages:
CONNECTIONSTRINGS__UMBRACODBDSN: Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
additionalEnvironmentVariables: false
# ContentSettingConfig
WindowsContentSettingsConfig:
vmImage: "windows-latest"
testFolder: "ContentSettingConfig"
port: ''
testCommand: "npx playwright test --project=contentSettingConfig"
CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
additionalEnvironmentVariables: false
LinuxContentSettingsConfig:
vmImage: "ubuntu-latest"
testFolder: "ContentSettingConfig"
port: ''
testCommand: "npx playwright test --project=contentSettingConfig"
CONNECTIONSTRINGS__UMBRACODBDSN: Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
additionalEnvironmentVariables: false
# SMTP
LinuxSMTP:
vmImage: "ubuntu-latest"
@@ -693,7 +716,7 @@ stages:
AZUREB2CTESTUSEREMAIL: $(AZUREB2CTESTUSEREMAIL)
AZUREB2CTESTUSERPASSWORD: $(AZUREB2CTESTUSERPASSWORD)
DatabaseType: ${{ variables.DatabaseType }}
# Stop SMTP4dev container
- bash: |
echo "Stopping SMTP4dev container..."
@@ -1,13 +1,10 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Common.OpenApi;
using Umbraco.Extensions;
using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Common.Configuration;
@@ -16,15 +13,31 @@ public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOpt
private readonly IOperationIdSelector _operationIdSelector;
private readonly ISchemaIdSelector _schemaIdSelector;
private readonly ISubTypesSelector _subTypesSelector;
private readonly IDocumentInclusionSelector _documentInclusionSelector;
public ConfigureUmbracoSwaggerGenOptions(
IOperationIdSelector operationIdSelector,
ISchemaIdSelector schemaIdSelector,
ISubTypesSelector subTypesSelector)
ISubTypesSelector subTypesSelector,
IDocumentInclusionSelector documentInclusionSelector)
{
_operationIdSelector = operationIdSelector;
_schemaIdSelector = schemaIdSelector;
_subTypesSelector = subTypesSelector;
_documentInclusionSelector = documentInclusionSelector;
}
[Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 19.")]
public ConfigureUmbracoSwaggerGenOptions(
IOperationIdSelector operationIdSelector,
ISchemaIdSelector schemaIdSelector,
ISubTypesSelector subTypesSelector)
: this(
operationIdSelector,
schemaIdSelector,
subTypesSelector,
StaticServiceProvider.Instance.GetRequiredService<IDocumentInclusionSelector>())
{
}
public void Configure(SwaggerGenOptions swaggerGenOptions)
@@ -39,19 +52,11 @@ public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOpt
});
swaggerGenOptions.CustomOperationIds(description => _operationIdSelector.OperationId(description));
swaggerGenOptions.DocInclusionPredicate((name, api) =>
{
if (api.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
&& controllerActionDescriptor.HasMapToApiAttribute(name))
{
return true;
}
ApiVersionMetadata apiVersionMetadata = api.ActionDescriptor.GetApiVersionMetadata();
return apiVersionMetadata.Name == name
|| (string.IsNullOrEmpty(apiVersionMetadata.Name) && name == DefaultApiConfiguration.ApiName);
});
swaggerGenOptions.TagActionsBy(api => new[] { api.GroupName });
swaggerGenOptions.DocInclusionPredicate(_documentInclusionSelector.Include);
swaggerGenOptions.TagActionsBy(api =>
api.GroupName is null
? []
: new[] { api.GroupName });
swaggerGenOptions.OrderActionsBy(ActionOrderBy);
swaggerGenOptions.SchemaFilter<EnumSchemaFilter>();
swaggerGenOptions.CustomSchemaIds(_schemaIdSelector.SchemaId);
@@ -25,6 +25,7 @@ public static class UmbracoBuilderApiExtensions
builder.Services.AddSingleton<ISchemaIdHandler, SchemaIdHandler>();
builder.Services.AddSingleton<ISubTypesSelector, SubTypesSelector>();
builder.Services.AddSingleton<ISubTypesHandler, SubTypesHandler>();
builder.Services.AddSingleton<IDocumentInclusionSelector, DocumentInclusionSelector>();
builder.Services.Configure<UmbracoPipelineOptions>(options => options.AddFilter(new SwaggerRouteTemplatePipelineFilter("UmbracoApiCommon")));
return builder;
@@ -0,0 +1,30 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Controllers;
using Umbraco.Cms.Api.Common.Configuration;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Determines whether an API description should be included in a specific documentation set based on the document name
/// and API metadata.
/// </summary>
public class DocumentInclusionSelector : IDocumentInclusionSelector
{
/// <inheritdoc/>
public bool Include(string documentName, ApiDescription apiDescription)
{
if (apiDescription.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
&& controllerActionDescriptor.HasMapToApiAttribute(documentName))
{
return true;
}
ApiVersionMetadata apiVersionMetadata = apiDescription.ActionDescriptor.GetApiVersionMetadata();
return apiVersionMetadata.Name == documentName
|| (string.IsNullOrEmpty(apiVersionMetadata.Name) && documentName == DefaultApiConfiguration.ApiName);
}
}
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc.ApiExplorer;
namespace Umbraco.Cms.Api.Common.OpenApi;
/// <summary>
/// Defines a method that determines whether a given API description should be included in a specific documentation
/// document.
/// </summary>
public interface IDocumentInclusionSelector
{
/// <summary>
/// Determines whether the specified API description should be included in the generated documentation for the given
/// document name.
/// </summary>
/// <param name="documentName">The name of the documentation document being generated.</param>
/// <param name="apiDescription">The API description to evaluate for inclusion.</param>
/// <returns>true if the API description should be included in the documentation; otherwise, false.</returns>
bool Include(string documentName, ApiDescription apiDescription);
}
@@ -99,8 +99,8 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
private IActionResult RedirectTo(IApiContentRoute redirectRoute)
{
Response.Headers.Add("Location-Start-Item-Path", redirectRoute.StartItem.Path);
Response.Headers.Add("Location-Start-Item-Id", redirectRoute.StartItem.Id.ToString("D"));
Response.Headers.Append("Location-Start-Item-Path", redirectRoute.StartItem.Path);
Response.Headers.Append("Location-Start-Item-Id", redirectRoute.StartItem.Id.ToString("D"));
return RedirectPermanent(redirectRoute.Path);
}
}
@@ -7,10 +7,9 @@
<PropertyGroup>
<!--
TODO: Fix and remove overrides:
[ASP0019] use IHeaderDictionary.Append or the indexer to append or set headers
[CS0618/CS0612] update obsolete references
-->
<WarningsNotAsErrors>$(WarningsNotAsErrors),ASP0019,CS0618,CS0612</WarningsNotAsErrors>
<WarningsNotAsErrors>$(WarningsNotAsErrors),CS0618,CS0612</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
@@ -1,16 +1,10 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.ViewModels.Content;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Api.Management.ViewModels.Media;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Factories;
@@ -21,7 +15,6 @@ public class MediaUrlFactory : IMediaUrlFactory
private readonly MediaUrlGeneratorCollection _mediaUrlGenerators;
private readonly IAbsoluteUrlBuilder _absoluteUrlBuilder;
public MediaUrlFactory(
IOptions<ContentSettings> contentSettings,
MediaUrlGeneratorCollection mediaUrlGenerators,
@@ -39,10 +32,26 @@ public class MediaUrlFactory : IMediaUrlFactory
.Select(mediaUrl => new MediaUrlInfo
{
Culture = null,
Url = _absoluteUrlBuilder.ToAbsoluteUrl(mediaUrl).ToString(),
Url = CreateMediaUrl(mediaUrl),
})
.ToArray();
private string CreateMediaUrl(string mediaUrl)
{
var url = _absoluteUrlBuilder.ToAbsoluteUrl(mediaUrl).ToString();
if (_contentSettings.EnableMediaRecycleBinProtection is false)
{
return url;
}
return _contentSettings.EnableMediaRecycleBinProtection
? AddProtectedSuffixToMediaUrl(url)
: url;
}
private static string AddProtectedSuffixToMediaUrl(string url) => Path.ChangeExtension(url, Constants.Conventions.Media.TrashedMediaSuffix + Path.GetExtension(url));
public IEnumerable<MediaUrlInfoResponseModel> CreateUrlSets(IEnumerable<IMedia> mediaItems) =>
mediaItems.Select(media => new MediaUrlInfoResponseModel(media.Key, CreateUrls(media))).ToArray();
}
@@ -54,6 +54,7 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
{
Id = userGroup.Key,
Name = userGroup.Name ?? string.Empty,
Description = userGroup.Description ?? string.Empty,
Alias = userGroup.Alias,
DocumentStartNode = ReferenceByIdModel.ReferenceOrNull(contentStartNodeKey),
DocumentRootAccess = contentRootAccess,
@@ -87,6 +88,7 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
{
Id = userGroup.Key,
Name = userGroup.Name ?? string.Empty,
Description = userGroup.Description ?? string.Empty,
Alias = userGroup.Alias,
DocumentStartNode = ReferenceByIdModel.ReferenceOrNull(contentStartNodeKey),
MediaStartNode = ReferenceByIdModel.ReferenceOrNull(mediaStartNodeKey),
@@ -132,6 +134,7 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
{
Name = CleanUserGroupNameOrAliasForXss(requestModel.Name),
Alias = CleanUserGroupNameOrAliasForXss(requestModel.Alias),
Description = requestModel.Description,
Icon = requestModel.Icon,
HasAccessToAllLanguages = requestModel.HasAccessToAllLanguages,
Permissions = requestModel.FallbackPermissions,
@@ -197,9 +200,10 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
current.Name = CleanUserGroupNameOrAliasForXss(request.Name);
current.Alias = CleanUserGroupNameOrAliasForXss(request.Alias);
current.Description = request.Description;
current.Icon = request.Icon;
current.HasAccessToAllLanguages = request.HasAccessToAllLanguages;
current.Permissions = request.FallbackPermissions;
current.GranularPermissions = await _permissionPresentationFactory.CreatePermissionSetsAsync(request.Permissions);
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Management.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Core;
@@ -10,6 +11,12 @@ internal sealed class NotificationHeaderFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
// Only apply to the Umbraco CMS Management API.
if (context.DocumentName != ManagementApiConfiguration.ApiName)
{
return;
}
if (context.ApiDescription.HttpMethod == HttpMethod.Get.Method)
{
return;
@@ -1,13 +1,19 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.ServerEvents;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.ServerEvents;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.ServerEvents;
/// <summary>
/// Handles server event notifications for various entity types by routing create, update, delete, and recycle bin
/// events to connected clients or systems.
/// </summary>
internal sealed class ServerEventSender :
INotificationAsyncHandler<ContentSavedNotification>,
INotificationAsyncHandler<ContentSavedBlueprintNotification>,
@@ -57,8 +63,16 @@ internal sealed class ServerEventSender :
INotificationAsyncHandler<MediaMovedToRecycleBinNotification>
{
private readonly IServerEventRouter _serverEventRouter;
private readonly IIdKeyMap _idKeyMap;
public ServerEventSender(IServerEventRouter serverEventRouter) => _serverEventRouter = serverEventRouter;
/// <summary>
/// Initializes a new instance of the <see cref="ServerEventSender"/> class.
/// </summary>
public ServerEventSender(IServerEventRouter serverEventRouter, IIdKeyMap idKeyMap)
{
_serverEventRouter = serverEventRouter;
_idKeyMap = idKeyMap;
}
private async Task NotifySavedAsync<T>(SavedNotification<T> notification, string source)
where T : IEntity
@@ -113,66 +127,115 @@ internal sealed class ServerEventSender :
}
}
/// <inheritdoc/>
public async Task HandleAsync(ContentSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Document);
/// <inheritdoc/>
public async Task HandleAsync(ContentSavedBlueprintNotification notification, CancellationToken cancellationToken) =>
await RouteCreatedOrUpdatedEvent(Constants.ServerEvents.EventSource.DocumentBlueprint, notification.SavedBlueprint);
/// <inheritdoc/>
public async Task HandleAsync(ContentTypeSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.DocumentType);
/// <inheritdoc/>
public async Task HandleAsync(MediaSavedNotification notification, CancellationToken cancellationToken)
=> await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Media);
/// <inheritdoc/>
public async Task HandleAsync(MediaTypeSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.MediaType);
/// <inheritdoc/>
public async Task HandleAsync(MemberSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Member);
/// <inheritdoc/>
public async Task HandleAsync(MemberTypeSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.MemberType);
/// <inheritdoc/>
public async Task HandleAsync(MemberGroupSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.MemberGroup);
/// <inheritdoc/>
public async Task HandleAsync(DataTypeSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.DataType);
/// <inheritdoc/>
public async Task HandleAsync(LanguageSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Language);
/// <inheritdoc/>
public async Task HandleAsync(ScriptSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Script);
/// <inheritdoc/>
public async Task HandleAsync(StylesheetSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Stylesheet);
/// <inheritdoc/>
public async Task HandleAsync(TemplateSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Template);
/// <inheritdoc/>
public async Task HandleAsync(DictionaryItemSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.DictionaryItem);
/// <inheritdoc/>
public async Task HandleAsync(DomainSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Domain);
/// <inheritdoc/>
public async Task HandleAsync(PartialViewSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.PartialView);
public async Task HandleAsync(PublicAccessEntrySavedNotification notification, CancellationToken cancellationToken) =>
/// <inheritdoc/>
public async Task HandleAsync(PublicAccessEntrySavedNotification notification, CancellationToken cancellationToken)
{
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.PublicAccessEntry);
// For public access entries, we also need to notify affected content items, so any client-side
// cache for the document can be invalidated.
await RouteDocumentUpdatedEventForPublicAccessModification(notification.SavedEntities);
}
private async Task RouteDocumentUpdatedEventForPublicAccessModification(IEnumerable<PublicAccessEntry> entities)
{
foreach (PublicAccessEntry entity in entities)
{
Attempt<Guid> getKeyAttempt = _idKeyMap.GetKeyForId(
entity.ProtectedNodeId,
UmbracoObjectTypes.Document);
if (getKeyAttempt.Success is false)
{
continue;
}
var eventModel = new ServerEvent
{
EventType = Constants.ServerEvents.EventType.Updated,
Key = getKeyAttempt.Result,
EventSource = Constants.ServerEvents.EventSource.Document,
};
await _serverEventRouter.RouteEventAsync(eventModel);
}
}
/// <inheritdoc/>
public async Task HandleAsync(RelationSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Relation);
/// <inheritdoc/>
public async Task HandleAsync(RelationTypeSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.RelationType);
/// <inheritdoc/>
public async Task HandleAsync(UserGroupSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.UserGroup);
/// <inheritdoc/>
public async Task HandleAsync(UserSavedNotification notification, CancellationToken cancellationToken)
{
// We still need to notify of saved entities like any other event source.
@@ -192,12 +255,15 @@ internal sealed class ServerEventSender :
}
}
/// <inheritdoc/>
public async Task HandleAsync(WebhookSavedNotification notification, CancellationToken cancellationToken) =>
await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Webhook);
/// <inheritdoc/>
public async Task HandleAsync(ContentDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Document);
/// <inheritdoc/>
public async Task HandleAsync(ContentDeletedBlueprintNotification notification, CancellationToken cancellationToken)
{
foreach (Core.Models.IContent entity in notification.DeletedBlueprints)
@@ -206,69 +272,97 @@ internal sealed class ServerEventSender :
}
}
/// <inheritdoc/>
public async Task HandleAsync(ContentTypeDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.DocumentType);
/// <inheritdoc/>
public async Task HandleAsync(MediaDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Media);
/// <inheritdoc/>
public async Task HandleAsync(MediaTypeDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.MediaType);
/// <inheritdoc/>
public async Task HandleAsync(MemberDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Member);
/// <inheritdoc/>
public async Task HandleAsync(MemberTypeDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.MemberType);
/// <inheritdoc/>
public async Task HandleAsync(MemberGroupDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.MemberGroup);
/// <inheritdoc/>
public async Task HandleAsync(DataTypeDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.DataType);
/// <inheritdoc/>
public async Task HandleAsync(LanguageDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Language);
/// <inheritdoc/>
public async Task HandleAsync(ScriptDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Script);
/// <inheritdoc/>
public async Task HandleAsync(StylesheetDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Stylesheet);
/// <inheritdoc/>
public async Task HandleAsync(TemplateDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Template);
/// <inheritdoc/>
public async Task HandleAsync(DictionaryItemDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.DictionaryItem);
/// <inheritdoc/>
public async Task HandleAsync(DomainDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Domain);
/// <inheritdoc/>
public async Task HandleAsync(PartialViewDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.PartialView);
public async Task HandleAsync(PublicAccessEntryDeletedNotification notification, CancellationToken cancellationToken) =>
/// <inheritdoc/>
public async Task HandleAsync(PublicAccessEntryDeletedNotification notification, CancellationToken cancellationToken)
{
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.PublicAccessEntry);
// For public access entries, we also need to notify affected content items, so any client-side
// cache for the document can be invalidated.
await RouteDocumentUpdatedEventForPublicAccessModification(notification.DeletedEntities);
}
/// <inheritdoc/>
public async Task HandleAsync(RelationDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Relation);
/// <inheritdoc/>
public async Task HandleAsync(RelationTypeDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.RelationType);
/// <inheritdoc/>
public async Task HandleAsync(UserGroupDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.UserGroup);
/// <inheritdoc/>
public async Task HandleAsync(UserDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.User);
/// <inheritdoc/>
public async Task HandleAsync(WebhookDeletedNotification notification, CancellationToken cancellationToken) =>
await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Webhook);
/// <inheritdoc/>
public async Task HandleAsync(ContentMovedToRecycleBinNotification notification, CancellationToken cancellationToken) =>
await NotifyTrashedAsync(notification, Constants.ServerEvents.EventSource.Document);
/// <inheritdoc/>
public async Task HandleAsync(MediaMovedToRecycleBinNotification notification, CancellationToken cancellationToken) =>
await NotifyTrashedAsync(notification, Constants.ServerEvents.EventSource.Media);
}
@@ -11,12 +11,21 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Services.Entities;
/// <summary>
/// Provides functionality for retrieving user start node entities with access information.
/// </summary>
public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
{
private readonly IEntityService _entityService;
private readonly ICoreScopeProvider _scopeProvider;
private readonly IIdKeyMap _idKeyMap;
/// <summary>
/// Initializes a new instance of the <see cref="UserStartNodeEntitiesService"/> class.
/// </summary>
/// <param name="entityService">The entity service.</param>
/// <param name="scopeProvider">The core scope provider.</param>
/// <param name="idKeyMap">The ID to key mapping service.</param>
public UserStartNodeEntitiesService(IEntityService entityService, ICoreScopeProvider scopeProvider, IIdKeyMap idKeyMap)
{
_entityService = entityService;
@@ -150,8 +159,7 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService
int after,
Ordering ordering,
out long totalBefore,
out long totalAfter
)
out long totalAfter)
{
Attempt<int> targetIdAttempt = _idKeyMap.GetIdForKey(targetKey, umbracoObjectType);
if (targetIdAttempt.Success is false)
@@ -1,4 +1,4 @@
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
namespace Umbraco.Cms.Api.Management.ViewModels.UserGroup;
@@ -22,6 +22,11 @@ public class UserGroupBase
/// </summary>
public required string Alias { get; init; }
/// <summary>
/// The description of the user group
/// </summary>
public string? Description { get; set; }
/// <summary>
/// The Icon for the user group
/// </summary>
@@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation;
using Microsoft.CodeAnalysis;
using Microsoft.Extensions.Options;
#pragma warning disable ASPDEPR003 // MvcRazorRuntimeCompilationOptions is obsolete - intentional use for development-mode runtime compilation
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
@@ -7,6 +7,8 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Exceptions;
#pragma warning disable ASPDEPR003 // MvcRazorRuntimeCompilationOptions is obsolete - intentional use for development-mode runtime compilation
namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto;
internal sealed class UmbracoViewCompilerProvider : IViewCompilerProvider
@@ -11,6 +11,17 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Provides cache refresh functionality for content items, ensuring that content-related caches are updated or
/// invalidated in response to content changes.
/// </summary>
/// <remarks>
/// The ContentCacheRefresher coordinates cache invalidation for content, including memory caches, URL
/// caches, navigation structures, and domain assignments. It responds to content change notifications and ensures that
/// all relevant caches reflect the current state of published and unpublished content. This refresher is used
/// internally to maintain cache consistency after content operations such as publish, unpublish, move,
/// or delete.
/// </remarks>
public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCacheRefresherNotification,
ContentCacheRefresher.JsonPayload>
{
@@ -25,6 +36,9 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
private readonly IPublishStatusManagementService _publishStatusManagementService;
private readonly IIdKeyMap _idKeyMap;
/// <summary>
/// Initializes a new instance of the <see cref="ContentCacheRefresher"/> class.
/// </summary>
public ContentCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
@@ -60,11 +74,15 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
#region Indirect
/// <summary>
/// Clears cached content and public access data from the provided application caches.
/// </summary>
/// <param name="appCaches">The application caches instance from which to clear content and public access entries.</param>
public static void RefreshContentTypes(AppCaches appCaches)
{
// we could try to have a mechanism to notify the PublishedCachesService
// We could try to have a mechanism to notify the PublishedCachesService
// and figure out whether published items were modified or not... keep it
// simple for now, just clear the whole thing
// simple for now, just clear the whole thing.
appCaches.ClearPartialViewCache();
appCaches.IsolatedCaches.ClearCache<PublicAccessEntry>();
@@ -75,16 +93,22 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
#region Define
/// <summary>
/// Represents a unique identifier for the cache refresher.
/// </summary>
public static readonly Guid UniqueId = Guid.Parse("900A4FBE-DF3C-41E6-BB77-BE896CD158EA");
/// <inheritdoc/>
public override Guid RefresherUniqueId => UniqueId;
/// <inheritdoc/>
public override string Name => "ContentCacheRefresher";
#endregion
#region Refresher
/// <inheritdoc/>
public override void RefreshInternal(JsonPayload[] payloads)
{
AppCaches.RuntimeCache.ClearOfType<PublicAccessEntry>();
@@ -121,13 +145,15 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
base.RefreshInternal(payloads);
}
/// <inheritdoc/>
public override void Refresh(JsonPayload[] payloads)
{
var idsRemoved = new HashSet<int>();
foreach (JsonPayload payload in payloads)
{
// if the item is not a blueprint and is being completely removed, we need to refresh the domains cache if any domain was assigned to the content
// If the item is not a blueprint and is being completely removed, we need to refresh the domains cache if any domain was assigned to the content.
// So track the IDs that have been removed.
if (payload.Blueprint is false && payload.ChangeTypes.HasTypesAny(TreeChangeTypes.Remove))
{
idsRemoved.Add(payload.Id);
@@ -138,43 +164,18 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
HandleNavigation(payload);
HandlePublishedAsync(payload, CancellationToken.None).GetAwaiter().GetResult();
if (payload.Id != default)
{
_idKeyMap.ClearCache(payload.Id);
}
if (payload.Key.HasValue)
{
_idKeyMap.ClearCache(payload.Key.Value);
}
HandleIdKeyMap(payload);
}
// Clear partial view cache when published content changes
// Clear partial view cache when published content changes.
if (ShouldClearPartialViewCache(payloads))
{
AppCaches.ClearPartialViewCache();
}
if (idsRemoved.Count > 0)
{
var assignedDomains = _domainService.GetAll(true)
?.Where(x => x.RootContentId.HasValue && idsRemoved.Contains(x.RootContentId.Value)).ToList();
if (assignedDomains?.Count > 0)
{
// TODO: this is duplicating the logic in DomainCacheRefresher BUT we cannot inject that into this because it it not registered explicitly in the container,
// and we cannot inject the CacheRefresherCollection since that would be a circular reference, so what is the best way to call directly in to the
// DomainCacheRefresher?
ClearAllIsolatedCacheByEntityType<IDomain>();
// note: must do what's above FIRST else the repositories still have the old cached
// content and when the PublishedCachesService is notified of changes it does not see
// the new content...
// notify
_domainCacheService.Refresh(assignedDomains
.Select(x => new DomainCacheRefresher.JsonPayload(x.Id, DomainChangeTypes.Remove)).ToArray());
}
}
// Clear the domain cache if any domain is assigned to removed content.
HandleDomainCache(idsRemoved);
base.Refresh(payloads);
}
@@ -251,18 +252,51 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
}
}
private bool IsBranchUnpublished(JsonPayload payload)
{
private static bool IsBranchUnpublished(JsonPayload payload) =>
// If unpublished cultures has one or more values, but published cultures does not, this means that the branch is unpublished entirely
// And therefore should no longer be resolve-able from the cache, so we need to remove it instead.
// Otherwise, some culture is still published, so it should be resolve-able from cache, and published cultures should instead be used.
return payload.UnpublishedCultures is not null && payload.UnpublishedCultures.Length != 0 &&
payload.UnpublishedCultures is not null && payload.UnpublishedCultures.Length != 0 &&
(payload.PublishedCultures is null || payload.PublishedCultures.Length == 0);
private void HandleRouting(JsonPayload payload)
{
if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove))
{
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
// Note that we need to clear the navigation service as the last thing.
if (_documentNavigationQueryService.TryGetDescendantsKeysOrSelfKeys(key, out IEnumerable<Guid>? descendantsOrSelfKeys))
{
_documentUrlService.DeleteUrlsFromCacheAsync(descendantsOrSelfKeys).GetAwaiter().GetResult();
}
else if (_documentNavigationQueryService.TryGetDescendantsKeysOrSelfKeysInBin(key, out IEnumerable<Guid>? descendantsOrSelfKeysInBin))
{
_documentUrlService.DeleteUrlsFromCacheAsync(descendantsOrSelfKeysInBin).GetAwaiter().GetResult();
}
}
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll))
{
_documentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult(); // TODO: make async
}
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
{
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
_documentUrlService.CreateOrUpdateUrlSegmentsAsync(key).GetAwaiter().GetResult();
}
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
{
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
_documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(key).GetAwaiter().GetResult();
}
}
private void HandleNavigation(JsonPayload payload)
{
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll))
{
_documentNavigationManagementService.RebuildAsync().GetAwaiter().GetResult();
@@ -389,50 +423,68 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
await _publishStatusManagementService.AddOrUpdateStatusWithDescendantsAsync(payload.Key.Value, cancellationToken);
}
}
private void HandleRouting(JsonPayload payload)
private void HandleIdKeyMap(JsonPayload payload)
{
if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove))
// We only need to flush the ID/Key map when content is deleted.
if (payload.ChangeTypes.HasTypesAny(TreeChangeTypes.Remove) is false)
{
var key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
//Note the we need to clear the navigation service as the last thing
if (_documentNavigationQueryService.TryGetDescendantsKeysOrSelfKeys(key, out var descendantsOrSelfKeys))
{
_documentUrlService.DeleteUrlsFromCacheAsync(descendantsOrSelfKeys).GetAwaiter().GetResult();
}
else if (_documentNavigationQueryService.TryGetDescendantsKeysOrSelfKeysInBin(key, out var descendantsOrSelfKeysInBin))
{
_documentUrlService.DeleteUrlsFromCacheAsync(descendantsOrSelfKeysInBin).GetAwaiter().GetResult();
}
}
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll))
{
_documentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult(); //TODO make async
return;
}
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
if (payload.Id != default)
{
var key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
_documentUrlService.CreateOrUpdateUrlSegmentsAsync(key).GetAwaiter().GetResult();
_idKeyMap.ClearCache(payload.Id);
}
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
if (payload.Key.HasValue)
{
var key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
_documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(key).GetAwaiter().GetResult();
_idKeyMap.ClearCache(payload.Key.Value);
}
}
// these events should never trigger
// everything should be PAYLOAD/JSON
private void HandleDomainCache(HashSet<int> idsRemoved)
{
if (idsRemoved.Count == 0)
{
return;
}
#pragma warning disable CS0618 // Type or member is obsolete
var assignedDomains = _domainService.GetAll(true)
.Where(x => x.RootContentId.HasValue && idsRemoved.Contains(x.RootContentId.Value))
.ToList();
#pragma warning restore CS0618 // Type or member is obsolete
if (assignedDomains.Count <= 0)
{
return;
}
// TODO: this is duplicating the logic in DomainCacheRefresher BUT we cannot inject that into this because it it not registered explicitly in the container,
// and we cannot inject the CacheRefresherCollection since that would be a circular reference, so what is the best way to call directly in to the
// DomainCacheRefresher?
ClearAllIsolatedCacheByEntityType<IDomain>();
// note: must do what's above FIRST else the repositories still have the old cached
// content and when the PublishedCachesService is notified of changes it does not see
// the new content...
// notify
_domainCacheService.Refresh(assignedDomains
.Select(x => new DomainCacheRefresher.JsonPayload(x.Id, DomainChangeTypes.Remove)).ToArray());
}
// 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
@@ -440,19 +492,40 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
#region Json
// TODO (V14): Change into a record
/// <summary>
/// Represents a JSON-serializable payload containing information about a content or tree change event, including
/// identifiers, change types, and culture-specific publishing details.
/// </summary>
public class JsonPayload
{
/// <summary>
/// Gets the unique integer identifier for the entity.
/// </summary>
public int Id { get; init; }
/// <summary>
/// Gets the unique GUID key associated with the entity, or null if no key is assigned.
/// </summary>
public Guid? Key { get; init; }
/// <summary>
/// Gets the types of changes that have occurred in the tree.
/// </summary>
public TreeChangeTypes ChangeTypes { get; init; }
/// <summary>
/// Gets a value indicating whether the content represents a document blueprint.
/// </summary>
public bool Blueprint { get; init; }
/// <summary>
/// Gets the collection of culture codes in which the content is published.
/// </summary>
public string[]? PublishedCultures { get; init; }
/// <summary>
/// Gets the collection of culture codes for which the content has been unpublished.
/// </summary>
public string[]? UnpublishedCultures { get; init; }
}
@@ -1,3 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
@@ -18,6 +20,7 @@ public sealed class DataTypeCacheRefresher : PayloadCacheRefresherBase<DataTypeC
private readonly IPublishedContentTypeCache _publishedContentTypeCache;
private readonly IDocumentCacheService _documentCacheService;
private readonly IMediaCacheService _mediaCacheService;
private readonly IContentTypeCommonRepository _contentTypeCommonRepository;
public DataTypeCacheRefresher(
AppCaches appCaches,
@@ -29,7 +32,8 @@ public sealed class DataTypeCacheRefresher : PayloadCacheRefresherBase<DataTypeC
IPublishedContentTypeFactory publishedContentTypeFactory,
IPublishedContentTypeCache publishedContentTypeCache,
IDocumentCacheService documentCacheService,
IMediaCacheService mediaCacheService)
IMediaCacheService mediaCacheService,
IContentTypeCommonRepository contentTypeCommonRepository)
: base(appCaches, serializer, eventAggregator, factory)
{
_idKeyMap = idKeyMap;
@@ -38,6 +42,34 @@ public sealed class DataTypeCacheRefresher : PayloadCacheRefresherBase<DataTypeC
_publishedContentTypeCache = publishedContentTypeCache;
_documentCacheService = documentCacheService;
_mediaCacheService = mediaCacheService;
_contentTypeCommonRepository = contentTypeCommonRepository;
}
[Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in V18.")]
public DataTypeCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
IIdKeyMap idKeyMap,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory,
IPublishedModelFactory publishedModelFactory,
IPublishedContentTypeFactory publishedContentTypeFactory,
IPublishedContentTypeCache publishedContentTypeCache,
IDocumentCacheService documentCacheService,
IMediaCacheService mediaCacheService)
: this(
appCaches,
serializer,
idKeyMap,
eventAggregator,
factory,
publishedModelFactory,
publishedContentTypeFactory,
publishedContentTypeCache,
documentCacheService,
mediaCacheService,
StaticServiceProvider.Instance.GetRequiredService<IContentTypeCommonRepository>())
{
}
#region Json
@@ -84,6 +116,9 @@ public sealed class DataTypeCacheRefresher : PayloadCacheRefresherBase<DataTypeC
ClearAllIsolatedCacheByEntityType<IMember>();
ClearAllIsolatedCacheByEntityType<IMemberType>();
// Also clear the 5 minute runtime cache held in ContentTypeCommonRepository.
_contentTypeCommonRepository.ClearCache();
Attempt<IAppPolicyCache?> dataTypeCache = AppCaches.IsolatedCaches.Get<IDataType>();
foreach (JsonPayload payload in payloads)
+1 -1
View File
@@ -29,7 +29,7 @@ public class TypeFinder : ITypeFinder
"DataAnnotationsExtensions,", "DataAnnotationsExtensions.", "Dynamic,", "Examine,", "Examine.",
"HtmlAgilityPack,", "HtmlAgilityPack.", "HtmlDiff,", "ICSharpCode.", "Iesi.Collections,", // used by NHibernate
"JetBrains.Annotations,", "LightInject.", // DI
"LightInject,", "Lucene.", "Markdown,", "Microsoft.", "MiniProfiler,", "Moq,", "MySql.", "NHibernate,",
"LightInject,", "Lucene.", "Markdig,", "Markdown,", "Microsoft.", "MiniProfiler,", "Moq,", "MySql.", "NHibernate,",
"NHibernate.", "Newtonsoft.", "NPoco,", "NuGet.", "RouteDebugger,", "Semver.", "Serilog.", "Serilog,",
"ServiceStack.", "SqlCE4Umbraco,", "Superpower,", // used by Serilog
"System.", "TidyNet,", "TidyNet.", "WebDriver,", "itextsharp,", "mscorlib,", "NUnit,", "NUnit.", "NUnit3.",
@@ -9,6 +9,11 @@ public interface IReadOnlyUserGroup
{
string? Name { get; }
string Alias { get; }
// TODO (V18): Remove the default implementations.
string? Description { get { return null; } }
string? Icon { get; }
int Id { get; }
@@ -19,11 +24,6 @@ public interface IReadOnlyUserGroup
int? StartMediaId { get; }
/// <summary>
/// The alias
/// </summary>
string Alias { get; }
// This is set to return true as default to avoid breaking changes.
bool HasAccessToAllLanguages => true;
@@ -4,12 +4,24 @@ using Umbraco.Cms.Core.Models.Membership.Permissions;
namespace Umbraco.Cms.Core.Models.Membership;
/// <summary>
/// Represents a user group in Umbraco.
/// </summary>
public interface IUserGroup : IEntity, IRememberBeingDirty
{
/// <summary>
/// Gets or sets the alias of the user group.
/// </summary>
string Alias { get; set; }
/// <summary>
/// Gets or sets the starting content node ID for this user group.
/// </summary>
int? StartContentId { get; set; }
/// <summary>
/// Gets or sets the starting media node ID for this user group.
/// </summary>
int? StartMediaId { get; set; }
/// <summary>
@@ -22,6 +34,18 @@ public interface IUserGroup : IEntity, IRememberBeingDirty
/// </summary>
string? Name { get; set; }
/// <summary>
/// Gets or sets the description of the user group.
/// </summary>
/// <remarks>
/// TODO (V18): Remove the default implementations.
/// </remarks>
string? Description
{
get => null;
set { }
}
/// <summary>
/// If this property is true it will give the group access to all languages
/// </summary>
@@ -40,26 +64,57 @@ public interface IUserGroup : IEntity, IRememberBeingDirty
/// </remarks>
ISet<string> Permissions { get; set; }
/// <summary>
/// Gets or sets the granular permissions for this user group.
/// </summary>
ISet<IGranularPermission> GranularPermissions { get; set; }
/// <summary>
/// Gets the collection of section aliases that this user group has access to.
/// </summary>
IEnumerable<string> AllowedSections { get; }
/// <summary>
/// Removes access to a section for this user group.
/// </summary>
/// <param name="sectionAlias">The alias of the section to remove.</param>
void RemoveAllowedSection(string sectionAlias);
/// <summary>
/// Adds access to a section for this user group.
/// </summary>
/// <param name="sectionAlias">The alias of the section to add.</param>
void AddAllowedSection(string sectionAlias);
/// <summary>
/// Removes access to all sections for this user group.
/// </summary>
void ClearAllowedSections();
/// <summary>
/// Gets the collection of language IDs that this user group has access to.
/// </summary>
IEnumerable<int> AllowedLanguages => Enumerable.Empty<int>();
/// <summary>
/// Removes access to a language for this user group.
/// </summary>
/// <param name="languageId">The ID of the language to remove.</param>
void RemoveAllowedLanguage(int languageId)
{
}
/// <summary>
/// Adds access to a language for this user group.
/// </summary>
/// <param name="languageId">The ID of the language to add.</param>
void AddAllowedLanguage(int languageId)
{
}
/// <summary>
/// Removes access to all languages for this user group.
/// </summary>
void ClearAllowedLanguages()
{
}
@@ -4,6 +4,7 @@ namespace Umbraco.Cms.Core.Models.Membership;
public class ReadOnlyUserGroup : IReadOnlyUserGroup, IEquatable<ReadOnlyUserGroup>
{
[Obsolete("Please use the constructor that includes all parameters. Scheduled for removal in Umbraco 19.")]
public ReadOnlyUserGroup(
int id,
Guid key,
@@ -17,11 +18,43 @@ public class ReadOnlyUserGroup : IReadOnlyUserGroup, IEquatable<ReadOnlyUserGrou
ISet<string> permissions,
ISet<IGranularPermission> granularPermissions,
bool hasAccessToAllLanguages)
: this(
id,
key,
name,
null,
icon,
startContentId,
startMediaId,
alias,
allowedLanguages,
allowedSections,
permissions,
granularPermissions,
hasAccessToAllLanguages)
{
}
public ReadOnlyUserGroup(
int id,
Guid key,
string? name,
string? description,
string? icon,
int? startContentId,
int? startMediaId,
string? alias,
IEnumerable<int> allowedLanguages,
IEnumerable<string> allowedSections,
ISet<string> permissions,
ISet<IGranularPermission> granularPermissions,
bool hasAccessToAllLanguages)
{
Name = name ?? string.Empty;
Icon = icon;
Id = id;
Key = key;
Name = name ?? string.Empty;
Description = description;
Icon = icon;
Alias = alias ?? string.Empty;
AllowedLanguages = allowedLanguages.ToArray();
AllowedSections = allowedSections.ToArray();
@@ -38,23 +71,10 @@ public class ReadOnlyUserGroup : IReadOnlyUserGroup, IEquatable<ReadOnlyUserGrou
public Guid Key { get; }
public bool Equals(ReadOnlyUserGroup? other)
{
if (ReferenceEquals(null, other))
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return string.Equals(Alias, other.Alias);
}
public string Name { get; }
public string? Description { get; }
public string? Icon { get; }
public int? StartContentId { get; }
@@ -77,7 +97,7 @@ public class ReadOnlyUserGroup : IReadOnlyUserGroup, IEquatable<ReadOnlyUserGrou
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj))
if (obj is null)
{
return false;
}
@@ -95,6 +115,21 @@ public class ReadOnlyUserGroup : IReadOnlyUserGroup, IEquatable<ReadOnlyUserGrou
return Equals((ReadOnlyUserGroup)obj);
}
public bool Equals(ReadOnlyUserGroup? other)
{
if (other is null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return string.Equals(Alias, other.Alias);
}
public override int GetHashCode() => Alias?.GetHashCode() ?? base.GetHashCode();
public static bool operator !=(ReadOnlyUserGroup left, ReadOnlyUserGroup right) => !Equals(left, right);
@@ -30,6 +30,7 @@ public class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup
private string _alias;
private string? _icon;
private string _name;
private string? _description;
private bool _hasAccessToAllLanguages;
private ISet<string> _permissions;
private ISet<IGranularPermission> _granularPermissions;
@@ -112,6 +113,13 @@ public class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup
set => SetPropertyValueAndDetectChanges(value, ref _name!, nameof(Name));
}
[DataMember]
public string? Description
{
get => _description;
set => SetPropertyValueAndDetectChanges(value, ref _description!, nameof(Description));
}
[DataMember]
public bool HasAccessToAllLanguages
{
@@ -18,6 +18,7 @@ public static class UserGroupExtensions
group.Id,
group.Key,
group.Name,
group.Description,
group.Icon,
group.StartContentId,
group.StartMediaId,
@@ -1,4 +1,4 @@
using System.Runtime.InteropServices;
using System.Collections.Concurrent;
namespace Umbraco.Cms.Core.Persistence.Repositories;
@@ -8,33 +8,25 @@ namespace Umbraco.Cms.Core.Persistence.Repositories;
public static class RepositoryCacheKeys
{
/// <summary>
/// A cache for the keys we don't keep allocating strings.
/// A thread-safe cache for the keys so we don't keep allocating strings.
/// </summary>
private static readonly Dictionary<Type, string> Keys = new();
private static readonly ConcurrentDictionary<Type, string> _keys = new();
/// <summary>
/// Gets the repository cache key for the provided type.
/// </summary>
/// <typeparam name="T">The entity type to get the cache key for.</typeparam>
/// <returns>A cache key string in the format "uRepo_{TypeName}_".</returns>
public static string GetKey<T>()
{
Type type = typeof(T);
// The following code is a micro-optimization to avoid an unnecessary lookup in the Keys dictionary, when writing the newly created key.
// Previously, the code was:
// return Keys.TryGetValue(type, out var key)
// ? key
// : Keys[type] = "uRepo_" + type.Name + "_";
// Look up the existing value or get a reference to the newly created default value.
ref string? key = ref CollectionsMarshal.GetValueRefOrAddDefault(Keys, type, out _);
// As we have the reference, we can just assign it if null, without the expensive write back to the dictionary.
return key ??= "uRepo_" + type.Name + "_";
}
=> _keys.GetOrAdd(typeof(T), static type => "uRepo_" + type.Name + "_");
/// <summary>
/// Gets the repository cache key for the provided type and Id.
/// </summary>
/// <typeparam name="T">The entity type to get the cache key for.</typeparam>
/// <typeparam name="TId">The type of the entity identifier.</typeparam>
/// <param name="id">The entity identifier.</param>
/// <returns>A cache key string in the format "uRepo_{TypeName}_{Id}", or an empty string if the id is the default value.</returns>
public static string GetKey<T, TId>(TId? id)
{
if (EqualityComparer<TId?>.Default.Equals(id, default))
@@ -9,8 +9,6 @@ namespace Umbraco.Cms.Core.PropertyEditors;
[DataContract]
public class ConfigurationField
{
private readonly string? _view;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationField" /> class.
/// </summary>
@@ -22,6 +20,7 @@ public class ConfigurationField
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationField" /> class.
/// </summary>
/// <param name="validators">The validators for this configuration field.</param>
public ConfigurationField(params IValueValidator[] validators)
: this(validators.ToList())
{
+23 -1
View File
@@ -20,7 +20,6 @@ namespace Umbraco.Cms.Core.Routing;
public class DefaultUrlProvider : IUrlProvider
{
private readonly ILocalizationService _localizationService;
private readonly ILocalizedTextService? _localizedTextService;
private readonly ILogger<DefaultUrlProvider> _logger;
private readonly ISiteDomainMapper _siteDomainMapper;
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
@@ -30,6 +29,18 @@ public class DefaultUrlProvider : IUrlProvider
private readonly UriUtility _uriUtility;
private RequestHandlerSettings _requestSettings;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultUrlProvider"/> class.
/// </summary>
/// <param name="requestSettings">The request handler settings.</param>
/// <param name="logger">The logger.</param>
/// <param name="siteDomainMapper">The site domain mapper.</param>
/// <param name="umbracoContextAccessor">The Umbraco context accessor.</param>
/// <param name="uriUtility">The URI utility.</param>
/// <param name="localizationService">The localization service.</param>
/// <param name="navigationQueryService">The document navigation query service.</param>
/// <param name="publishedContentStatusFilteringService">The published content status filtering service.</param>
/// <param name="publishedUrlProvider">The published URL provider.</param>
public DefaultUrlProvider(
IOptionsMonitor<RequestHandlerSettings> requestSettings,
ILogger<DefaultUrlProvider> logger,
@@ -54,6 +65,17 @@ public class DefaultUrlProvider : IUrlProvider
requestSettings.OnChange(x => _requestSettings = x);
}
/// <summary>
/// Initializes a new instance of the <see cref="DefaultUrlProvider"/> class.
/// </summary>
/// <param name="requestSettings">The request handler settings.</param>
/// <param name="logger">The logger.</param>
/// <param name="siteDomainMapper">The site domain mapper.</param>
/// <param name="umbracoContextAccessor">The Umbraco context accessor.</param>
/// <param name="uriUtility">The URI utility.</param>
/// <param name="localizationService">The localization service.</param>
/// <param name="navigationQueryService">The document navigation query service.</param>
/// <param name="publishedContentStatusFilteringService">The published content status filtering service.</param>
[Obsolete("Use the other constructor - Scheduled for removal in V18")]
public DefaultUrlProvider(
IOptionsMonitor<RequestHandlerSettings> requestSettings,
+33 -14
View File
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
@@ -7,7 +7,19 @@ using Umbraco.Cms.Core.Web;
namespace Umbraco.Cms.Core.Routing;
// TODO: Kill this, but we need to port all of it's functionality
/// <summary>
/// Represents a legacy published content request.
/// </summary>
/// <remarks>
/// <para>
/// This class is deprecated and scheduled for removal. Its functionality is being
/// migrated to the newer <see cref="IPublishedRequest"/> implementations.
/// </para>
/// <para>
/// TODO: Kill this, but we need to port all of its functionality.
/// </para>
/// </remarks>
[Obsolete("This class is deprecated and will be removed in a future version. Use IPublishedRequest instead. Scheduled for removal in Umbraco 19.")]
public class PublishedRequestOld // : IPublishedRequest
{
private readonly IPublishedRouter _publishedRouter;
@@ -15,13 +27,14 @@ public class PublishedRequestOld // : IPublishedRequest
private CultureInfo? _culture;
private DomainAndUri? _domain;
private bool _is404;
private IPublishedContent? _publishedContent;
private bool _readonly; // after prepared
/// <summary>
/// Initializes a new instance of the <see cref="PublishedRequest" /> class.
/// Initializes a new instance of the <see cref="PublishedRequestOld" /> class.
/// </summary>
/// <param name="publishedRouter">The published router.</param>
/// <param name="umbracoContext">The Umbraco context.</param>
/// <param name="webRoutingSettings">The web routing settings.</param>
/// <param name="uri">The optional URI for this request. If not provided, uses the cleaned Umbraco URL from the context.</param>
public PublishedRequestOld(IPublishedRouter publishedRouter, IUmbracoContext umbracoContext, IOptions<WebRoutingSettings> webRoutingSettings, Uri? uri = null)
{
UmbracoContext = umbracoContext ?? throw new ArgumentNullException(nameof(umbracoContext));
@@ -41,6 +54,9 @@ public class PublishedRequestOld // : IPublishedRequest
/// <remarks>The cleaned up Uri has no virtual directory, no trailing slash, no .aspx extension, etc.</remarks>
public Uri Uri { get; }
/// <summary>
/// Gets or sets a value indicating whether caching should be disabled for this request.
/// </summary>
public bool CacheabilityNoCache { get; set; }
///// <summary>
@@ -102,13 +118,15 @@ public class PublishedRequestOld // : IPublishedRequest
}
}
// utility for ensuring it is ok to set some properties
/// <summary>
/// Ensures the request is in a writeable state.
/// </summary>
/// <remarks>
/// This method is a no-op. The readonly check was removed because the backing field
/// was never set to true, making the check unnecessary. Retained for API compatibility.
/// </remarks>
public void EnsureWriteable()
{
if (_readonly)
{
throw new InvalidOperationException("Cannot modify a PublishedRequest once it is read-only.");
}
}
// #region Events
@@ -226,18 +244,19 @@ public class PublishedRequestOld // : IPublishedRequest
/// <summary>
/// Gets value indicating whether the current published content is the initial one.
/// </summary>
public bool IsInitialPublishedContent =>
InitialPublishedContent != null && InitialPublishedContent == _publishedContent;
/// <remarks>Always returns false as _publishedContent field was removed (never assigned).</remarks>
public bool IsInitialPublishedContent => false;
/// <summary>
/// Indicates that the current PublishedContent is the initial one.
/// </summary>
/// <remarks>Note: _publishedContent field was removed (never assigned), so this sets InitialPublishedContent to null.</remarks>
public void SetIsInitialPublishedContent()
{
EnsureWriteable();
// note: it can very well be null if the initial content was not found
InitialPublishedContent = _publishedContent;
InitialPublishedContent = null;
IsInternalRedirectPublishedContent = false;
}
@@ -52,13 +52,12 @@ internal abstract class ContentValidationServiceBase<TContentType>
// We don't have managed segments, so we have to make do with the ones passed in the model.
var segments =
new string?[] { null }
.Union(contentEditingModelBase.Variants
.Where(variant => variant.Culture is null || cultures.Contains(variant.Culture))
.DistinctBy(variant => variant.Segment).Select(variant => variant.Segment)
.WhereNotNull()
)
.ToArray();
new string?[] { null }
.Union(contentEditingModelBase.Variants
.Where(variant => variant.Culture is null || cultures.Contains(variant.Culture))
.DistinctBy(variant => variant.Segment).Select(variant => variant.Segment)
.WhereNotNull())
.ToArray();
foreach (IPropertyType propertyType in invariantPropertyTypes)
{
@@ -1,5 +1,8 @@
namespace Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Defines methods for resolving user keys to user IDs and vice versa without retrieving full user details.
/// </summary>
public interface IUserIdKeyResolver
{
/// <summary>
+2 -1
View File
@@ -548,7 +548,8 @@ namespace Umbraco.Cms.Core.Services
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
scope.ReadLock(Constants.Locks.MemberTree);
IQuery<IMember> query = Query<IMember>().Where(x => ids.Contains(x.Key));
List<Guid> idsAsList = [.. ids];
IQuery<IMember> query = Query<IMember>().Where(x => idsAsList.Contains(x.Key));
return Task.FromResult(_memberRepository.Get(query));
}
@@ -5,23 +5,28 @@ using Umbraco.Cms.Core.Notifications;
namespace Umbraco.Cms.Core.Services.Navigation;
/// <summary>
/// Responsible for seeding the in-memory publish status cache at application's startup
/// by loading all data from the database.
/// Responsible for seeding the in-memory publish status cache at application's startup
/// by loading all data from the database.
/// </summary>
public sealed class PublishStatusInitializationNotificationHandler : INotificationAsyncHandler<PostRuntimePremigrationsUpgradeNotification>
{
private readonly IRuntimeState _runtimeState;
private readonly IPublishStatusManagementService _publishStatusManagementService;
/// <summary>
/// Initializes a new instance of the <see cref="PublishStatusInitializationNotificationHandler"/> class.
/// </summary>
/// <param name="runtimeState">The runtime state.</param>
/// <param name="publishStatusManagementService">The publish status management service.</param>
public PublishStatusInitializationNotificationHandler(
IRuntimeState runtimeState,
IPublishStatusManagementService publishStatusManagementService
)
IPublishStatusManagementService publishStatusManagementService)
{
_runtimeState = runtimeState;
_publishStatusManagementService = publishStatusManagementService;
}
/// <inheritdoc />
public async Task HandleAsync(PostRuntimePremigrationsUpgradeNotification notification, CancellationToken cancellationToken)
{
if(_runtimeState.Level < RuntimeLevel.Upgrade)
+60 -6
View File
@@ -1,4 +1,4 @@
namespace Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Represents the Umbraco Service context, which provides access to all services.
@@ -34,8 +34,35 @@ public class ServiceContext
private readonly Lazy<IWebhookService>? _webhookService;
/// <summary>
/// Initializes a new instance of the <see cref="ServiceContext" /> class with lazy services.
/// Initializes a new instance of the <see cref="ServiceContext" /> class with lazy services.
/// </summary>
/// <param name="publicAccessService">The public access service.</param>
/// <param name="domainService">The domain service.</param>
/// <param name="auditService">The audit service.</param>
/// <param name="localizedTextService">The localized text service.</param>
/// <param name="tagService">The tag service.</param>
/// <param name="contentService">The content service.</param>
/// <param name="userService">The user service.</param>
/// <param name="memberService">The member service.</param>
/// <param name="mediaService">The media service.</param>
/// <param name="contentTypeService">The content type service.</param>
/// <param name="mediaTypeService">The media type service.</param>
/// <param name="dataTypeService">The data type service.</param>
/// <param name="fileService">The file service.</param>
/// <param name="localizationService">The localization service.</param>
/// <param name="packagingService">The packaging service.</param>
/// <param name="serverRegistrationService">The server registration service.</param>
/// <param name="entityService">The entity service.</param>
/// <param name="relationService">The relation service.</param>
/// <param name="memberTypeService">The member type service.</param>
/// <param name="memberGroupService">The member group service.</param>
/// <param name="notificationService">The notification service.</param>
/// <param name="externalLoginService">The external login service.</param>
/// <param name="redirectUrlService">The redirect URL service.</param>
/// <param name="consentService">The consent service.</param>
/// <param name="keyValueService">The key-value service.</param>
/// <param name="contentTypeBaseServiceProvider">The content type base service provider.</param>
/// <param name="webhookService">The webhook service.</param>
public ServiceContext(
Lazy<IPublicAccessService>? publicAccessService,
Lazy<IDomainService>? domainService,
@@ -230,10 +257,38 @@ public class ServiceContext
public IWebhookService? WebhookService => _webhookService?.Value;
/// <summary>
/// Creates a partial service context with only some services (for tests).
/// Creates a partial service context with only some services (for tests).
/// </summary>
/// <param name="contentService">The content service.</param>
/// <param name="mediaService">The media service.</param>
/// <param name="contentTypeService">The content type service.</param>
/// <param name="mediaTypeService">The media type service.</param>
/// <param name="dataTypeService">The data type service.</param>
/// <param name="fileService">The file service.</param>
/// <param name="localizationService">The localization service.</param>
/// <param name="packagingService">The packaging service.</param>
/// <param name="entityService">The entity service.</param>
/// <param name="relationService">The relation service.</param>
/// <param name="memberGroupService">The member group service.</param>
/// <param name="memberTypeService">The member type service.</param>
/// <param name="memberService">The member service.</param>
/// <param name="userService">The user service.</param>
/// <param name="tagService">The tag service.</param>
/// <param name="notificationService">The notification service.</param>
/// <param name="localizedTextService">The localized text service.</param>
/// <param name="auditService">The audit service.</param>
/// <param name="domainService">The domain service.</param>
/// <param name="publicAccessService">The public access service.</param>
/// <param name="externalLoginService">The external login service.</param>
/// <param name="serverRegistrationService">The server registration service.</param>
/// <param name="redirectUrlService">The redirect URL service.</param>
/// <param name="consentService">The consent service.</param>
/// <param name="keyValueService">The key-value service.</param>
/// <param name="contentTypeBaseServiceProvider">The content type base service provider.</param>
/// <param name="webhookService">The webhook service.</param>
/// <returns>A new <see cref="ServiceContext"/> instance with the specified services.</returns>
/// <remarks>
/// <para>Using a true constructor for this confuses DI containers.</para>
/// <para>Using a true constructor for this confuses DI containers.</para>
/// </remarks>
public static ServiceContext CreatePartial(
IContentService? contentService = null,
@@ -296,7 +351,6 @@ public class ServiceContext
Lazy(consentService),
Lazy(keyValueService),
Lazy(contentTypeBaseServiceProvider),
Lazy(webhookService)
);
Lazy(webhookService));
}
}
@@ -0,0 +1,14 @@
namespace Umbraco.Cms.Core.Strings;
/// <summary>
/// Defines a service that converts Markdown-formatted text to HTML.
/// </summary>
public interface IMarkdownToHtmlConverter
{
/// <summary>
/// Converts the specified Markdown-formatted text to an HTML-encoded string.
/// </summary>
/// <param name="markdown">The input string containing Markdown syntax to be converted.</param>
/// <returns>A string containing the HTML representation of the input Markdown.</returns>
public string ToHtml(string markdown);
}
@@ -7,11 +7,22 @@ using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Core.Webhooks.Events;
/// <summary>
/// Webhook event that fires when user group permissions are assigned.
/// </summary>
[WebhookEvent("User Group Permissions Assigned")]
public class AssignedUserGroupPermissionsWebhookEvent : WebhookEventBase<AssignedUserGroupPermissionsNotification>
{
private readonly IIdKeyMap _idKeyMap;
/// <summary>
/// Initializes a new instance of the <see cref="AssignedUserGroupPermissionsWebhookEvent"/> class.
/// </summary>
/// <param name="webhookFiringService">The webhook firing service.</param>
/// <param name="webHookService">The webhook service.</param>
/// <param name="webhookSettings">The webhook settings.</param>
/// <param name="serverRoleAccessor">The server role accessor.</param>
/// <param name="idKeyMap">The ID to key mapping service.</param>
public AssignedUserGroupPermissionsWebhookEvent(
IWebhookFiringService webhookFiringService,
IWebhookService webHookService,
@@ -23,11 +34,14 @@ public class AssignedUserGroupPermissionsWebhookEvent : WebhookEventBase<Assigne
_idKeyMap = idKeyMap;
}
/// <inheritdoc />
public override string Alias => Constants.WebhookEvents.Aliases.AssignedUserGroupPermissions;
/// <inheritdoc />
public override object ConvertNotificationToRequestPayload(AssignedUserGroupPermissionsNotification notification)
=> notification.EntityPermissions.Select(permission =>
new {
new
{
UserId = _idKeyMap.GetKeyForId(permission.EntityId, UmbracoObjectTypes.Unknown).Result,
UserGroupId = _idKeyMap.GetKeyForId(permission.UserGroupId, UmbracoObjectTypes.Unknown).Result,
});
@@ -46,7 +46,8 @@ public static class WebhookEventCollectionBuilderCmsUserExtensions
/// The builder.
/// </returns>
public static WebhookEventCollectionBuilderCmsUser AddLogin(this WebhookEventCollectionBuilderCmsUser builder, WebhookPayloadType payloadType = WebhookPayloadType.Legacy)
{switch (payloadType)
{
switch (payloadType)
{
case WebhookPayloadType.Extended:
case WebhookPayloadType.Minimal:
@@ -25,10 +25,20 @@ namespace Umbraco.Cms.Core.Cache;
internal sealed class FullDataSetRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyBase<TEntity, TId>
where TEntity : class, IEntity
{
private static readonly TId[] EmptyIds = new TId[0]; // const
private static readonly TId[] _emptyIds = []; // const
private readonly Func<TEntity, TId> _entityGetId;
private readonly bool _expires;
private readonly Lock _getAllLock = new();
/// <summary>
/// Initializes a new instance of the <see cref="FullDataSetRepositoryCachePolicy{TEntity, TId}"/> class.
/// </summary>
/// <param name="cache">The cache to use for storing entities.</param>
/// <param name="scopeAccessor">The scope accessor for accessing the current scope.</param>
/// <param name="repositoryCacheVersionService">The service for managing cache version synchronization.</param>
/// <param name="cacheSyncService">The service for synchronizing cache changes across servers.</param>
/// <param name="entityGetId">A function to extract the identifier from an entity.</param>
/// <param name="expires">Whether cached items should expire after a timeout.</param>
public FullDataSetRepositoryCachePolicy(IAppPolicyCache cache, IScopeAccessor scopeAccessor, IRepositoryCacheVersionService repositoryCacheVersionService, ICacheSyncService cacheSyncService, Func<TEntity, TId> entityGetId, bool expires)
: base(cache, scopeAccessor, repositoryCacheVersionService, cacheSyncService)
{
@@ -39,10 +49,7 @@ internal sealed class FullDataSetRepositoryCachePolicy<TEntity, TId> : Repositor
/// <inheritdoc />
public override void Create(TEntity entity, Action<TEntity> persistNew)
{
if (entity == null)
{
throw new ArgumentNullException(nameof(entity));
}
ArgumentNullException.ThrowIfNull(entity);
try
{
@@ -54,7 +61,7 @@ internal sealed class FullDataSetRepositoryCachePolicy<TEntity, TId> : Repositor
}
}
private string GetEntityTypeCacheKey() => RepositoryCacheKeys.GetKey<TEntity>();
private static string GetEntityTypeCacheKey() => RepositoryCacheKeys.GetKey<TEntity>();
private void InsertEntities(TEntity[]? entities)
{
@@ -88,10 +95,7 @@ internal sealed class FullDataSetRepositoryCachePolicy<TEntity, TId> : Repositor
/// <inheritdoc />
public override void Update(TEntity entity, Action<TEntity> persistUpdated)
{
if (entity == null)
{
throw new ArgumentNullException(nameof(entity));
}
ArgumentNullException.ThrowIfNull(entity);
try
{
@@ -110,10 +114,7 @@ internal sealed class FullDataSetRepositoryCachePolicy<TEntity, TId> : Repositor
/// <inheritdoc />
public override void Delete(TEntity entity, Action<TEntity> persistDeleted)
{
if (entity == null)
{
throw new ArgumentNullException(nameof(entity));
}
ArgumentNullException.ThrowIfNull(entity);
try
{
@@ -190,19 +191,37 @@ internal sealed class FullDataSetRepositoryCachePolicy<TEntity, TId> : Repositor
/// <inheritdoc />
public override void ClearAll() => Cache.Clear(GetEntityTypeCacheKey());
// does NOT clone anything, so be nice with the returned values
/// <summary>
/// Gets all cached entities, or retrieves them from the repository if not cached.
/// </summary>
/// <remarks>
/// Uses double-check locking to prevent the "thundering herd" problem where multiple
/// threads detecting a cache miss would all query the database simultaneously.
/// Does NOT clone anything, so be nice with the returned values.
/// </remarks>
internal IEnumerable<TEntity> GetAllCached(Func<TId[], IEnumerable<TEntity>?> performGetAll)
{
// try the cache first
// Fast path - check cache without lock.
DeepCloneableList<TEntity>? all = Cache.GetCacheItem<DeepCloneableList<TEntity>>(GetEntityTypeCacheKey());
if (all != null)
{
return all.ToArray();
}
// else get from repo and cache
TEntity[]? entities = performGetAll(EmptyIds)?.WhereNotNull().ToArray();
InsertEntities(entities); // may be an empty array...
return entities ?? Enumerable.Empty<TEntity>();
// Slow path - lock to prevent thundering herd on cache miss.
lock (_getAllLock)
{
// Double-check inside lock - another thread may have populated the cache.
all = Cache.GetCacheItem<DeepCloneableList<TEntity>>(GetEntityTypeCacheKey());
if (all != null)
{
return all.ToArray();
}
// Only one thread queries the database.
TEntity[]? entities = performGetAll(_emptyIds)?.WhereNotNull().ToArray();
InsertEntities(entities); // may be an empty array...
return entities ?? Enumerable.Empty<TEntity>();
}
}
}
@@ -174,7 +174,7 @@ public static partial class UmbracoBuilderExtensions
.Remove<SimpleRichTextValueConverter>();
// register *all* checks, except those marked [HideFromTypeFinder] of course
builder.Services.AddSingleton<IMarkdownToHtmlConverter, MarkdownToHtmlConverter>();
builder.Services.AddSingleton<Core.HealthChecks.NotificationMethods.IMarkdownToHtmlConverter, MarkdownToHtmlConverter>();
builder.Services.AddSingleton<IContentLastChanceFinder, ContentFinderByConfigured404>();
@@ -23,6 +23,7 @@ using Umbraco.Cms.Infrastructure.PublishedCache;
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Cms.Infrastructure.Services;
using Umbraco.Cms.Infrastructure.Services.Implement;
using Umbraco.Cms.Infrastructure.Strings;
using Umbraco.Cms.Infrastructure.Telemetry.Providers;
using Umbraco.Cms.Infrastructure.Templates.PartialViews;
using Umbraco.Extensions;
@@ -87,6 +88,11 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddUnique<IMediaSearchService, MediaSearchService>();
builder.Services.AddUnique<IDistributedJobService, DistributedJobService>();
#pragma warning disable CS0618 // Type or member is obsolete
// TODO (V18): Replace this with MarkdigMarkdownToHtmlConverter as the default implementation.
builder.Services.AddUnique<IMarkdownToHtmlConverter, HeyRedMarkdownToHtmlConverter>();
#pragma warning restore CS0618 // Type or member is obsolete
return builder;
}
@@ -1,4 +1,5 @@
using HeyRed.MarkdownSharp;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.HealthChecks;
using Umbraco.Cms.Core.HealthChecks.NotificationMethods;
@@ -6,10 +7,19 @@ namespace Umbraco.Cms.Infrastructure.HealthChecks;
public class MarkdownToHtmlConverter : IMarkdownToHtmlConverter
{
private readonly Core.Strings.IMarkdownToHtmlConverter _markdownToHtmlConverter;
public MarkdownToHtmlConverter(Core.Strings.IMarkdownToHtmlConverter markdownToHtmlConverter) => _markdownToHtmlConverter = markdownToHtmlConverter;
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
public MarkdownToHtmlConverter()
: this(StaticServiceProvider.Instance.GetRequiredService<Core.Strings.IMarkdownToHtmlConverter>())
{
}
public string ToHtml(HealthCheckResults results, HealthCheckNotificationVerbosity verbosity)
{
var mark = new Markdown();
var html = mark.Transform(results.ResultsAsMarkDown(verbosity));
var html = _markdownToHtmlConverter.ToHtml(results.ResultsAsMarkDown(verbosity));
html = ApplyHtmlHighlighting(html);
return html;
}
@@ -1312,6 +1312,7 @@ internal sealed class DatabaseDataCreator
StartContentId = -1,
Alias = Constants.Security.AdminGroupAlias,
Name = "Administrators",
Description = "Users with full access to all sections and functionality",
CreateDate = DateTime.UtcNow,
UpdateDate = DateTime.UtcNow,
Icon = "icon-medal",
@@ -1329,6 +1330,7 @@ internal sealed class DatabaseDataCreator
StartContentId = -1,
Alias = WriterGroupAlias,
Name = "Writers",
Description = "Users with permission to create and update but not publish content",
CreateDate = DateTime.UtcNow,
UpdateDate = DateTime.UtcNow,
Icon = "icon-edit",
@@ -1346,6 +1348,7 @@ internal sealed class DatabaseDataCreator
StartContentId = -1,
Alias = EditorGroupAlias,
Name = "Editors",
Description = "Users with full permission to create, update and publish content",
CreateDate = DateTime.UtcNow,
UpdateDate = DateTime.UtcNow,
Icon = "icon-tools",
@@ -1363,6 +1366,7 @@ internal sealed class DatabaseDataCreator
StartContentId = -1,
Alias = TranslatorGroupAlias,
Name = "Translators",
Description = "Users with permission to manage dictionary entries",
CreateDate = DateTime.UtcNow,
UpdateDate = DateTime.UtcNow,
Icon = "icon-globe",
@@ -1378,6 +1382,7 @@ internal sealed class DatabaseDataCreator
Key = Constants.Security.SensitiveDataGroupKey,
Alias = SensitiveDataGroupAlias,
Name = "Sensitive data",
Description = "Users with the specific permission to be able to manage properties and data marked as sensitive",
CreateDate = DateTime.UtcNow,
UpdateDate = DateTime.UtcNow,
Icon = "icon-lock",
@@ -147,6 +147,9 @@ public class UmbracoPlan : MigrationPlan
// To 17.1.0
To<V_17_1_0.ChangeValidationRegExpToNvarcharMax>("{1CE2E78B-E736-45D8-97A2-CE3EF2F31BCD}");
// To 17.2.0
To<V_17_2_0.AddDescriptionToUserGroup>("{F1A2B3C4-D5E6-4789-ABCD-1234567890AB}");
// To 18.0.0
// TODO (V18): Enable on 18 branch
//// To<V_18_0_0.MigrateSingleBlockList>("{74332C49-B279-4945-8943-F8F00B1F5949}");
@@ -1,4 +1,4 @@
using System.Net;
using System.Net;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence;
@@ -52,7 +52,8 @@ public class ChangeLogStatusCode : MigrationBase
[PrimaryKeyColumn(AutoIncrement = true)]
public int Id { get; set; }
[Column("webhookKey")] public Guid WebhookKey { get; set; }
[Column("webhookKey")]
public Guid WebhookKey { get; set; }
[Column(Name = "key")]
[NullSetting(NullSetting = NullSettings.NotNull)]
@@ -1,4 +1,4 @@
using NPoco;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_14_0_0;
using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations;
@@ -201,14 +201,17 @@ internal class AddGuidsToUsers : UnscopedMigrationBase
[Constraint(Default = "0")]
public bool NoConsole { get; set; }
[Column("userName")] public string UserName { get; set; } = null!;
[Column("userName")]
public string UserName { get; set; } = null!;
[Column("userLogin")]
[Length(125)]
[Index(IndexTypes.NonClustered)]
public string? Login { get; set; }
[Column("userPassword")] [Length(500)] public string? Password { get; set; }
[Column("userPassword")]
[Length(500)]
public string? Password { get; set; }
/// <summary>
/// This will represent a JSON structure of how the password has been created (i.e hash algorithm, iterations)
@@ -218,7 +221,8 @@ internal class AddGuidsToUsers : UnscopedMigrationBase
[Length(500)]
public string? PasswordConfig { get; set; }
[Column("userEmail")] public string Email { get; set; } = null!;
[Column("userEmail")]
public string Email { get; set; } = null!;
[Column("userLanguage")]
[NullSetting(NullSetting = NullSettings.Null)]
@@ -320,14 +324,17 @@ internal class AddGuidsToUsers : UnscopedMigrationBase
[Constraint(Default = "0")]
public bool NoConsole { get; set; }
[Column("userName")] public string UserName { get; set; } = null!;
[Column("userName")]
public string UserName { get; set; } = null!;
[Column("userLogin")]
[Length(125)]
[Index(IndexTypes.NonClustered)]
public string? Login { get; set; }
[Column("userPassword")] [Length(500)] public string? Password { get; set; }
[Column("userPassword")]
[Length(500)]
public string? Password { get; set; }
/// <summary>
/// This will represent a JSON structure of how the password has been created (i.e hash algorithm, iterations)
@@ -337,7 +344,8 @@ internal class AddGuidsToUsers : UnscopedMigrationBase
[Length(500)]
public string? PasswordConfig { get; set; }
[Column("userEmail")] public string Email { get; set; } = null!;
[Column("userEmail")]
public string Email { get; set; } = null!;
[Column("userLanguage")]
[NullSetting(NullSetting = NullSettings.Null)]
@@ -178,14 +178,17 @@ internal class MigrateTours : UnscopedMigrationBase
[Constraint(Default = "0")]
public bool NoConsole { get; set; }
[Column("userName")] public string UserName { get; set; } = null!;
[Column("userName")]
public string UserName { get; set; } = null!;
[Column("userLogin")]
[Length(125)]
[Index(IndexTypes.NonClustered)]
public string? Login { get; set; }
[Column("userPassword")] [Length(500)] public string? Password { get; set; }
[Column("userPassword")]
[Length(500)]
public string? Password { get; set; }
/// <summary>
/// This will represent a JSON structure of how the password has been created (i.e hash algorithm, iterations)
@@ -195,7 +198,8 @@ internal class MigrateTours : UnscopedMigrationBase
[Length(500)]
public string? PasswordConfig { get; set; }
[Column("userEmail")] public string Email { get; set; } = null!;
[Column("userEmail")]
public string Email { get; set; } = null!;
[Column("userLanguage")]
[NullSetting(NullSetting = NullSettings.Null)]
@@ -0,0 +1,43 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_2_0
{
/// <summary>
/// Migration to add a description column to the user group table.
/// </summary>
public class AddDescriptionToUserGroup : AsyncMigrationBase
{
/// <summary>
/// Initializes a new instance of the <see cref="AddDescriptionToUserGroup"/> class.
/// </summary>
/// <param name="context">The migration context.</param>
public AddDescriptionToUserGroup(
IMigrationContext context)
: base(context)
{
}
/// <inheritdoc/>
protected override async Task MigrateAsync()
{
if (TableExists(Constants.DatabaseSchema.Tables.UserGroup) is false)
{
return;
}
const string ColumnName = "description";
var hasColumn = Context.SqlContext.SqlSyntax.GetColumnsInSchema(Context.Database)
.Any(c =>
c.TableName == Constants.DatabaseSchema.Tables.UserGroup &&
c.ColumnName == ColumnName);
if (hasColumn)
{
return;
}
AddColumn<UserGroupDto>(Constants.DatabaseSchema.Tables.UserGroup, ColumnName);
}
}
}
@@ -40,6 +40,11 @@ public class UserGroupDto
[Index(IndexTypes.UniqueNonClustered, Name = "IX_umbracoUserGroup_userGroupName")]
public string? Name { get; set; }
[Column(Name = "description")]
[SpecialDbType(SpecialDbTypes.NVARCHARMAX)]
[NullSetting(NullSetting = NullSettings.Null)]
public string? Description { get; set; }
[Column("userGroupDefaultPermissions")]
[Length(50)]
[NullSetting(NullSetting = NullSettings.Null)]
@@ -30,6 +30,7 @@ internal static class UserGroupFactory
userGroup.StartMediaId = dto.StartMediaId;
userGroup.Permissions = dto.UserGroup2PermissionDtos.Select(x => x.Permission).ToHashSet();
userGroup.HasAccessToAllLanguages = dto.HasAccessToAllLanguages;
userGroup.Description = dto.Description;
if (dto.UserGroup2AppDtos != null)
{
foreach (UserGroup2AppDto app in dto.UserGroup2AppDtos)
@@ -84,6 +85,7 @@ internal static class UserGroupFactory
Key = entity.Key,
Alias = entity.Alias,
Name = entity.Name,
Description = entity.Description,
UserGroup2AppDtos = new List<UserGroup2AppDto>(),
CreateDate = entity.CreateDate,
UpdateDate = entity.UpdateDate,
@@ -25,5 +25,6 @@ public sealed class UserGroupMapper : BaseMapper
DefineMap<UserGroup, UserGroupDto>(nameof(UserGroup.Icon), nameof(UserGroupDto.Icon));
DefineMap<UserGroup, UserGroupDto>(nameof(UserGroup.StartContentId), nameof(UserGroupDto.StartContentId));
DefineMap<UserGroup, UserGroupDto>(nameof(UserGroup.StartMediaId), nameof(UserGroupDto.StartMediaId));
DefineMap<UserGroup, UserGroupDto>(nameof(UserGroup.Description), nameof(UserGroupDto.Description));
}
}
@@ -27,8 +27,13 @@ public abstract class EntityRepositoryBase<TId, TEntity> : RepositoryBase, IRead
private IQuery<TEntity>? _hasIdQuery;
/// <summary>
/// Initializes a new instance of the <see cref="EntityRepositoryBase{TId, TEntity}" /> class.
/// Initializes a new instance of the <see cref="EntityRepositoryBase{TId, TEntity}" /> class.
/// </summary>
/// <param name="scopeAccessor">The scope accessor.</param>
/// <param name="appCaches">The application caches.</param>
/// <param name="logger">The logger.</param>
/// <param name="repositoryCacheVersionService">The repository cache version service.</param>
/// <param name="cacheSyncService">The cache synchronization service.</param>
protected EntityRepositoryBase(
IScopeAccessor scopeAccessor,
AppCaches appCaches,
@@ -228,8 +233,7 @@ public abstract class EntityRepositoryBase<TId, TEntity> : RepositoryBase, IRead
ScopeAccessor,
DefaultOptions,
RepositoryCacheVersionService,
CacheSyncService
);
CacheSyncService);
protected abstract TEntity? PerformGet(TId? id);
@@ -432,7 +432,8 @@ internal sealed class RelationRepository : EntityRepositoryBase<int, IRelation>,
protected override IEnumerable<string> GetDeleteClauses()
{
var list = new List<string> {
var list = new List<string>
{
$"DELETE FROM {QuoteTableName(Constants.DatabaseSchema.Tables.Relation)} WHERE id = @id"
};
return list;
@@ -447,25 +447,35 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
private sealed class UnionHelperDto
{
[Column("id")] public int Id { get; set; }
[Column("id")]
public int Id { get; set; }
[Column("otherId")] public int OtherId { get; set; }
[Column("otherId")]
public int OtherId { get; set; }
[Column("key")] public Guid Key { get; set; }
[Column("key")]
public Guid Key { get; set; }
[Column("trashed")] public bool Trashed { get; set; }
[Column("trashed")]
public bool Trashed { get; set; }
[Column("nodeObjectType")] public Guid NodeObjectType { get; set; }
[Column("nodeObjectType")]
public Guid NodeObjectType { get; set; }
[Column("otherKey")] public Guid OtherKey { get; set; }
[Column("otherKey")]
public Guid OtherKey { get; set; }
[Column("alias")] public string? Alias { get; set; }
[Column("alias")]
public string? Alias { get; set; }
[Column("name")] public string? Name { get; set; }
[Column("name")]
public string? Name { get; set; }
[Column("isDependency")] public bool IsDependency { get; set; }
[Column("isDependency")]
public bool IsDependency { get; set; }
[Column("dual")] public bool Dual { get; set; }
[Column("dual")]
public bool Dual { get; set; }
}
private RelationItem MapDtoToEntity(RelationItemDto dto) =>
@@ -481,6 +481,7 @@ public class UserGroupRepository : EntityRepositoryBase<int, IUserGroup>, IUserG
x => x.UpdateDate,
x => x.Alias,
x => x.Name,
x => x.Description,
x => x.HasAccessToAllLanguages,
x => x.Key,
x => x.DefaultPermissions)
@@ -133,7 +133,19 @@ public class ImageCropperPropertyEditor : DataEditor,
public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
/// <inheritdoc/>
public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
public void Handle(MediaDeletedNotification notification)
{
if (_contentSettings.EnableMediaRecycleBinProtection)
{
RecycleBinMediaProtectionHelper.DeleteContainedFilesWithProtection(
notification.DeletedEntities,
ContainedFilePaths,
_mediaFileManager);
return;
}
DeleteContainedFiles(notification.DeletedEntities);
}
/// <inheritdoc/>
public void Handle(MediaSavingNotification notification)
@@ -3,8 +3,6 @@
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;
@@ -348,18 +346,21 @@ public class MultiNodeTreePickerPropertyEditor : DataEditor
Guid[] allowedTypes = configuration?.Filter?.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries).Select(Guid.Parse).ToArray() ?? [];
// We can't validate if there is no object type, and we don't need to if there's no filter.
if (entityReferences is null || allowedTypes.Length == 0 || configuration?.TreeSource?.ObjectType is null)
// Don't need to validate if there's no filter.
if (entityReferences is null || allowedTypes.Length == 0)
{
return validationResults;
}
// If no object type is specified, it's considered as document.
var objectType = configuration?.TreeSource?.ObjectType ?? DocumentObjectType;
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
Guid?[] uniqueContentTypeKeys = entityReferences
.Select(x => x.Unique)
.Distinct()
.Select(x => GetContent(configuration.TreeSource.ObjectType, x))
.Select(x => GetContent(objectType, x))
.Select(x => x?.ContentType.Key)
.Distinct()
.ToArray();
@@ -56,7 +56,19 @@ internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadNo
public void Handle(ContentDeletedBlueprintNotification notification) => DeleteContainedFiles(notification.DeletedBlueprints);
/// <inheritdoc/>
public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
public void Handle(MediaDeletedNotification notification)
{
if (_contentSettings.EnableMediaRecycleBinProtection)
{
RecycleBinMediaProtectionHelper.DeleteContainedFilesWithProtection(
notification.DeletedEntities,
ContainedFilePaths,
MediaFileManager);
return;
}
DeleteContainedFiles(notification.DeletedEntities);
}
/// <inheritdoc/>
public void Handle(MediaMovedToRecycleBinNotification notification)
@@ -115,7 +127,7 @@ internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadNo
private void RemoveSuffixFromContainedFiles(IEnumerable<IMedia> restoredMedia)
{
IEnumerable<string> filePathsToRename = ContainedFilePaths(restoredMedia);
MediaFileManager.RemoveSuffixFromMediaFiles(filePathsToRename, Constants.Conventions.Media.TrashedMediaSuffix);
RecycleBinMediaProtectionHelper.RemoveSuffixFromContainedFiles(filePathsToRename, MediaFileManager);
}
/// <summary>
@@ -1,5 +1,6 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
@@ -27,4 +28,36 @@ internal static class RecycleBinMediaProtectionHelper
.Select(x => Path.ChangeExtension(x, Constants.Conventions.Media.TrashedMediaSuffix + Path.GetExtension(x)));
mediaFileManager.RemoveSuffixFromMediaFiles(filePathsToRename, Constants.Conventions.Media.TrashedMediaSuffix);
}
/// <summary>
/// Deletes all media files, accounting for recycle bin protection (trashed files have .deleted suffix on disk).
/// </summary>
/// <param name="deletedMedia">Deleted media entities.</param>
/// <param name="containedFilePaths">Function to extract file paths from media entities.</param>
/// <param name="mediaFileManager">The media file manager.</param>
public static void DeleteContainedFilesWithProtection(
IEnumerable<IMedia> deletedMedia,
Func<IEnumerable<IMedia>, IEnumerable<string>> containedFilePaths,
MediaFileManager mediaFileManager)
{
// Typically all deleted media will have Trashed == true since they come from the recycle bin.
// However, media can be force-deleted programmatically bypassing the recycle bin, in which
// case the files won't have the .deleted suffix on disk. We handle both cases here.
var trashedMedia = deletedMedia.Where(m => m.Trashed).ToList();
var nonTrashedMedia = deletedMedia.Where(m => !m.Trashed).ToList();
// Delete trashed media files (with .deleted suffix on disk).
if (trashedMedia.Count > 0)
{
IEnumerable<string> trashedPaths = containedFilePaths(trashedMedia)
.Select(x => Path.ChangeExtension(x, Constants.Conventions.Media.TrashedMediaSuffix + Path.GetExtension(x)));
mediaFileManager.DeleteMediaFiles(trashedPaths);
}
// Delete non-trashed media files (original paths).
if (nonTrashedMedia.Count > 0)
{
mediaFileManager.DeleteMediaFiles(containedFilePaths(nonTrashedMedia));
}
}
}
@@ -1,7 +1,8 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using HeyRed.MarkdownSharp;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors.DeliveryApi;
using Umbraco.Cms.Core.Strings;
@@ -15,11 +16,22 @@ public class MarkdownEditorValueConverter : PropertyValueConverterBase, IDeliver
{
private readonly HtmlLocalLinkParser _localLinkParser;
private readonly HtmlUrlParser _urlParser;
private readonly IMarkdownToHtmlConverter _markdownToHtmlConverter;
public MarkdownEditorValueConverter(HtmlLocalLinkParser localLinkParser, HtmlUrlParser urlParser)
public MarkdownEditorValueConverter(HtmlLocalLinkParser localLinkParser, HtmlUrlParser urlParser, IMarkdownToHtmlConverter markdownToHtmlConverter)
{
_localLinkParser = localLinkParser;
_urlParser = urlParser;
_markdownToHtmlConverter = markdownToHtmlConverter;
}
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
public MarkdownEditorValueConverter(HtmlLocalLinkParser localLinkParser, HtmlUrlParser urlParser)
: this(
localLinkParser,
urlParser,
StaticServiceProvider.Instance.GetRequiredService<IMarkdownToHtmlConverter>())
{
}
public override bool IsConverter(IPublishedPropertyType propertyType)
@@ -49,10 +61,15 @@ public class MarkdownEditorValueConverter : PropertyValueConverterBase, IDeliver
public override object ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview)
{
// convert markup to HTML for frontend rendering.
// source should come from ConvertSource and be a string (or null) already
var mark = new Markdown();
return new HtmlEncodedString(inter == null ? string.Empty : mark.Transform((string)inter));
// Convert markup to HTML for frontend rendering.
// Source should come from ConvertSource and be a string (or null) already.
if (inter is null)
{
return new HtmlEncodedString(string.Empty);
}
var htmlString = _markdownToHtmlConverter.ToHtml((string)inter);
return new HtmlEncodedString(htmlString);
}
public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Element;
@@ -15,12 +15,24 @@ using MapperCollection = Umbraco.Cms.Infrastructure.Persistence.Mappers.MapperCo
namespace Umbraco.Cms.Infrastructure.Runtime;
/// <summary>
/// Implements <see cref="IMainDomLock"/> using SQL database for distributed lock coordination.
/// </summary>
/// <remarks>
/// <para>
/// This implementation uses a database row in the KeyValue table to coordinate MainDom
/// acquisition across multiple application instances in a load-balanced environment.
/// </para>
/// <para>
/// The lock is acquired by inserting or updating a row with a unique identifier. Other
/// instances poll the database to detect when the current MainDom releases the lock.
/// </para>
/// </remarks>
public class SqlMainDomLock : IMainDomLock
{
private const string MainDomKeyPrefix = "Umbraco.Core.Runtime.SqlMainDom";
private const string UpdatedSuffix = "_updated";
private readonly CancellationTokenSource _cancellationTokenSource = new();
private readonly IUmbracoDatabase? _db;
private readonly UmbracoDatabaseFactory _dbFactory;
private readonly IOptions<GlobalSettings> _globalSettings;
private readonly Lock _locker = new();
@@ -31,6 +43,16 @@ public class SqlMainDomLock : IMainDomLock
private bool _hasTable;
private bool _mainDomChanging;
/// <summary>
/// Initializes a new instance of the <see cref="SqlMainDomLock"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="globalSettings">The global settings.</param>
/// <param name="connectionStrings">The connection strings configuration.</param>
/// <param name="dbProviderFactoryCreator">The database provider factory creator.</param>
/// <param name="mainDomKeyGenerator">The MainDom key generator.</param>
/// <param name="databaseSchemaCreatorFactory">The database schema creator factory.</param>
/// <param name="npocoMappers">The NPoco mapper collection.</param>
public SqlMainDomLock(
ILoggerFactory loggerFactory,
IOptions<GlobalSettings> globalSettings,
@@ -70,6 +92,7 @@ public class SqlMainDomLock : IMainDomLock
/// </remarks>
private string MainDomKey { get; }
/// <inheritdoc />
public async Task<bool> AcquireLockAsync(int millisecondsTimeout)
{
if (!_dbFactory.Configured)
@@ -133,6 +156,7 @@ public class SqlMainDomLock : IMainDomLock
return await WaitForExistingAsync(tempId, millisecondsTimeout);
}
/// <inheritdoc />
public Task ListenAsync()
{
if (_errorDuringAcquiring)
@@ -396,7 +420,13 @@ public class SqlMainDomLock : IMainDomLock
private bool _disposedValue; // To detect redundant calls
/// <summary>
/// Releases unmanaged and - optionally - managed resources.
/// </summary>
/// <param name="disposing">
/// <c>true</c> to release both managed and unmanaged resources;
/// <c>false</c> to release only unmanaged resources.
/// </param>
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
@@ -464,7 +494,7 @@ public class SqlMainDomLock : IMainDomLock
}
}
// This code added to correctly implement the disposable pattern.
/// <inheritdoc />
public void Dispose() =>
// Do not change this code. Put cleanup code in Dispose(bool disposing) above.
Dispose(true);
@@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Services;
@@ -9,8 +9,13 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Services.Implement;
// It's okay that we never clear this, since you can never change a user's key/id
// and it'll be caught by the services if it doesn't exist.
/// <summary>
/// Implements <see cref="IUserIdKeyResolver"/> for resolving user keys to user IDs and vice versa without retrieving full user details.
/// </summary>
/// <remarks>
/// It's okay that we never clear this, since you can never change a user's key/id
/// and it'll be caught by the services if it doesn't exist.
/// </remarks>
internal sealed class UserIdKeyResolver : IUserIdKeyResolver
{
private readonly IScopeProvider _scopeProvider;
@@ -19,6 +24,9 @@ internal sealed class UserIdKeyResolver : IUserIdKeyResolver
private readonly SemaphoreSlim _keytToIdLock = new(1, 1);
private readonly SemaphoreSlim _idToKeyLock = new(1, 1);
/// <summary>
/// Initializes a new instance of the <see cref="UserIdKeyResolver"/> class.
/// </summary>
public UserIdKeyResolver(IScopeProvider scopeProvider) => _scopeProvider = scopeProvider;
/// <inheritdoc/>
@@ -28,6 +36,14 @@ internal sealed class UserIdKeyResolver : IUserIdKeyResolver
/// <inheritdoc/>
public async Task<Attempt<int>> TryGetAsync(Guid key)
{
// The super-user Id and key is known, so we don't need a look-up here.
if (key == Constants.Security.SuperUserKey)
{
#pragma warning disable CS0618 // Type or member is obsolete
return Attempt.Succeed(Constants.Security.SuperUserId);
#pragma warning restore CS0618 // Type or member is obsolete
}
if (_keyToId.TryGetValue(key, out int id))
{
return Attempt.Succeed(id);
@@ -76,6 +92,14 @@ internal sealed class UserIdKeyResolver : IUserIdKeyResolver
/// <inheritdoc/>
public async Task<Attempt<Guid>> TryGetAsync(int id)
{
// The super-user Id and key is known, so we don't need a look-up here.
#pragma warning disable CS0618 // Type or member is obsolete
if (id is Constants.Security.SuperUserId)
#pragma warning restore CS0618 // Type or member is obsolete
{
return Attempt.Succeed(Constants.Security.SuperUserKey);
}
if (_idToKey.TryGetValue(id, out Guid key))
{
return Attempt.Succeed(key);
@@ -0,0 +1,18 @@
using HeyRed.MarkdownSharp;
using Umbraco.Cms.Core.Strings;
namespace Umbraco.Cms.Infrastructure.Strings;
// TODO (V19): Remove this class along with the HeyRed.MarkdownSharp library entirely (remove reference from Directory.props and .csproj, and remove from NOTICES.txt).
/// <summary>
/// Implements a service that converts Markdown-formatted text to HTML using the HeyRed.MarkdownSharp library.
/// </summary>
[Obsolete("Uses the deprecated HeyRed.MarkdownSharp library which will continue to be provided for the lifetime of Umbraco 17 as the default implementation of IMarkdownToHtmlConverter. The default will be changed to MarkdigMarkdownToHtmlConverter for Umbraco 18. Scheduled for removal along with the HeyRed.MarkdownSharp library in Umbraco 19.")]
public class HeyRedMarkdownToHtmlConverter : IMarkdownToHtmlConverter
{
private static readonly Markdown _markdownConverter = new();
/// <inheritdoc/>
public string ToHtml(string markdown) => _markdownConverter.Transform(markdown);
}
@@ -0,0 +1,15 @@
using Markdig;
using Umbraco.Cms.Core.Strings;
namespace Umbraco.Cms.Infrastructure.Strings;
/// <summary>
/// Implements a service that converts Markdown-formatted text to HTML using the Markdig library.
/// </summary>
public class MarkdigMarkdownToHtmlConverter : IMarkdownToHtmlConverter
{
private static readonly MarkdownPipeline _markdownPipeline = new MarkdownPipelineBuilder().Build();
/// <inheritdoc/>
public string ToHtml(string markdown) => Markdown.ToHtml(markdown, _markdownPipeline);
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>Umbraco.Cms.Infrastructure</PackageId>
<Title>Umbraco CMS - Infrastructure</Title>
@@ -39,6 +39,7 @@
<PackageReference Include="Examine.Core" />
<PackageReference Include="HtmlAgilityPack" />
<PackageReference Include="MailKit" />
<PackageReference Include="Markdig" />
<PackageReference Include="Markdown" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
@@ -1,4 +1,4 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
@@ -21,10 +21,9 @@ internal sealed class CacheNodeFactory : ICacheNodeFactory
ContentData contentData = GetContentData(
content,
GetPublishedValue(content, preview),
GetTemplateId(content, preview),
content.PublishCultureInfos!.Values.Select(x=>x.Culture).ToHashSet()
);
GetPublishedValue(content, preview),
GetTemplateId(content, preview),
content.PublishCultureInfos!.Values.Select(x => x.Culture).ToHashSet());
return new ContentCacheNode
{
Id = content.Id,
@@ -1,5 +1,6 @@
using System.Net;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
@@ -56,7 +57,7 @@ public sealed class ModelBindingExceptionAttribute : TypeFilterAttribute
&& (filterContext.Exception is ModelBindingException || filterContext.Exception is InvalidCastException)
&& IsMessageAboutTheSameModelType(filterContext.Exception.Message))
{
filterContext.HttpContext.Response.Headers.Add(HttpResponseHeader.RetryAfter.ToString(), "1");
filterContext.HttpContext.Response.Headers.Append(HttpResponseHeader.RetryAfter.ToString(), "1");
filterContext.Result = new RedirectResult(filterContext.HttpContext.Request.GetEncodedUrl(), false);
filterContext.ExceptionHandled = true;
@@ -1,4 +1,5 @@
using System.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Umbraco.Extensions;
@@ -28,7 +29,7 @@ public class UmbracoUserTimeoutFilterAttribute : TypeFilterAttribute
}
var remainingSeconds = context.HttpContext.User.GetRemainingAuthSeconds();
context.HttpContext.Response.Headers.Add(
context.HttpContext.Response.Headers.Append(
"X-Umb-User-Seconds",
remainingSeconds.ToString(CultureInfo.InvariantCulture));
}
@@ -12,14 +12,13 @@
[SA1117] params all on same line
[SA1401] make fields private
[SA1134] own line attributes
[ASP0019] use IHeaderDictionary.Append or the indexer to append or set headers
[CS0618]/[SYSLIB0051] adjust obsolete references
[IDE0040]/[SA1400] access modifiers
[SA1405] Debug assert message text
[CS0419]/[CS1574] cref ambiguities
[SA1649] file name match type
-->
<WarningsNotAsErrors>$(WarningsNotAsErrors),SA1117,SA1401,SA1134,ASP0019,CS0618,SYSLIB0051,IDE0040,SA1400,SA1405,CS0419,CS1574,SA1649</WarningsNotAsErrors>
<WarningsNotAsErrors>$(WarningsNotAsErrors),SA1117,SA1401,SA1134,CS0618,SYSLIB0051,IDE0040,SA1400,SA1405,CS0419,CS1574,SA1649</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
+10 -15
View File
@@ -1,4 +1,4 @@
const { rest } = window.MockServiceWorker;
const { http, HttpResponse } = window.MockServiceWorker;
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
import {
@@ -12,14 +12,10 @@ test.describe('installer tests', () => {
test.beforeEach(async ({ page, worker }) => {
await worker.use(
// Override the server status to be "must-install"
rest.get(umbracoPath('/server/status'), (_req, res, ctx) => {
return res(
// Respond with a 200 status code
ctx.status(200),
ctx.json<ServerStatusResponseModel>({
serverStatus: RuntimeLevelModel.INSTALL,
}),
);
http.get(umbracoPath('/server/status'), () => {
return HttpResponse.json<ServerStatusResponseModel>({
serverStatus: RuntimeLevelModel.INSTALL,
});
}),
);
@@ -62,18 +58,17 @@ test.describe('installer tests', () => {
test('installer fails', async ({ page, worker }) => {
await worker.use(
// Override the server status to be "must-install"
rest.post(umbracoPath('/install/setup'), (_req, res, ctx) => {
return res(
// Respond with a 200 status code
ctx.status(400),
ctx.json<ProblemDetails>({
http.post(umbracoPath('/install/setup'), () => {
return HttpResponse.json<ProblemDetails>(
{
status: 400,
type: 'validation',
detail: 'Something went wrong',
errors: {
databaseName: ['The database name is required'],
},
}),
},
{ status: 400 },
);
}),
);
+11 -16
View File
@@ -1,24 +1,20 @@
const { rest } = window.MockServiceWorker;
const { http, HttpResponse } = window.MockServiceWorker;
import { expect, test } from './test.js';
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
import {
type ProblemDetails,
RuntimeLevelModel,
type ServerStatusResponseModel,
} from '@umbraco-cms/backoffice/external/backend-api';
import { expect, test } from './test.js';
test.describe('upgrader tests', () => {
test.beforeEach(async ({ page, worker }) => {
await worker.use(
// Override the server status to be "must-install"
rest.get(umbracoPath('/server/status'), (_req, res, ctx) => {
return res(
// Respond with a 200 status code
ctx.status(200),
ctx.json<ServerStatusResponseModel>({
serverStatus: RuntimeLevelModel.UPGRADE,
}),
);
http.get(umbracoPath('/server/status'), () => {
return HttpResponse.json<ServerStatusResponseModel>({
serverStatus: RuntimeLevelModel.UPGRADE,
});
}),
);
@@ -43,15 +39,14 @@ test.describe('upgrader tests', () => {
test('upgrader fails and shows error', async ({ page, worker }) => {
await worker.use(
// Override the server status to be "must-install"
rest.post(umbracoPath('/upgrade/authorize'), (_req, res, ctx) => {
return res(
// Respond with a 200 status code
ctx.status(400),
ctx.json<ProblemDetails>({
http.post(umbracoPath('/upgrade/authorize'), () => {
return HttpResponse.json<ProblemDetails>(
{
status: 400,
type: 'error',
detail: 'Something went wrong',
}),
},
{ status: 400 },
);
}),
);
+3 -3
View File
@@ -13736,9 +13736,9 @@
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -319,6 +319,7 @@ export class UmbAppElement extends UmbLitElement {
static override styles = css`
:host {
overflow: hidden;
min-width: 920px;
}
:host,
@@ -238,6 +238,7 @@ export default {
},
content: {
isPublished: 'Is Published',
unpublishedChanges: 'Unpublished changes',
about: 'About this page',
alias: 'Alias',
alternativeTextHelp: '(how would you describe the picture over the phone)',
@@ -275,6 +276,7 @@ export default {
getUrlException: 'Could not get the URL',
routeError: 'This document is published but its URL would collide with content %0%',
routeErrorCannotRoute: 'This document is published but its URL cannot be routed',
protected: 'Protected',
publish: 'Publish',
published: 'Published',
publishedPendingChanges: 'Published (pending changes)',
@@ -287,6 +289,7 @@ export default {
releaseDate: 'Publish at',
unpublishDate: 'Unpublish at',
removeDate: 'Clear date',
scheduledPublishing: 'Scheduled publishing',
setDate: 'Set date',
sortDone: 'Sort order is updated',
sortHelp:
@@ -174,11 +174,13 @@ export default {
parentNotPublishedAnomaly: 'Ups: este documento está publicado pero no está en la caché (error interno)',
getUrlException: 'No se pudo obtener la URL',
routeError: 'Este documento está publicado pero tu URL colisionará con contenido %0%',
protected: 'Protegido',
publish: 'Publicar',
publishStatus: 'Estado de la Publicación',
releaseDate: 'Publicar el',
unpublishDate: 'Retirar publicación el',
removeDate: 'Fecha de Eliminación',
scheduledPublishing: 'Publicación programada',
sortDone: 'El Orden esta actualizado',
sortHelp:
'Para organizar los nodos, simplemente arrastra los nodos o realice un clic en uno de los encabezados de columna. Puedes seleccionar múltiple nodos manteniendo presionados "Shift" o "Control" mientras seleccionas',
@@ -186,6 +188,7 @@ export default {
titleOptional: 'Título (opcional)',
altTextOptional: 'Texto alternativo (opcional)',
type: 'Tipo',
unpublishedChanges: 'Cambios no publicados',
unpublish: 'Ocultar',
updateDate: 'Última actualización',
updateDateDesc: 'Fecha/hora este documento fue modificado',
@@ -27,6 +27,7 @@ export const data: Array<UmbMockUserGroupModel> = [
id: 'user-group-administrators-id',
name: 'Administrators',
alias: 'admin',
description: 'Administrators have full access to all settings and features within the CMS.',
icon: 'icon-medal',
fallbackPermissions: [
UMB_USER_PERMISSION_DOCUMENT_READ,
@@ -91,6 +92,8 @@ export const data: Array<UmbMockUserGroupModel> = [
id: 'user-group-editors-id',
name: 'Editors',
alias: 'editors',
description:
'The Editors group is responsible for creating, updating, and managing Content and Media within the platform. While they do not have access to system-level areas such as Settings, Users, or Packages, they play a crucial role in maintaining the websites daily content operations. Editors start from the Media root node when handling media files, ensuring they can upload, modify, and organize assets relevant to their work.',
icon: 'icon-tools',
documentStartNode: { id: 'all-property-editors-document-id' },
fallbackPermissions: [
@@ -2,9 +2,10 @@ const { http, HttpResponse } = window.MockServiceWorker;
import { umbDataTypeMockDb } from '../../data/data-type/data-type.db.js';
import { UMB_SLUG } from './slug.js';
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
import type { CreateFolderRequestModel, UpdateFolderResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
export const folderHandlers = [
http.post(umbracoPath(`${UMB_SLUG}/folder`), async ({ request }) => {
http.post<object, CreateFolderRequestModel>(umbracoPath(`${UMB_SLUG}/folder`), async ({ request }) => {
const requestBody = await request.json();
if (!requestBody) return new HttpResponse(null, { status: 400, statusText: 'no body found' });
@@ -26,14 +27,17 @@ export const folderHandlers = [
return HttpResponse.json(response);
}),
http.put(umbracoPath(`${UMB_SLUG}/folder/:id`), async ({ request, params }) => {
const id = params.id as string;
if (!id) return new HttpResponse(null, { status: 400, statusText: 'no id found' });
const requestBody = await request.json();
if (!requestBody) return new HttpResponse(null, { status: 400, statusText: 'no body found' });
umbDataTypeMockDb.folder.update(id, requestBody);
return new HttpResponse(null, { status: 200 });
}),
http.put<{ id: string }, UpdateFolderResponseModel>(
umbracoPath(`${UMB_SLUG}/folder/:id`),
async ({ request, params }) => {
const id = params.id;
if (!id) return new HttpResponse(null, { status: 400, statusText: 'no id found' });
const requestBody = await request.json();
if (!requestBody) return new HttpResponse(null, { status: 400, statusText: 'no body found' });
umbDataTypeMockDb.folder.update(id, requestBody);
return new HttpResponse(null, { status: 200 });
},
),
http.delete(umbracoPath(`${UMB_SLUG}/folder/:id`), ({ params }) => {
const id = params.id as string;
@@ -2,9 +2,10 @@ const { http, HttpResponse } = window.MockServiceWorker;
import { umbDocumentTypeMockDb } from '../../data/document-type/document-type.db.js';
import { UMB_SLUG } from './slug.js';
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
import type { CreateFolderRequestModel, UpdateFolderResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
export const folderHandlers = [
http.post(umbracoPath(`${UMB_SLUG}/folder`), async ({ request }) => {
http.post<object, CreateFolderRequestModel>(umbracoPath(`${UMB_SLUG}/folder`), async ({ request }) => {
const requestBody = await request.json();
if (!requestBody) return new HttpResponse(null, { status: 400 });
@@ -26,14 +27,17 @@ export const folderHandlers = [
return HttpResponse.json(response);
}),
http.put(umbracoPath(`${UMB_SLUG}/folder/:id`), async ({ request, params }) => {
const id = params.id as string;
if (!id) return new HttpResponse(null, { status: 400 });
const requestBody = await request.json();
if (!requestBody) return new HttpResponse(null, { status: 400 });
umbDocumentTypeMockDb.folder.update(id, requestBody);
return new HttpResponse(null, { status: 200 });
}),
http.put<{ id: string }, UpdateFolderResponseModel>(
umbracoPath(`${UMB_SLUG}/folder/:id`),
async ({ request, params }) => {
const id = params.id;
if (!id) return new HttpResponse(null, { status: 400 });
const requestBody = await request.json();
if (!requestBody) return new HttpResponse(null, { status: 400 });
umbDocumentTypeMockDb.folder.update(id, requestBody);
return new HttpResponse(null, { status: 200 });
},
),
http.delete(umbracoPath(`${UMB_SLUG}/folder/:id`), ({ params }) => {
const id = params.id as string;
@@ -1,11 +1,10 @@
import { umbDocumentMockDb } from '../data/document/document.db.js';
import type { DynamicRootRequestModel } from '@umbraco-cms/backoffice/external/backend-api';
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
const { http, HttpResponse } = window.MockServiceWorker;
export const handlers = [
http.post<DynamicRootRequestModel>(umbracoPath('/dynamic-root/query'), async () => {
http.post(umbracoPath('/dynamic-root/query'), async () => {
const response = umbDocumentMockDb.tree
.getRoot()
.items.map((item) => item.id)
@@ -48,8 +48,8 @@ export const handlers = [
}
}),
http.post<HealthCheckActionRequestModel>(umbracoPath('/health-check/execute-action'), async ({ request }) => {
const body = await request.json<HealthCheckActionRequestModel>();
http.post<object, HealthCheckActionRequestModel>(umbracoPath('/health-check/execute-action'), async ({ request }) => {
const body = await request.json();
const healthCheckId = body.healthCheck.id;
// Find the health check based on the healthCheckId from the healthGroups[].checks
const healthCheck = healthGroups.flatMap((group) => group.checks).find((check) => check?.id === healthCheckId);
@@ -77,8 +77,8 @@ export const handlers = [
});
}),
http.post(umbracoPath('/install/validate-database'), async ({ request }) => {
const body = await request.json<DatabaseInstallRequestModel>();
http.post<object, DatabaseInstallRequestModel>(umbracoPath('/install/validate-database'), async ({ request }) => {
const body = await request.json();
if (body.name === 'validate') {
return HttpResponse.json<ProblemDetails>(
@@ -94,8 +94,8 @@ export const handlers = [
return new HttpResponse(null, { status: 201 });
}),
http.post(umbracoPath('/install/setup'), async ({ request }) => {
const body = await request.json<InstallRequestModel>();
http.post<object, InstallRequestModel>(umbracoPath('/install/setup'), async ({ request }) => {
const body = await request.json();
if (body.database?.name === 'fail') {
// Note: ctx.delay() is not directly supported in v2, needs to be implemented differently if delay is needed
@@ -2,9 +2,10 @@ const { http, HttpResponse } = window.MockServiceWorker;
import { umbMediaTypeMockDb } from '../../data/media-type/media-type.db.js';
import { UMB_SLUG } from './slug.js';
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
import type { CreateFolderRequestModel, UpdateFolderResponseModel } from '@umbraco-cms/backoffice/external/backend-api';
export const folderHandlers = [
http.post(umbracoPath(`${UMB_SLUG}/folder`), async ({ request }) => {
http.post<object, CreateFolderRequestModel>(umbracoPath(`${UMB_SLUG}/folder`), async ({ request }) => {
const requestBody = await request.json();
if (!requestBody) return new HttpResponse(null, { status: 400 });
@@ -26,14 +27,17 @@ export const folderHandlers = [
return HttpResponse.json(response);
}),
http.put(umbracoPath(`${UMB_SLUG}/folder/:id`), async ({ request, params }) => {
const id = params.id as string;
if (!id) return new HttpResponse(null, { status: 400 });
const requestBody = await request.json();
if (!requestBody) return new HttpResponse(null, { status: 400 });
umbMediaTypeMockDb.folder.update(id, requestBody);
return new HttpResponse(null, { status: 200 });
}),
http.put<{ id: string }, UpdateFolderResponseModel>(
umbracoPath(`${UMB_SLUG}/folder/:id`),
async ({ request, params }) => {
const id = params.id;
if (!id) return new HttpResponse(null, { status: 400 });
const requestBody = await request.json();
if (!requestBody) return new HttpResponse(null, { status: 400 });
umbMediaTypeMockDb.folder.update(id, requestBody);
return new HttpResponse(null, { status: 200 });
},
),
http.delete(umbracoPath(`${UMB_SLUG}/folder/:id`), ({ params }) => {
const id = params.id as string;
@@ -51,9 +51,9 @@ export const handlers = [
});
}),
http.post(umbracoPath('/package/created'), async ({ request }) => {
http.post<object, CreatePackageRequestModel>(umbracoPath('/package/created'), async ({ request }) => {
//save
const data: CreatePackageRequestModel = await request.json();
const data = await request.json();
const newPackage: PackageDefinitionResponseModel = { ...data, id: UmbId.new(), packagePath: '' };
packageArray.push(newPackage);
return HttpResponse.json<PackageDefinitionResponseModel>(newPackage);
@@ -68,9 +68,9 @@ export const handlers = [
return HttpResponse.json<PackageDefinitionResponseModel>(found);
}),
http.put(umbracoPath('/package/created/:id'), async ({ request }) => {
http.put<object, PackageDefinitionResponseModel>(umbracoPath('/package/created/:id'), async ({ request }) => {
//update
const data: PackageDefinitionResponseModel = await request.json();
const data = await request.json();
if (!data.id) return;
const index = packageArray.findIndex((x) => x.id === data.id);
packageArray[index] = data;
@@ -24,8 +24,9 @@ export const handlers = [
});
}),
http.post<TelemetryResponseModel>(umbracoPath('/telemetry/level'), async ({ request }) => {
const newLevel = (await request.json<TelemetryResponseModel>()).telemetryLevel;
http.post<object, TelemetryResponseModel>(umbracoPath('/telemetry/level'), async ({ request }) => {
const body = await request.json();
const newLevel = body?.telemetryLevel;
if (newLevel) {
telemetryLevel = newLevel;
return new HttpResponse(null, { status: 200 });
@@ -4,13 +4,16 @@ import type { ChangePasswordUserRequestModel } from '@umbraco-cms/backoffice/ext
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
export const handlers = [
http.post<ChangePasswordUserRequestModel>(umbracoPath(`${UMB_SLUG}/change-password/:id`), async ({ request }) => {
const data = await request.json();
if (!data) return;
if (!data.newPassword) return;
http.post<object, ChangePasswordUserRequestModel>(
umbracoPath(`${UMB_SLUG}/change-password/:id`),
async ({ request }) => {
const data = await request.json();
if (!data) return;
if (!data.newPassword) return;
/* we don't have to update any mock data when a password is changed
/* we don't have to update any mock data when a password is changed
so we just return a 200 */
return new HttpResponse(null, { status: 200 });
}),
return new HttpResponse(null, { status: 200 });
},
),
];
@@ -9,7 +9,7 @@ export const handlers = [
const loggedInUser = umbUserMockDb.getCurrentUser();
return HttpResponse.json(loggedInUser);
}),
http.get<GetUserCurrentLoginProvidersResponse>(umbracoPath(`${UMB_SLUG}/current/login-providers`), () => {
http.get(umbracoPath(`${UMB_SLUG}/current/login-providers`), () => {
return HttpResponse.json<GetUserCurrentLoginProvidersResponse>([
{
hasManualLinkingEnabled: true,
@@ -24,12 +24,13 @@ export const handlers = [
return HttpResponse.json(mfaLoginProviders);
}),
http.get(umbracoPath(`${UMB_SLUG}/current/2fa/:providerName`), ({ params }) => {
if (!params.providerName) {
const providerName = params.providerName as string | undefined;
if (!providerName) {
return new HttpResponse(null, { status: 400 });
}
const mfaProviders = umbUserMockDb.getMfaLoginProviders();
const mfaProvider = mfaProviders.find((p) => p.providerName === params.providerName.toString());
const mfaProvider = mfaProviders.find((p) => p.providerName === providerName);
if (!mfaProvider) {
return new HttpResponse(null, { status: 404 });
@@ -41,11 +42,12 @@ export const handlers = [
secret: '8b713fc7-8f17-4f5d-b2ac-b53879c75953',
});
}),
http.post<{ code: string; secret: string }>(
http.post<{ providerName: string }, { code: string; secret: string }>(
umbracoPath(`${UMB_SLUG}/current/2fa/:providerName`),
async ({ request, params }) => {
const providerName = params.providerName;
const body = await request.json();
if (!params.providerName || !body.code || !body.secret) {
if (!providerName || !body?.code || !body?.secret) {
return new HttpResponse(null, { status: 400 });
}
@@ -53,14 +55,15 @@ export const handlers = [
return new HttpResponse(null, { status: 400 });
}
const result = umbUserMockDb.enableMfaProvider(params.providerName.toString());
const result = umbUserMockDb.enableMfaProvider(providerName);
return new HttpResponse(null, { status: result ? 200 : 404 });
},
),
http.delete<{ code: string }>(umbracoPath(`${UMB_SLUG}/current/2fa/:providerName`), ({ request, params }) => {
http.delete(umbracoPath(`${UMB_SLUG}/current/2fa/:providerName`), ({ request, params }) => {
const providerName = params.providerName as string | undefined;
const url = new URL(request.url);
const code = url.searchParams.get('code');
if (!params.providerName || !code) {
if (!providerName || !code) {
return new HttpResponse(null, { status: 400 });
}
@@ -68,7 +71,7 @@ export const handlers = [
return new HttpResponse(null, { status: 400 });
}
const result = umbUserMockDb.disableMfaProvider(params.providerName.toString());
const result = umbUserMockDb.disableMfaProvider(providerName);
return new HttpResponse(null, { status: result ? 200 : 404 });
}),
];
@@ -5,12 +5,13 @@ import type { DisableUserRequestModel } from '@umbraco-cms/backoffice/external/b
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
export const handlers = [
http.post<DisableUserRequestModel>(umbracoPath(`${UMB_SLUG}/disable`), async ({ request }) => {
http.post<object, DisableUserRequestModel>(umbracoPath(`${UMB_SLUG}/disable`), async ({ request }) => {
const data = await request.json();
if (!data) return;
if (!data.userIds) return;
umbUserMockDb.disable(data.userIds);
const ids = data.userIds.map((ref) => ref.id);
umbUserMockDb.disable(ids);
return new HttpResponse(null, { status: 200 });
}),
@@ -5,12 +5,13 @@ import type { EnableUserRequestModel } from '@umbraco-cms/backoffice/external/ba
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
export const handlers = [
http.post<EnableUserRequestModel>(umbracoPath(`${UMB_SLUG}/enable`), async ({ request }) => {
http.post<object, EnableUserRequestModel>(umbracoPath(`${UMB_SLUG}/enable`), async ({ request }) => {
const data = await request.json();
if (!data) return;
if (!data.userIds) return;
umbUserMockDb.enable(data.userIds);
const ids = data.userIds.map((ref) => ref.id);
umbUserMockDb.enable(ids);
return new HttpResponse(null, { status: 200 });
}),
@@ -7,7 +7,7 @@ import { umbracoPath } from '@umbraco-cms/backoffice/utils';
const inviteSlug = `${UMB_SLUG}/invite`;
export const handlers = [
http.post<InviteUserRequestModel>(umbracoPath(`${inviteSlug}`), async ({ request }) => {
http.post<object, InviteUserRequestModel>(umbracoPath(`${inviteSlug}`), async ({ request }) => {
const data = await request.json();
if (!data) return;
@@ -24,7 +24,7 @@ export const handlers = [
});
}),
http.post<any>(umbracoPath(`${inviteSlug}/resend`), async ({ request }) => {
http.post(umbracoPath(`${inviteSlug}/resend`), async ({ request }) => {
const data = await request.json();
if (!data) return;
@@ -1,15 +1,19 @@
const { http, HttpResponse } = window.MockServiceWorker;
import { umbUserMockDb } from '../../data/user/user.db.js';
import { UMB_SLUG } from './slug.js';
import type { UpdateUserGroupsOnUserRequestModel } from '@umbraco-cms/backoffice/external/backend-api';
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
export const handlers = [
http.post(umbracoPath(`${UMB_SLUG}/set-user-groups`), async ({ request }) => {
const data = await request.json();
if (!data) return;
http.post<object, UpdateUserGroupsOnUserRequestModel>(
umbracoPath(`${UMB_SLUG}/set-user-groups`),
async ({ request }) => {
const data = await request.json();
if (!data) return;
umbUserMockDb.setUserGroups(data);
umbUserMockDb.setUserGroups(data);
return new HttpResponse(null, { status: 200 });
}),
return new HttpResponse(null, { status: 200 });
},
),
];
@@ -5,12 +5,13 @@ import type { UnlockUsersRequestModel } from '@umbraco-cms/backoffice/external/b
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
export const handlers = [
http.post<UnlockUsersRequestModel>(umbracoPath(`${UMB_SLUG}/unlock`), async ({ request }) => {
http.post<object, UnlockUsersRequestModel>(umbracoPath(`${UMB_SLUG}/unlock`), async ({ request }) => {
const data = await request.json();
if (!data) return;
if (!data.userIds) return;
umbUserMockDb.unlock(data.userIds);
const ids = data.userIds.map((ref) => ref.id);
umbUserMockDb.unlock(ids);
return new HttpResponse(null, { status: 200 });
}),
@@ -613,6 +613,10 @@ export class UmbBlockGridEntriesContext
this.#invalidBlockTypeLimits = areaType.specifiedAllowance
.map((rule) => {
if (!this._manager) {
// There is no manager at this point so we cannot validate, this is properly in a very early or a deconstruction phase.
return undefined;
}
const minAllowed = rule.minAllowed || 0;
const maxAllowed = rule.maxAllowed || 0;
@@ -620,7 +624,7 @@ export class UmbBlockGridEntriesContext
if (rule.groupKey) {
const groupElementTypeKeys =
this._manager
?.getBlockTypes()
.getBlockTypes()
.filter((blockType) => blockType.groupKey === rule.groupKey && blockType.allowInAreas === true)
.map((x) => x.contentElementTypeKey) ?? [];
const groupAmount = layoutEntries.filter((entry) => {
@@ -631,7 +635,7 @@ export class UmbBlockGridEntriesContext
if (groupAmount < minAllowed || (maxAllowed > 0 && groupAmount > maxAllowed)) {
return {
groupKey: rule.groupKey,
name: this._manager!.getBlockGroupName(rule.groupKey) ?? '?',
name: this._manager.getBlockGroupName(rule.groupKey) ?? '?',
amount: groupAmount,
minRequirement: minAllowed,
maxRequirement: maxAllowed,
@@ -648,7 +652,7 @@ export class UmbBlockGridEntriesContext
if (amount < minAllowed || (maxAllowed > 0 ? amount > maxAllowed : false)) {
return {
key: rule.elementTypeKey,
name: this._manager!.getContentTypeNameOf(rule.elementTypeKey) ?? '?',
name: this._manager.getContentTypeNameOf(rule.elementTypeKey) ?? '?',
amount: amount,
minRequirement: minAllowed,
maxRequirement: maxAllowed,
@@ -203,7 +203,7 @@ export class UmbPropertyEditorUIBlockGridAreaTypePermissionElement
(group) =>
html`<uui-combobox-list-option .value=${group.key} ?selected=${area.groupKey === group.key}>
<umb-icon name="icon-folder"></umb-icon>
${group.name}
${this.localize.string(group.name)}
</uui-combobox-list-option>`,
);
}
@@ -217,7 +217,7 @@ export class UmbPropertyEditorUIBlockGridAreaTypePermissionElement
.value=${block.type.contentElementTypeKey}
?selected=${area.elementTypeKey === block.type.contentElementTypeKey}>
<umb-icon name=${block.icon}></umb-icon>
${block.name}
${this.localize.string(block.name)}
</uui-combobox-list-option>`,
);
}
@@ -122,7 +122,7 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement<
const noGroupBlocks = blocks.filter((block) => !blockGroups.find((group) => group.key === block.groupKey));
const grouped = blockGroups.map((group) => ({
name: group.name,
name: this.localize.string(group.name),
blocks: blocks.filter((block) => block.groupKey === group.key),
}));
@@ -20,7 +20,6 @@ import {
type UmbElementPropertyDataOwner,
} from '@umbraco-cms/backoffice/content';
import { UmbReadOnlyVariantGuardManager } from '@umbraco-cms/backoffice/utils';
import { UmbDataTypeItemRepositoryManager } from '@umbraco-cms/backoffice/data-type';
import { UmbVariantPropertyGuardManager } from '@umbraco-cms/backoffice/property';
import { UmbHintContext, type UmbVariantHint } from '@umbraco-cms/backoffice/hint';
@@ -1,5 +1,4 @@
import type { UmbBlockElementManager } from './block-element-manager.js';
import { UMB_BLOCK_WORKSPACE_CONTEXT } from './block-workspace.context-token.js';
import type { UmbPropertyDatasetContext } from '@umbraco-cms/backoffice/property';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbVariantId } from '@umbraco-cms/backoffice/variant';
@@ -24,15 +23,5 @@ export class UmbBlockElementPropertyDatasetContext
this.getName = elementManager.getName;
this.culture = createObservablePart(elementManager.variantId, (v) => v?.culture);
this.segment = createObservablePart(elementManager.variantId, (v) => v?.segment);
this.consumeContext(UMB_BLOCK_WORKSPACE_CONTEXT, (workspace) => {
this.observe(
workspace?.readOnlyGuard.isPermittedForVariant(elementManager.getVariantId()),
(isReadOnly) => {
this._readOnly.setValue(isReadOnly ?? false);
},
'umbObserveReadOnlyStates',
);
});
}
}
@@ -42,6 +42,14 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
@state()
private _usedForComposition: Array<string> = [];
/**
* Loading state for the composition data fetch.
* Initialized to true to show loader immediately when modal opens.
* Set to false when data is successfully loaded or an error occurs.
*/
@state()
private _loading = true;
override connectedCallback() {
super.connectedCallback();
@@ -113,7 +121,10 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
currentPropertyAliases: currentPropertyAliases,
});
if (!data) return;
if (!data) {
this._loading = false;
return;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
@@ -121,6 +132,8 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
this._compatibleCompositions = Object.keys(grouped)
.sort((a, b) => a.localeCompare(b))
.map((key) => ({ path: key, compositions: grouped[key] }));
this._loading = false;
}
#onSelectionAdd(unique: string) {
@@ -183,6 +196,12 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
}
#renderAvailableCompositions() {
if (this._loading) {
return html`<div id="loader" role="status" aria-live="polite" aria-label="Loading compositions">
<uui-loader></uui-loader>
</div>`;
}
if (this._compatibleCompositions) {
return html`
<umb-localize key="contentTypeEditor_compositionsDescription">
@@ -242,6 +261,14 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
static override styles = [
css`
#loader {
display: flex;
justify-content: center;
align-items: center;
padding: var(--uui-size-layout-1);
min-height: 200px;
}
uui-input {
margin: var(--uui-size-6) 0;
display: flex;

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