Compare commits

...
Author SHA1 Message Date
Nhu DinhandGitHub 9505011d71 Build: Updated nightly E2E test pipeline schedule in v16 (#22805)
Updated nightly E2E test pipeline schedule
2026-05-12 15:36:39 +07:00
Nhu DinhandGitHub 2579aaf2db Build: Cherry pick #22164 for V16 (#22171)
Serialize E2E stages and stagger branch schedules to reduce agent usage
2026-03-19 21:19:45 +07:00
Andy Butland 2b7784a226 Merge branch 'release/16.5.1' into v16/dev 2026-03-10 06:36:50 +01:00
8555a97b39 Merge commit from fork
* Add authorization checks for domain operations.

* Remove duplicate 403 ProducesResponseType attributes.

BackOfficeSecurityRequirementsOperationFilterBase already adds 403
responses for endpoints whose controllers inject IAuthorizationService.

---------

Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>
2026-03-10 05:11:16 +01:00
Andy ButlandandGitHub c2dd685a4b Merge commit from fork 2026-03-10 05:10:31 +01:00
Andy ButlandandGitHub 66fc819379 Merge commit from fork
* Protect endpoint that sets user groups for a user collection to prevent elevation of permissions for users.

* Update tests from code review feedback.
2026-03-10 05:07:41 +01:00
Andy Butland 4f1f7e15c4 Bump version to 16.5.1. 2026-02-23 16:38:23 +01:00
Andy Butland a826c52e2e Merge branch 'release/16.5' into v16/dev and bumped version to 16.6.0-rc 2026-01-22 06:45:06 +01:00
Andy Butland 8b2c22aaf1 Merge branch 'release/16.5' of https://github.com/umbraco/Umbraco-CMS into release/16.5 2026-01-21 17:40:17 +01:00
Andy Butland aecfee4469 Bump version to 16.5.0. 2026-01-21 17:39:59 +01:00
Niels LyngsøandGitHub 9c785a9c5b Varying Compositions in Invariant Document Types, Cherrypick of #21267 (#21472)
* cherry picked a5a6d0645f

* correct to getVariesBySegment
2026-01-21 15:27:26 +00:00
Nhu DinhandGitHub 2fe10387ee E2E: V16 QA Update acceptance tests to use refactored UI helpers (#21271)
* Bumped version of test helper

* Bumped version

* Bumped version
2026-01-05 17:25:23 +07:00
8642b9e615 Content Types: Fix property variation change when content exists only in non-default language (#21182)
* Content Types: Fix property variation change when content exists only in non-default language (closes #11771)

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

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

* Add tests for AllowEditInvariantFromNonDefault enabled scenario

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 15:21:17 +01:00
Andreas ZerbstandGitHub 2a604c8719 E2E: QA Replaced unreliable Thread.Sleep(500) with a counter/gate pattern that ensures both transactions are initialized before releasing them to compete for locks (#21165)
Fix flaky test
2025-12-16 12:02:07 +00:00
9c0a0a1086 V16/drag event media (#20893)
* updates the drag event to convert types to lowercase

* clearing up

* clearing up

* Fixed style problem in safari and an unused parameter.

* Fixed lint error.

---------

Co-authored-by: engjlr <enl@umbraco.dk>
Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2025-12-15 14:16:57 +00:00
Andy Butland 1a4256f997 Add option to hide colors from icon picker (#20650)
* Add option to hide colors from icon picker

* Hide colors

* Hide colors from config
# Conflicts:
#	src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-picker-modal/icon-picker-modal.element.ts
2025-12-11 06:57:29 +01:00
Andy Butland 80ae0380a2 oEmbed Providers: Updated the X oEmbed provider to use the x.com domain (closes #21052) (#21053)
* Updated the X oEmbed provider to use the x.com domain.

* Fixed issues raised in code review.
2025-12-05 10:55:09 +01:00
Andy Butland fd01282798 Merge branch 'release/16.4.1' into v16/dev
# Conflicts:
#	src/Umbraco.Web.UI.Client/package.json
#	version.json
2025-12-05 06:44:51 +01:00
f7ba2eaa62 Property Editors: Hide "add button" when maximum configuration is 1 (fixes #20407) (#20738)
Hide add button when max 1

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2025-12-04 16:34:10 +01:00
9485a95c0e Image cropper modal import missing component (#20651)
* Import missing component

* Handle nullable type

* Vertically center image

* Add minimum width for SVG without dimensions

* 100% height until max height

* 100% height minus top/bottom padding

* Revert "100% height minus top/bottom padding"

This reverts commit 67ada4c70f4b75dfcfa2b54ce139ec7465a17ce1.

* Revert "Handle nullable type"

This reverts commit 3130e11a4be83a18b5a7d8c1c24ee23c94d8765d.

* Removed flexbox style

* Fixed circular dependency

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2025-12-04 16:32:10 +01:00
f1ab605bb9 Debug mode: Marks UMB-DEBUG cookie as HttpOnly and Secure (#21032)
* fix: sets profiling cookie to httpOnly and strict in order to run non-secure

* fix: adds extra message to explain when you can set a cookie

* fix: simplify cookie explanation comment in WebProfilerRepository

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

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

* fix: checks that the profiler is actually enabled and/or disabled and warns the user if that is not the case

* Update src/Umbraco.Web.UI.Client/src/assets/lang/en.ts

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-04 10:24:12 +01:00
Andy Butland 3472ff9ba3 Bump version to 16.4.1. 2025-12-03 15:38:10 +01:00
577dc06d55 Delivery API: Only add default strategy if delivery API is not registered. (#20982)
* Only add if not already present

* Update src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2025-12-03 15:37:03 +01:00
f4771d1495 Delivery API: Only add default strategy if delivery API is not registered. (#20982)
* Only add if not already present

* Update src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2025-12-03 12:32:30 +01:00
Andy Butland 4b3ce53acf Merge branch 'release/16.4' into v16/dev
# Conflicts:
#	src/Umbraco.Web.UI.Client/package.json
#	version.json
2025-11-26 07:18:09 +01:00
Andy Butland ca267047d3 Bumped version to 16.4.0. 2025-11-25 12:32:28 +01:00
Jacob Overgaard 0543163817 bumps version to 16.5.0-rc 2025-11-24 16:29:11 +01:00
Jacob Overgaard 72f43a5821 Merge branch 'release/16.4' into v16/dev 2025-11-24 16:28:24 +01:00
aea9034adf Localization: Restores region-specific cultures (#20939) (#20942)
* Adds localization manifests for region-specific cultures

This is to support backwards-compatibility and v13 upgradability.

* Removed `uiCulture` from Vietnamese localizations

since it duplicated the English fallback texts.

* 'en' localization file formatting

* Update src/Umbraco.Web.UI.Client/src/assets/lang/en.ts



---------

Co-authored-by: Lee Kelleher <leekelleher@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-24 15:27:35 +00:00
Jacob Overgaard 6e6f822761 bumps version to 16.4.0-rc3 2025-11-24 15:24:58 +01:00
Niels LyngsøandGitHub 137aa20a10 Block Editors: avoid discard changes for no changes (Fixes #20680) (#20941)
* ensure Block List only updates if it has an update

* ensures RTE and Grid Block Editor ony updates value if there is a change
2025-11-24 14:04:59 +00:00
Callum WhyteandZeegaan d7231c5435 Preserve existing Examine FieldDefinitionCollection if it already exists (#20931)
* Preserve existing Examine FieldDefinitionCollection if it already exists (#20267)

* Fix missing bracket

* Minor tidy/addition of comments; addition of unit tests.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
(cherry picked from commit 908974c6ac)
2025-11-24 12:47:08 +09:00
Andy Butland be116436d9 Migrations: Handles rich text blocks created with TinyMCE in convert local links migration and refreshes internal datatype cache following migration requiring cache rebuild (closes #20885) (#20887)
Handles rich text blocks created with TinyMCE in convert local links migration.
Refreshes internal datatype cache following migration requiring cache rebuild.
# Conflicts:
#	src/Umbraco.Infrastructure/Migrations/MigrationPlanExecutor.cs
2025-11-19 15:35:48 +01:00
Jacob Overgaard aed7505e4b Merge remote-tracking branch 'origin/release/16.4' into v16/dev 2025-11-17 16:39:38 +01:00
590a020303 Redact back-office PKCE codes from the server (V16) (#20851)
Redact back-office PKCE codes from the server (#20847)

* Redact back-office PKCE codes from the server

* Update src/Umbraco.Cms.Api.Common/DependencyInjection/HideBackOfficeTokensHandler.cs

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-11-17 11:17:18 +01:00
Jacob Overgaard 15c6ca7628 Merge remote-tracking branch 'origin/release/16.4' into v16/dev 2025-11-13 14:15:24 +01:00
49ba89c22a Move access/refresh tokens to secure cookies (#20779)
* feat: adds the `credentials: include` header to all manual requests

* feat: adds `credentials: include` as a configurable option to xhr requests (and sets it by default to true)

* feat: configures the auto-generated fetch client from hey-api to include credentials by default

* Add OpenIddict handler to hide tokens from the back-office client

* Make back-office token redaction optional (default false)

* Clear back-office token cookies on logout

* Add configuration for backoffice cookie settings

* Make cookies forcefully secure + move cookie handler enabling to the BackOfficeTokenCookieSettings

* Use the "__Host-" prefix for cookie names

* docs: adds documentation on cookie settings

* build: sets up launch profile for vscode with new cookie recommended settings

* docs: adds extra note around SameSite settings

* docs: adds extra note around SameSite settings

* Respect sites that do not use HTTPS

* Explicitly invalidate potentially valid, old refresh tokens that should no longer be used

* Removed obsolete const

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2025-11-13 08:19:42 +01:00
Andy Butland c295271757 Bumped version to 16.4.0-rc2. 2025-11-13 06:39:10 +01:00
Niels LyngsøandNiels Lyngsø 12b483ff05 Fix block list inline mode (#20745)
* Fix block list inline mode

https://github.com/umbraco/Umbraco-CMS/issues/20618

* Fixed potential runtime errors

* Code cleanup

* Fixed Code Health Review

* Revert some changes

Commented out unused state properties and related code.

* Remove commented-out state property in block workspace view

* fix localization

* no need for question mark after ids, they should be presented as required

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2025-11-10 17:42:16 +01:00
Andy ButlandandGitHub 7502a38033 Dependencies: Update dotnet sdk and node development dependency to latest secure version of current major (16) (#20734)
* Update dotnet sdk and node development dependency to latest secure version of current major.

* Update package-lock.json.
2025-11-05 20:02:11 +01:00
Jacob Overgaard 4e74dbf218 Merge branch 'release/16.4' into v16/dev 2025-11-04 14:07:30 +01:00
Jacob OvergaardandGitHub fa5c53b571 Auth: Cleans up stale or completed auth details from storage (#20725)
* fix: cleans up stale PKCE keys after auth regardless of success or error

* fix: cleans up stale PKCE data on logout
2025-11-04 11:31:34 +00:00
Andreas ZerbstandGitHub 76fed82e91 E2E: QA cherry picked acceptance tests updates from 17 (#20714)
* Updated tests

* Bumped version

* Added v16 to nightly e2e run
2025-11-04 08:23:04 +01:00
Rick ButterfieldandAndy Butland 43ac32282c Preview: Add allow-forms to iframe sandbox attributes (#20701)
Add 'allow-forms' to iframe sandbox attributes
2025-10-31 13:37:35 +01:00
96ecef0a92 Performance: Request cache referenced entities when saving documents with block editors (#20590)
* Added request cache to content and media lookups in mult URL picker.

* Allow property editors to cache referenced entities from block data.

* Update src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs

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

* Add obsoletions.

* Minor spellcheck

* Ensure request cache is available before relying on it.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: kjac <kja@umbraco.dk>
2025-10-31 12:41:46 +01:00
Laura Neto 5e87dead44 Task: Dependency track (#20670)
* Generate BOM files on build

* Upload BOM to Dependency Track

* Move Backoffice BOM generation to right after install

The build and/or pack steps are deleting files that are needed for the BOM to be generated properly.

* Split the BOM uploads into different jobs

* Fix wrong usage of parameters

* Move order of dependency track stage

* Fix wrong umbracoVersion value

* Small fixes

* Log curl response headers

* Correct version sent to dependency track

* Adjusted curl flags

* Fix bom file path

* Fix dotnet bom file name

* Add Login UI to dependency track

* Generate BOM for E2E Tests

* Move dependency track stage

* Move acceptance test .env generation to e2e install template

Needed as the post install script is expecting this to exist.

* Use major version if public release

* Missing ')'

* Reverted npm install command changes in static assets project
2025-10-31 12:11:32 +01:00
7af67d2944 Have to control of the state store navigation for custom sections or … (#20637)
* Have to control of the state store navigation for custom sections or overrides

* revert wording

* move logic and update comment

---------

Co-authored-by: Lucas Bach Bisgaard <lucas.bisgaard@kraftvaerk.com>
Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
2025-10-29 20:00:36 +01:00
Jacob Overgaard ce59537006 build: updates lockfile 2025-10-29 11:40:10 +01:00
Jacob Overgaard f87e15b941 build: adds back the ^ missing from openapi-ts to allow newer versions to be used 2025-10-29 11:38:44 +01:00
Andy Butland 1f82bdde3d Merge branch 'release/16.3.4'
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	version.json
2025-10-29 06:45:38 +01:00
0d2393d866 Caching: Resolves publish and install issues related to stale cached data retrieval (closes #20539 and #20630) (#20640)
* Request cache published content creation with version.

* Reload memory cache after install with package migrations.

* Improve message on install for database cache rebuild.

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

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

* Relocated memory cache refresh after package install from notification handler to unattended upgrader.

* Fix construtor breaking change

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: mole <nikolajlauridsen@protonmail.ch>
2025-10-28 13:26:26 +01:00
bea21d7b99 Caching: Resolves publish and install issues related to stale cached data retrieval (closes #20539 and #20630) (#20640)
* Request cache published content creation with version.

* Reload memory cache after install with package migrations.

* Improve message on install for database cache rebuild.

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

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

* Relocated memory cache refresh after package install from notification handler to unattended upgrader.

* Fix construtor breaking change

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: mole <nikolajlauridsen@protonmail.ch>
2025-10-28 12:25:13 +00:00
Andy Butland 3dc65c48b3 Bump package-lock.json to 16.3.4. 2025-10-28 09:58:31 +01:00
Niels LyngsøandAndy Butland 18ab333afc Hotfix: Implement a specific sorting method for statuses as the existing has … (#20609)
Implement a specific sorting method for statuses as the existing has to support deprecated implementation of custom getUnique method
2025-10-28 09:15:17 +01:00
Niels LyngsøandAndy Butland fd91f88a7e Item Repository: Sort statuses by order of unique (#20603)
* utility

* ability to replace

* deprecate removeStatus

* no need to call this any longer

* Sort statuses and ensure not appending statuses, only updating them
# Conflicts:
#	src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts
2025-10-28 09:15:05 +01:00
Andy Butland 13c164d81f Bump version to 16.3.4. 2025-10-28 08:58:03 +01:00
Andy ButlandandGitHub f33eb3f678 Media types: Handle null configured file extensions when populating allowed media types (closes #20620) (#20635)
* Handle null configured file extensions when populating allowed media types.

* Added clarifying comment.
2025-10-27 13:42:48 +01:00
Jan SkovgaardandSebastiaan Janssen e893682723 Don't call generateAlias on #onAliasChange()
Currently it's not possible to use characters like "_" and "-" in aliases due to this check - At least that is was @nul800sebastiaan told me 😇

Suggested fix for #20622
2025-10-25 10:47:56 +02:00
d9c201e3d1 docs: Add backoffice preview URL to README files (#20623)
* docs: Add backoffice preview URL to README files

Added links to https://backofficepreview.umbraco.com/ in both the main repository README and the Umbraco.Web.UI.Client package README to make the live backoffice preview easily discoverable.

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

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

* docs: fix link

* Update .github/README.md

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-10-24 08:36:34 +02:00
Andy Butland a5fcfc231d Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2025-10-24 06:44:08 +02:00
Andy Butland 6ba03a48c8 Merge branch 'release/16.3.3'
# Conflicts:
#	src/Umbraco.Web.UI.Client/package.json
#	version.json
2025-10-24 06:43:55 +02:00
8434c7d0cb Icon Picker: Fix empty selection allowed on mandatory fields and add validation. (#20536)
* Not show the empty tile when filtering is active.

* Added mandatory property to the icon picker.

* Avoid deselecting the icon on second click when not showing the empty option.

* Extends the form control mixin to the icon picker.

* Used super.value.

* Support mandatory from settings config.

* Removed mandatoryConf.

* remove requestUpdate

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
2025-10-23 13:49:14 +00:00
Bjarne FyrstenborgandGitHub 3854b2bd53 Block List: Remove bold label from inline editing (#20437)
Remove bold label from block list inline editing
2025-10-23 14:32:37 +02:00
dependabot[bot]andJacob Overgaard 08d217360e Bump vite from 7.1.9 to 7.1.11 in /src/Umbraco.Web.UI.Login
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.9 to 7.1.11.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.11
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-23 12:05:29 +02:00
Sebastiaan JanssenandGitHub b762135554 Exclude 'release/no-notes' from release labels 2025-10-23 11:21:25 +02:00
Andy Butland 644334c63b Trees: Restore backward compatibility for file system based tree controllers (closes #20602) (#20608)
* Restore backward compatibility for file system based tree controllers.

* Aligned obsoletion messages.
2025-10-22 16:43:12 +02:00
Andy Butland a09e1777c4 Migrations: Use reliable GUID to check for existence of data type when creating (#20604)
* Use reliable GUID to check for existence of data type in migration.

* Retrieve just a single field in existence check.
2025-10-22 16:43:04 +02:00
Andy Butland 9cb59fe1b4 Bumped version to 16.3.3. 2025-10-22 16:42:12 +02:00
114 changed files with 3042 additions and 642 deletions
+10 -3
View File
@@ -37,7 +37,7 @@ In order to work with the Umbraco source code locally, first make sure you have
### Familiarizing yourself with the code
Umbraco is a .NET application using C#. The solution is broken down into multiple projects. There are several class libraries. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes.
Umbraco is a .NET application using C#. The solution is broken down into multiple projects. There are several class libraries. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes.
There are two web projects in the solution with client-side assets based on TypeScript, `Umbraco.Web.UI.Client` and `Umbraco.Web.UI.Login`.
@@ -73,13 +73,20 @@ Just be careful not to include this change in your PR.
Conversely, if you are working on front-end only, you want to build the back-end once and then run it. Before you do so, update the configuration in `appSettings.json` to add the following under `Umbraco:Cms:Security`:
```
```json
"BackOfficeHost": "http://localhost:5173",
"AuthorizeCallbackPathName": "/oauth_complete",
"AuthorizeCallbackLogoutPathName": "/logout",
"AuthorizeCallbackErrorPathName": "/error"
"AuthorizeCallbackErrorPathName": "/error",
"BackOfficeTokenCookie": {
"Enabled": true,
"SameSite": "None"
}
```
> [!NOTE]
> If you get stuck in a login loop, try clearing your browser cookies for localhost, and make sure that the `BackOfficeTokenCookie` settings are correct. Namely, that `SameSite` should be set to `None` when running the front-end server separately.
Then run Umbraco from the command line.
```
+8
View File
@@ -38,6 +38,14 @@ Some important documentation links to get you started:
- [Getting to know Umbraco](https://docs.umbraco.com/umbraco-cms/fundamentals/get-to-know-umbraco)
- [Tutorials for creating a basic website and customizing the editing experience](https://docs.umbraco.com/umbraco-cms/tutorials/overview)
## Backoffice Preview
Want to see the latest backoffice UI in action? Check out our live preview:
**[backofficepreview.umbraco.com](https://backofficepreview.umbraco.com/)**
This preview is automatically deployed from the main branch and showcases the latest backoffice features and improvements. It runs from mock data and persistent edits are not supported.
## Get help
If you need a bit of feedback while building your Umbraco projects, we are [chatty on Discord](https://discord.umbraco.com). Our Discord server serves as a social space for all Umbracians. If you have any questions or need some help with a problem, head over to our [dedicated forum](https://forum.umbraco.com/) where the Umbraco Community will be happy to help.
+5 -1
View File
@@ -110,7 +110,11 @@ Use this for frontend-only development with hot module reloading:
"BackOfficeHost": "http://localhost:5173",
"AuthorizeCallbackPathName": "/oauth_complete",
"AuthorizeCallbackLogoutPathName": "/logout",
"AuthorizeCallbackErrorPathName": "/error"
"AuthorizeCallbackErrorPathName": "/error",
"BackOfficeTokenCookie": {
"Enabled": true,
"SameSite": "None"
}
```
2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build`
3. Run frontend dev server: `cd src/Umbraco.Web.UI.Client && npm run dev:server`
@@ -52,7 +52,7 @@ jobs:
for (const item of items) {
const releaseLabels = (item.labels || [])
.map(l => (typeof l === "string" ? l : l.name)) // always get the name
.filter(n => typeof n === "string" && n.startsWith("release/"));
.filter(n => typeof n === "string" && n.startsWith("release/") && n !== "release/no-notes");
if (releaseLabels.length === 0) continue;
core.info(`#${item.number}: ${releaseLabels.join(", ")}`);
+4 -1
View File
@@ -105,7 +105,10 @@
"UMBRACO__CMS__SECURITY__BACKOFFICEHOST": "http://localhost:5173",
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKPATHNAME": "/oauth_complete",
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKLOGOUTPATHNAME": "/logout",
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKERRORPATHNAME": "/error"
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKERRORPATHNAME": "/error",
"UMBRACO__CMS__SECURITY__KEEPUSERLOGGEDIN": "true",
"UMBRACO__CMS__SECURITY__BACKOFFICETOKENCOOKIE__ENABLED": "true",
"UMBRACO__CMS__SECURITY__BACKOFFICETOKENCOOKIE__SAMESITE": "None"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Umbraco.Web.UI/Views"
+81
View File
@@ -34,6 +34,10 @@ parameters:
displayName: Upload API docs
type: boolean
default: false
- name: uploadDependencyTrack
displayName: Upload BOMs to Dependency Track
type: boolean
default: false
- name: forceReleaseTestFilter
displayName: Force to use the release test filters
type: boolean
@@ -103,6 +107,15 @@ stages:
command: build
projects: $(solution)
arguments: "--configuration $(buildConfiguration) --no-restore --property:ContinuousIntegrationBuild=true --property:GeneratePackageOnBuild=true --property:PackageOutputPath=$(Build.ArtifactStagingDirectory)/nupkg"
- powershell: |
dotnet tool install --global CycloneDX
dotnet-CycloneDX $(solution) --output $(Build.ArtifactStagingDirectory)/bom --filename bom-dotnet.xml
displayName: 'Generate Backend BOM'
- powershell: |
npm install --global @cyclonedx/cyclonedx-npm
cyclonedx-npm -o $(Build.ArtifactStagingDirectory)\bom\bom-login.xml --ignore-npm-errors --verbose
displayName: Generate Login UI BOM
workingDirectory: src/Umbraco.Web.UI.Login
- task: PublishPipelineArtifact@1
displayName: Publish nupkg
inputs:
@@ -113,6 +126,11 @@ stages:
inputs:
targetPath: $(Build.SourcesDirectory)
artifactName: build_output
- task: PublishPipelineArtifact@1
displayName: Publish Backend BOM
inputs:
targetPath: $(Build.ArtifactStagingDirectory)/bom
artifactName: bom-backend
- job: B
displayName: Build Bellissima Package
@@ -124,6 +142,11 @@ stages:
lfs: false,
fetchDepth: 500
- template: templates/backoffice-install.yml
- powershell: |
npm install --global @cyclonedx/cyclonedx-npm
cyclonedx-npm -o $(Build.ArtifactStagingDirectory)/bom/bom-backoffice.xml --ignore-npm-errors --verbose
displayName: Generate Backoffice UI BOM
workingDirectory: src/Umbraco.Web.UI.Client
- script: npm run build:for:npm
displayName: Run build:for:npm
workingDirectory: src/Umbraco.Web.UI.Client
@@ -140,6 +163,35 @@ stages:
inputs:
targetPath: $(Build.ArtifactStagingDirectory)/npm
artifactName: npm
- publish: $(Build.ArtifactStagingDirectory)/bom
artifact: bom-frontend
displayName: 'Publish Frontend BOM'
- stage: E2E_BOM
displayName: E2E Tests BOM Generation
dependsOn: []
jobs:
- job:
displayName: E2E Generate BOM
pool:
vmImage: "ubuntu-latest"
steps:
- checkout: self
submodules: false
lfs: false,
fetchDepth: 500
- template: templates/e2e-install.yml
parameters:
nodeVersion: ${{ variables.nodeVersion }}
npm_config_cache: ${{ variables.npm_config_cache }}
- powershell: |
npm install --global @cyclonedx/cyclonedx-npm
cyclonedx-npm -o $(Build.ArtifactStagingDirectory)/bom/bom-e2e.xml --ignore-npm-errors --verbose
displayName: Generate E2E Tests BOM
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
- publish: $(Build.ArtifactStagingDirectory)/bom
artifact: bom-e2e
displayName: 'Publish E2E BOM'
- stage: Build_Docs
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.buildApiDocs}}))
@@ -668,6 +720,34 @@ stages:
ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
DatabaseType: ${{ variables.DatabaseType }}
- stage: Dependency_Track
displayName: Dependency Track
dependsOn:
- Build
- E2E_BOM
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.uploadDependencyTrack}}))
variables:
# Determine Umbraco version based on whether it's a public release or not. If public release, use major version, else use full NuGet package version.
umbracoVersion: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PublicRelease'], 'True'), stageDependencies.Build.A.outputs['build.NBGV_VersionMajor'], stageDependencies.Build.A.outputs['build.NBGV_NuGetPackageVersion']) ]
jobs:
- template: templates/dependency-track.yml
parameters:
projectName: "Umbraco-CMS"
umbracoVersion: $(umbracoVersion)
projects:
- name: "Backend"
artifact: "bom-backend"
bomFilePath: "bom-dotnet.xml"
- name: "Login"
artifact: "bom-backend"
bomFilePath: "bom-login.xml"
- name: "Backoffice"
artifact: "bom-frontend"
bomFilePath: "bom-backoffice.xml"
- name: "E2E"
artifact: "bom-e2e"
bomFilePath: "bom-e2e.xml"
###############################################
## Release
###############################################
@@ -874,3 +954,4 @@ stages:
ContainerName: "$web"
BlobPrefix: v$(umbracoMajorVersion)/ui-api
CleanTargetBeforeCopy: true
+7 -27
View File
@@ -26,38 +26,18 @@ steps:
artifact: nupkg
path: $(Agent.BuildDirectory)/app/nupkg
- task: NodeTool@0
displayName: Use Node.js $(nodeVersion)
inputs:
versionSpec: $(nodeVersion)
- task: UseDotNet@2
displayName: Use .NET SDK from global.json
inputs:
useGlobalJson: true
- pwsh: |
"UMBRACO_USER_LOGIN=${{ parameters.PlaywrightUserEmail }}
UMBRACO_USER_PASSWORD=${{ parameters.PlaywrightPassword }}
URL=${{ parameters.ASPNETCORE_URLS }}
STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
displayName: Generate .env
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
# Cache and restore NPM packages
- task: Cache@2
displayName: Cache NPM packages
inputs:
key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
restoreKeys: |
npm_e2e | "$(Agent.OS)"
npm_e2e
path: ${{ parameters.npm_config_cache }}
- script: npm ci --no-fund --no-audit --prefer-offline
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
displayName: Restore NPM packages
- template: templates/e2e-install.yml
parameters:
nodeVersion: ${{ parameters.nodeVersion }}
npm_config_cache: ${{ parameters.npm_config_cache }}
PlaywrightUserEmail: ${{ parameters.PlaywrightUserEmail }}
PlaywrightPassword: ${{ parameters.PlaywrightPassword }}
ASPNETCORE_URLS: ${{ parameters.ASPNETCORE_URLS }}
# Install Template
- pwsh: |
+8 -7
View File
@@ -4,12 +4,11 @@ pr: none
trigger: none
schedules:
- cron: '0 0 * * *'
displayName: Daily midnight build
- cron: '0 6 * * *'
displayName: Daily 6 AM build (v16/dev)
branches:
include:
- v15/dev
- main
- v16/dev
parameters:
- name: skipIntegrationTests
@@ -294,7 +293,8 @@ stages:
- stage: DefaultConfigE2E
displayName: Default Config E2E Tests
dependsOn: Build
dependsOn: Integration
condition: always()
variables:
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
# Enable console logging in Release mode
@@ -475,7 +475,8 @@ stages:
- stage: AdditionalConfigE2E
displayName: Additional Config E2E Tests
dependsOn: Build
dependsOn: DefaultConfigE2E
condition: always()
variables:
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
ASPNETCORE_URLS: https://localhost:44331
@@ -674,4 +675,4 @@ stages:
--data "$PAYLOAD" \
"$SLACK_WEBHOOK_URL"
env:
SLACK_WEBHOOK_URL: $(E2ESLACKWEBHOOKURL)
SLACK_WEBHOOK_URL: $(E2ESLACKWEBHOOKURL)
+56
View File
@@ -0,0 +1,56 @@
parameters:
- name: projectName
type: string
- name: umbracoVersion
type: string
- name: projects
type: object
jobs:
- job: Create_DT_Project
displayName: Create Dependency Track Project
steps:
- checkout: none
- bash: |
project_id=$(curl --no-progress-meter -H "X-Api-Key: $(DT_API_KEY)" "$(DT_API_URL)/v1/project/lookup?name=${{ parameters.projectName }}&version=${{ parameters.umbracoVersion }}" | jq -r '.uuid')
if [ "$project_id" != "null" ] && [ -n "$project_id" ]; then
echo "Project '${{ parameters.projectName }}' with version '${{ parameters.umbracoVersion }}' already exists (ID: $project_id)."
else
project_id=$(curl --no-progress-meter \
-X PUT "$(DT_API_URL)/v1/project" \
-H "X-Api-Key: $(DT_API_KEY)" \
-H "Content-Type: application/json" \
-d '{"name": "${{ parameters.projectName }}", "version": "${{ parameters.umbracoVersion }}", "collectionLogic": "AGGREGATE_DIRECT_CHILDREN"}' \
| jq -r '.uuid')
if [ -z "$project_id" ] || [ "$project_id" == "null" ]; then
echo "Failed to create project '${{ parameters.projectName }}' version '${{ parameters.umbracoVersion }}'."
exit 1
fi
echo "Created project '${{ parameters.projectName }}' with version '${{ parameters.umbracoVersion }}' (ID: $project_id)."
fi
displayName: Ensure main project exists in Dependency Track
- ${{ each project in parameters.projects }}:
- job:
displayName: Upload ${{ project.name }} BOM
dependsOn: Create_DT_Project
steps:
- checkout: none
- download: current
artifact: ${{ project.artifact }}
displayName: Download ${{ project.artifact }} artifact
- script: |
curl --no-progress-meter --fail-with-body \
-X POST "$(DT_API_URL)/v1/bom" \
-H "X-Api-Key: $(DT_API_KEY)" \
-H "Content-Type: multipart/form-data" \
-F "autoCreate=true" \
-F "projectName=${{ parameters.projectName }}-${{ project.name }}" \
-F "projectVersion=${{ parameters.umbracoVersion }}" \
-F "parentName=${{ parameters.projectName }}" \
-F "parentVersion=${{ parameters.umbracoVersion }}" \
-F "bom=@$(Pipeline.Workspace)/${{ project.artifact }}/${{ project.bomFilePath }}"
displayName: Upload ${{ project.name }} BOM to Dependency Track
+49
View File
@@ -0,0 +1,49 @@
parameters:
- name: nodeVersion
type: string
default: ''
- name: npm_config_cache
type: string
default: ''
- name: PlaywrightUserEmail
type: string
default: ''
- name: PlaywrightPassword
type: string
default: ''
- name: ASPNETCORE_URLS
type: string
default: ''
steps:
- task: NodeTool@0
displayName: Use Node.js $(nodeVersion)
inputs:
versionSpec: $(nodeVersion)
- pwsh: |
"UMBRACO_USER_LOGIN=${{ parameters.PlaywrightUserEmail }}
UMBRACO_USER_PASSWORD=${{ parameters.PlaywrightPassword }}
URL=${{ parameters.ASPNETCORE_URLS }}
STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
displayName: Generate .env
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
# Cache and restore NPM packages
- task: Cache@2
displayName: Cache NPM packages
inputs:
key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
restoreKeys: |
npm_e2e | "$(Agent.OS)"
npm_e2e
path: ${{ parameters.npm_config_cache }}
- script: npm ci --no-fund --no-audit --prefer-offline
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
displayName: Restore NPM packages
+1 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "9.0.100",
"version": "9.0.306",
"rollForward": "latestFeature",
"allowPrerelease": false
}
@@ -0,0 +1,230 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using OpenIddict.Server;
using OpenIddict.Validation;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Web.Common.Security;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.DependencyInjection;
internal sealed class HideBackOfficeTokensHandler
: IOpenIddictServerHandler<OpenIddictServerEvents.ApplyTokenResponseContext>,
IOpenIddictServerHandler<OpenIddictServerEvents.ApplyAuthorizationResponseContext>,
IOpenIddictServerHandler<OpenIddictServerEvents.ExtractTokenRequestContext>,
IOpenIddictValidationHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>,
INotificationHandler<UserLogoutSuccessNotification>
{
private const string RedactedTokenValue = "[redacted]";
private const string AccessTokenCookieKey = "__Host-umbAccessToken";
private const string RefreshTokenCookieKey = "__Host-umbRefreshToken";
private const string PkceCodeCookieKey = "__Host-umbPkceCode";
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IDataProtectionProvider _dataProtectionProvider;
private readonly BackOfficeTokenCookieSettings _backOfficeTokenCookieSettings;
private readonly GlobalSettings _globalSettings;
public HideBackOfficeTokensHandler(
IHttpContextAccessor httpContextAccessor,
IDataProtectionProvider dataProtectionProvider,
IOptions<BackOfficeTokenCookieSettings> backOfficeTokenCookieSettings,
IOptions<GlobalSettings> globalSettings)
{
_httpContextAccessor = httpContextAccessor;
_dataProtectionProvider = dataProtectionProvider;
_backOfficeTokenCookieSettings = backOfficeTokenCookieSettings.Value;
_globalSettings = globalSettings.Value;
}
/// <summary>
/// This is invoked when tokens (access and refresh tokens) are issued to a client. For the back-office client,
/// we will intercept the response, write the tokens from the response into HTTP-only cookies, and redact the
/// tokens from the response, so they are not exposed to the client.
/// </summary>
public ValueTask HandleAsync(OpenIddictServerEvents.ApplyTokenResponseContext context)
{
if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice)
{
// Only ever handle the back-office client.
return ValueTask.CompletedTask;
}
HttpContext httpContext = GetHttpContext();
if (context.Response.AccessToken is not null)
{
SetCookie(httpContext, AccessTokenCookieKey, context.Response.AccessToken);
context.Response.AccessToken = RedactedTokenValue;
}
if (context.Response.RefreshToken is not null)
{
SetCookie(httpContext, RefreshTokenCookieKey, context.Response.RefreshToken);
context.Response.RefreshToken = RedactedTokenValue;
}
return ValueTask.CompletedTask;
}
/// <summary>
/// This is invoked when a PKCE code is issued to the client. For the back-office client, we will intercept the
/// response, write the PKCE code from the response into a HTTP-only cookie, and redact the code from the response,
/// so it's not exposed to the client.
/// </summary>
public ValueTask HandleAsync(OpenIddictServerEvents.ApplyAuthorizationResponseContext context)
{
if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice)
{
// Only ever handle the back-office client.
return ValueTask.CompletedTask;
}
if (context.Response.Code is not null)
{
SetCookie(GetHttpContext(), PkceCodeCookieKey, context.Response.Code);
context.Response.Code = RedactedTokenValue;
}
return ValueTask.CompletedTask;
}
/// <summary>
/// This is invoked when requesting new tokens.
/// </summary>
public ValueTask HandleAsync(OpenIddictServerEvents.ExtractTokenRequestContext context)
{
if (context.Request?.ClientId != Constants.OAuthClientIds.BackOffice)
{
// Only ever handle the back-office client.
return ValueTask.CompletedTask;
}
// Handle when the PKCE code is being exchanged for an access token.
if (context.Request.Code == RedactedTokenValue
&& TryGetCookie(PkceCodeCookieKey, out var code))
{
context.Request.Code = code;
// We won't need the PKCE cookie after this, let's remove it.
RemoveCookie(GetHttpContext(), PkceCodeCookieKey);
}
else
{
// PCKE codes should always be redacted. If we got here, someone might be trying to pass another PKCE
// code. For security reasons, explicitly discard the code (if any) to be on the safe side.
context.Request.Code = null;
}
// Handle when a refresh token is being exchanged for a new access token.
if (context.Request.RefreshToken == RedactedTokenValue
&& TryGetCookie(RefreshTokenCookieKey, out var refreshToken))
{
context.Request.RefreshToken = refreshToken;
}
else
{
// If we got here, either the refresh token was not redacted, or nothing was found in the refresh token cookie.
// If OpenIddict found a refresh token, it could be an old token that is potentially still valid. For security
// reasons, we cannot accept that; at this point, we expect the refresh tokens to be explicitly redacted.
context.Request.RefreshToken = null;
}
return ValueTask.CompletedTask;
}
/// <summary>
/// This is invoked when extracting the auth context for a client request.
/// </summary>
public ValueTask HandleAsync(OpenIddictValidationEvents.ProcessAuthenticationContext context)
{
// For the back-office client, this only happens when an access token is sent to the API.
if (context.AccessToken != RedactedTokenValue)
{
return ValueTask.CompletedTask;
}
if (TryGetCookie(AccessTokenCookieKey, out var accessToken))
{
context.AccessToken = accessToken;
}
return ValueTask.CompletedTask;
}
public void Handle(UserLogoutSuccessNotification notification)
{
HttpContext? context = _httpContextAccessor.HttpContext;
if (context is null)
{
// For some reason there is no ambient HTTP context, so we can't clean up the cookies.
// This is OK, because the tokens in the cookies have already been revoked at user sign-out,
// so the cookie clean-up is mostly cosmetic.
return;
}
context.Response.Cookies.Delete(AccessTokenCookieKey);
context.Response.Cookies.Delete(RefreshTokenCookieKey);
}
private HttpContext GetHttpContext()
=> _httpContextAccessor.GetRequiredHttpContext();
private void SetCookie(HttpContext httpContext, string key, string value)
{
var cookieValue = EncryptionHelper.Encrypt(value, _dataProtectionProvider);
RemoveCookie(httpContext, key);
httpContext.Response.Cookies.Append(key, cookieValue, GetCookieOptions(httpContext));
}
private void RemoveCookie(HttpContext httpContext, string key)
=> httpContext.Response.Cookies.Delete(key, GetCookieOptions(httpContext));
private CookieOptions GetCookieOptions(HttpContext httpContext) =>
new()
{
// Prevent the client-side scripts from accessing the cookie.
HttpOnly = true,
// Mark the cookie as essential to the application, to enforce it despite any
// data collection consent options. This aligns with how ASP.NET Core Identity
// does when writing cookies for cookie authentication.
IsEssential = true,
// Cookie path must be root for optimal security.
Path = "/",
// For optimal security, the cooke must be secure. However, Umbraco allows for running development
// environments over HTTP, so we need to take that into account here.
// Thus, we will make the cookie secure if:
// - HTTPS is explicitly enabled by config (default for production environments), or
// - The current request is over HTTPS (meaning the environment supports it regardless of config).
Secure = _globalSettings.UseHttps || httpContext.Request.IsHttps,
// SameSite is configurable (see BackOfficeTokenCookieSettings for defaults):
SameSite = ParseSameSiteMode(_backOfficeTokenCookieSettings.SameSite),
};
private bool TryGetCookie(string key, [NotNullWhen(true)] out string? value)
{
if (GetHttpContext().Request.Cookies.TryGetValue(key, out var cookieValue))
{
value = EncryptionHelper.Decrypt(cookieValue, _dataProtectionProvider);
return true;
}
value = null;
return false;
}
private static SameSiteMode ParseSameSiteMode(string sameSiteMode) =>
Enum.TryParse(sameSiteMode, ignoreCase: true, out SameSiteMode result)
? result
: throw new ArgumentException($"The provided {nameof(sameSiteMode)} value could not be parsed into as SameSiteMode value.", nameof(sameSiteMode));
}
@@ -9,6 +9,7 @@ using Umbraco.Cms.Api.Common.Security;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
using Umbraco.Extensions;
@@ -28,6 +29,11 @@ public static class UmbracoBuilderAuthExtensions
private static void ConfigureOpenIddict(IUmbracoBuilder builder)
{
// Optionally hide tokens from the back-office.
var hideBackOfficeTokens = (builder.Config
.GetSection(Constants.Configuration.ConfigBackOfficeTokenCookie)
.Get<BackOfficeTokenCookieSettings>() ?? new BackOfficeTokenCookieSettings()).Enabled;
builder.Services.AddOpenIddict()
// Register the OpenIddict server components.
.AddServer(options =>
@@ -113,6 +119,28 @@ public static class UmbracoBuilderAuthExtensions
{
configuration.UseSingletonHandler<ProcessRequestContextHandler>().SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1);
});
if (hideBackOfficeTokens)
{
options.AddEventHandler<OpenIddictServerEvents.ApplyTokenResponseContext>(configuration =>
{
configuration
.UseSingletonHandler<HideBackOfficeTokensHandler>()
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ProcessJsonResponse<OpenIddictServerEvents.ApplyTokenResponseContext>.Descriptor.Order - 1);
});
options.AddEventHandler<OpenIddictServerEvents.ApplyAuthorizationResponseContext>(configuration =>
{
configuration
.UseSingletonHandler<HideBackOfficeTokensHandler>()
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.Authentication.ProcessQueryResponse.Descriptor.Order - 1);
});
options.AddEventHandler<OpenIddictServerEvents.ExtractTokenRequestContext>(configuration =>
{
configuration
.UseSingletonHandler<HideBackOfficeTokensHandler>()
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ExtractPostRequest<OpenIddictServerEvents.ExtractTokenRequestContext>.Descriptor.Order + 1);
});
}
})
// Register the OpenIddict validation components.
@@ -137,9 +165,25 @@ public static class UmbracoBuilderAuthExtensions
{
configuration.UseSingletonHandler<ProcessRequestContextHandler>().SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1);
});
if (hideBackOfficeTokens)
{
options.AddEventHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>(configuration =>
{
configuration
.UseSingletonHandler<HideBackOfficeTokensHandler>()
// IMPORTANT: the handler must be AFTER the built-in query string handler, because the client-side SignalR library sometimes appends access tokens to the query string.
.SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ExtractAccessTokenFromQueryString.Descriptor.Order + 1);
});
}
});
builder.Services.AddRecurringBackgroundJob<OpenIddictCleanupJob>();
builder.Services.ConfigureOptions<ConfigureOpenIddict>();
if (hideBackOfficeTokens)
{
builder.AddNotificationHandler<UserLogoutSuccessNotification, HideBackOfficeTokensHandler>();
}
}
}
@@ -1,31 +1,60 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
[ApiVersion("1.0")]
public class DomainsController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IDomainService _domainService;
private readonly IUmbracoMapper _umbracoMapper;
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
[ActivatorUtilitiesConstructor]
public DomainsController(IAuthorizationService authorizationService, IDomainService domainService, IUmbracoMapper umbracoMapper)
{
_authorizationService = authorizationService;
_domainService = domainService;
_umbracoMapper = umbracoMapper;
}
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
: this(
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
domainService,
umbracoMapper)
{
}
[MapToApiVersion("1.0")]
[HttpGet("{id:guid}/domains")]
[ProducesResponseType(typeof(DomainsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Domains(CancellationToken cancellationToken, Guid id)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
IDomain[] assignedDomains = (await _domainService.GetAssignedDomainsAsync(id, true))
.OrderBy(d => d.SortOrder)
.ToArray();
@@ -1,13 +1,19 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
@@ -15,17 +21,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document;
[ApiVersion("1.0")]
public class UpdateDomainsController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IDomainService _domainService;
private readonly IUmbracoMapper _umbracoMapper;
private readonly IDomainPresentationFactory _domainPresentationFactory;
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
[ActivatorUtilitiesConstructor]
public UpdateDomainsController(IAuthorizationService authorizationService, IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
{
_authorizationService = authorizationService;
_domainService = domainService;
_umbracoMapper = umbracoMapper;
_domainPresentationFactory = domainPresentationFactory;
}
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
: this(
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
domainService,
umbracoMapper,
domainPresentationFactory)
{
}
[MapToApiVersion("1.0")]
[HttpPut("{id:guid}/domains")]
[ProducesResponseType(StatusCodes.Status200OK)]
@@ -37,6 +56,16 @@ public class UpdateDomainsController : DocumentControllerBase
Guid id,
UpdateDomainsRequestModel updateModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionAssignDomain.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
DomainsUpdateModel domainsUpdateModel = _umbracoMapper.Map<DomainsUpdateModel>(updateModel)!;
Attempt<DomainUpdateResult, DomainOperationStatus> result = await _domainService.UpdateDomainsAsync(id, domainsUpdateModel);
@@ -1,33 +1,63 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
[ApiVersion("1.0")]
public class UpdateNotificationsController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly INotificationService _notificationService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public UpdateNotificationsController(IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
[ActivatorUtilitiesConstructor]
public UpdateNotificationsController(IAuthorizationService authorizationService, IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_notificationService = notificationService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
public UpdateNotificationsController(IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
: this(
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
contentEditingService,
notificationService,
backOfficeSecurityAccessor)
{
}
[MapToApiVersion("1.0")]
[HttpPut("{id:guid}/notifications")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateNotifications(CancellationToken cancellationToken, Guid id, UpdateDocumentNotificationsRequestModel updateModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
IContent? content = await _contentEditingService.GetAsync(id);
if (content == null)
{
@@ -1,11 +1,14 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Controllers.UserGroup;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.ViewModels.User;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -25,11 +28,26 @@ public class UpdateUserGroupsUserController : UserGroupControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IUserGroupService _userGroupService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public UpdateUserGroupsUserController(IAuthorizationService authorizationService, IUserGroupService userGroupService)
[ActivatorUtilitiesConstructor]
public UpdateUserGroupsUserController(
IAuthorizationService authorizationService,
IUserGroupService userGroupService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_userGroupService = userGroupService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[Obsolete("Please use the constructor accepting all parameters. Scheduled for removal in Umbraco 19.")]
public UpdateUserGroupsUserController(IAuthorizationService authorizationService, IUserGroupService userGroupService)
: this(
authorizationService,
userGroupService,
StaticServiceProvider.Instance.GetRequiredService<IBackOfficeSecurityAccessor>())
{
}
[HttpPost("set-user-groups")]
@@ -51,7 +69,8 @@ public class UpdateUserGroupsUserController : UserGroupControllerBase
Attempt<UserGroupOperationStatus> result = await _userGroupService.UpdateUserGroupsOnUsersAsync(
requestModel.UserGroupIds.Select(x => x.Id).ToHashSet(),
requestModel.UserIds.Select(x => x.Id).ToHashSet());
requestModel.UserIds.Select(x => x.Id).ToHashSet(),
CurrentUserKey(_backOfficeSecurityAccessor));
return result.Success
? Ok()
@@ -1,4 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Umbraco.Cms.Api.Common.Accessors;
using Umbraco.Cms.Api.Common.Rendering;
using Umbraco.Cms.Api.Management.Factories;
@@ -16,9 +16,10 @@ internal static class WebhooksBuilderExtensions
builder.Services.AddUnique<IWebhookPresentationFactory, WebhookPresentationFactory>();
builder.AddMapDefinition<WebhookEventMapDefinition>();
// deliveryApi will overwrite these more basic ones.
builder.Services.AddScoped<IOutputExpansionStrategy, ElementOnlyOutputExpansionStrategy>();
builder.Services.AddSingleton<IOutputExpansionStrategyAccessor, RequestContextOutputExpansionStrategyAccessor>();
// We have to use TryAdd here, as if they are registered by the delivery API, we don't want to register them
// Delivery API will also overwrite these IF it is enabled.
builder.Services.TryAddScoped<IOutputExpansionStrategy, ElementOnlyOutputExpansionStrategy>();
builder.Services.TryAddSingleton<IOutputExpansionStrategyAccessor, RequestContextOutputExpansionStrategyAccessor>();
return builder;
}
@@ -0,0 +1,31 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Typed configuration options for back-office token cookie settings.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigBackOfficeTokenCookie)]
[Obsolete("This will be replaced with a different authentication scheme. Scheduled for removal in Umbraco 18.")]
public class BackOfficeTokenCookieSettings
{
private const bool StaticEnabled = false;
private const string StaticSameSite = "Strict";
/// <summary>
/// Gets or sets a value indicating whether to enable access and refresh tokens in cookies.
/// </summary>
[DefaultValue(StaticEnabled)]
[Obsolete("This is only configurable in Umbraco 16. Scheduled for removal in Umbraco 17.")]
public bool Enabled { get; set; } = StaticEnabled;
/// <summary>
/// Gets or sets a value indicating whether the cookie SameSite configuration.
/// </summary>
/// <remarks>
/// Valid values are "Unspecified", "None", "Lax" and "Strict" (default).
/// </remarks>
[DefaultValue(StaticSameSite)]
public string SameSite { get; set; } = StaticSameSite;
}
@@ -64,6 +64,7 @@ public static partial class Constants
public const string ConfigWebhook = ConfigPrefix + "Webhook";
public const string ConfigWebhookPayloadType = ConfigWebhook + ":PayloadType";
public const string ConfigCache = ConfigPrefix + "Cache";
public const string ConfigBackOfficeTokenCookie = ConfigSecurity + ":BackOfficeTokenCookie";
public static class NamedOptions
{
@@ -86,7 +86,8 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<HelpPageSettings>()
.AddUmbracoOptions<DataTypesSettings>()
.AddUmbracoOptions<WebhookSettings>()
.AddUmbracoOptions<CacheSettings>();
.AddUmbracoOptions<CacheSettings>()
.AddUmbracoOptions<BackOfficeTokenCookieSettings>();
// Configure connection string and ensure it's updated when the configuration changes
builder.Services.AddSingleton<IConfigureOptions<ConnectionStrings>, ConfigureConnectionStrings>();
@@ -81,46 +81,23 @@ public class UserEditorAuthorizationHelper
return Attempt<string?>.Succeed();
}
// d) a non-admin user can remove any groups but can only add groups they themselves belong to
if (userGroupAliases != null)
{
var savingGroupAliases = userGroupAliases.ToArray();
var existingGroupAliases = savingUser == null
IEnumerable<string> requestedGroupAliases = userGroupAliases.ToArray();
IEnumerable<string> existingGroupAliases = savingUser == null
? []
: savingUser.Groups.Select(x => x.Alias).ToArray();
: savingUser.Groups.Select(x => x.Alias);
IEnumerable<string> performingUserGroupAliases = currentUser?.Groups.Select(x => x.Alias) ?? Enumerable.Empty<string>();
IEnumerable<string> addedGroupAliases = savingGroupAliases.Except(existingGroupAliases);
IReadOnlyList<string> unauthorized = UserGroupAssignmentAuthorization
.GetUnauthorizedGroupAssignments(performingUserGroupAliases, requestedGroupAliases, existingGroupAliases);
// As we know the current user is not admin, it is only allowed to use groups that the user do have themselves.
var savingGroupAliasesNotAllowed = addedGroupAliases
.Except(currentUser?.Groups.Select(x => x.Alias) ?? Enumerable.Empty<string>()).ToArray();
if (savingGroupAliasesNotAllowed.Any())
if (unauthorized.Count > 0)
{
return Attempt.Fail("Cannot assign the group(s) '" + string.Join(", ", savingGroupAliasesNotAllowed) +
return Attempt.Fail("Cannot assign the group(s) '" + string.Join(", ", unauthorized) +
"', the current user is not part of them or admin");
}
// only validate any groups that have changed.
// a non-admin user can remove groups and add groups that they have access to
// but they cannot add a group that they do not have access to or that grants them
// path or section access that they don't have access to.
var newGroups = savingUser == null
? savingGroupAliases
: savingGroupAliases.Except(savingUser.Groups.Select(x => x.Alias)).ToArray();
var userGroupsChanged = savingUser != null && newGroups.Length > 0;
if (userGroupsChanged)
{
// d) A user cannot assign a group to another user that they do not belong to
var currentUserGroups = currentUser?.Groups.Select(x => x.Alias).ToArray();
foreach (var group in newGroups)
{
if (currentUserGroups?.Contains(group) == false)
{
return Attempt.Fail("Cannot assign the group " + group + ", the current user is not a member");
}
}
}
}
return Attempt<string?>.Succeed();
+1 -1
View File
@@ -12,7 +12,7 @@ public class X : OEmbedProviderBase
{
}
public override string ApiEndpoint => "http://publish.twitter.com/oembed";
public override string ApiEndpoint => "https://publish.x.com/oembed";
public override string[] UrlSchemeRegex => new[] { @"(https?:\/\/(www\.)?)(twitter|x)\.com\/.*\/status\/.*" };
@@ -23,4 +23,11 @@ public class RichTextBlockValue : BlockValue<RichTextBlockLayoutItem>
/// <inheritdoc />
[JsonIgnore]
public override string PropertyEditorAlias => Constants.PropertyEditors.Aliases.RichText;
/// <inheritdoc />
#pragma warning disable CS0672 // Member overrides obsolete member
#pragma warning disable CS0618 // Type or member is obsolete
public override bool SupportsBlockLayoutAlias(string alias) => base.SupportsBlockLayoutAlias(alias) || alias.Equals("Umbraco.TinyMCE");
#pragma warning restore CS0618 // Type or member is obsolete
#pragma warning restore CS0672 // Member overrides obsolete member
}
@@ -53,6 +53,12 @@ public interface IPublishedContentTypeFactory
/// </summary>
PublishedDataType GetDataType(int id);
/// <summary>
/// Clears the internal data type cache.
/// </summary>
void ClearDataTypeCache()
{ }
/// <summary>
/// Notifies the factory of datatype changes.
/// </summary>
@@ -65,6 +65,22 @@ public class PublishedContentTypeFactory : IPublishedContentTypeFactory
return dataType;
}
/// <inheritdoc />
public void ClearDataTypeCache()
{
if (_publishedDataTypes is null)
{
// Not initialized yet, so skip and avoid lock
return;
}
lock (_publishedDataTypesLocker)
{
// Clear cache (and let it lazy initialize again later)
_publishedDataTypes = null;
}
}
/// <inheritdoc />
public void NotifyDataTypeChanges(params int[] ids)
{
@@ -3,12 +3,14 @@ using System.Globalization;
using System.Runtime.Serialization;
using System.Xml.Linq;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors.Validators;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
@@ -20,6 +22,9 @@ namespace Umbraco.Cms.Core.PropertyEditors;
[DataContract]
public class DataValueEditor : IDataValueEditor
{
private const string ContentCacheKeyFormat = nameof(DataValueEditor) + "_Content_{0}";
private const string MediaCacheKeyFormat = nameof(DataValueEditor) + "_Media_{0}";
private readonly IJsonSerializer? _jsonSerializer;
private readonly IShortStringHelper _shortStringHelper;
@@ -415,4 +420,155 @@ public class DataValueEditor : IDataValueEditor
return value.TryConvertTo(valueType);
}
/// <summary>
/// Retrieves a <see cref="IContent"/> instance by its unique identifier, using the provided request cache to avoid redundant
/// lookups within the same request.
/// </summary>
/// <remarks>
/// This method caches content lookups for the duration of the current request to improve performance when the same content
/// item may be accessed multiple times. This is particularly useful in scenarios involving multiple languages or blocks.
/// </remarks>
/// <param name="key">The unique identifier of the content item to retrieve.</param>
/// <param name="requestCache">The request-scoped cache used to store and retrieve content items for the duration of the current request.</param>
/// <param name="contentService">The content service used to fetch the content item if it is not found in the cache.</param>
/// <returns>The <see cref="IContent"/> instance corresponding to the specified key, or null if no such content item exists.</returns>
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
"Scheduled for removal in Umbraco 19.")]
protected static IContent? GetAndCacheContentById(Guid key, IRequestCache requestCache, IContentService contentService)
{
if (requestCache.IsAvailable is false)
{
return contentService.GetById(key);
}
var cacheKey = string.Format(ContentCacheKeyFormat, key);
IContent? content = requestCache.GetCacheItem<IContent?>(cacheKey);
if (content is null)
{
content = contentService.GetById(key);
if (content is not null)
{
requestCache.Set(cacheKey, content);
}
}
return content;
}
/// <summary>
/// Adds the specified <see cref="IContent"/> item to the request cache using its unique key.
/// </summary>
/// <param name="content">The content item to cache.</param>
/// <param name="requestCache">The request cache in which to store the content item.</param>
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
"Scheduled for removal in Umbraco 19.")]
protected static void CacheContentById(IContent content, IRequestCache requestCache)
{
if (requestCache.IsAvailable is false)
{
return;
}
var cacheKey = string.Format(ContentCacheKeyFormat, content.Key);
requestCache.Set(cacheKey, content);
}
/// <summary>
/// Retrieves a <see cref="IMedia"/> instance by its unique identifier, using the provided request cache to avoid redundant
/// lookups within the same request.
/// </summary>
/// <remarks>
/// This method caches media lookups for the duration of the current request to improve performance when the same media
/// item may be accessed multiple times. This is particularly useful in scenarios involving multiple languages or blocks.
/// </remarks>
/// <param name="key">The unique identifier of the media item to retrieve.</param>
/// <param name="requestCache">The request-scoped cache used to store and retrieve media items for the duration of the current request.</param>
/// <param name="mediaService">The media service used to fetch the media item if it is not found in the cache.</param>
/// <returns>The <see cref="IMedia"/> instance corresponding to the specified key, or null if no such media item exists.</returns>
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
"Scheduled for removal in Umbraco 19.")]
protected static IMedia? GetAndCacheMediaById(Guid key, IRequestCache requestCache, IMediaService mediaService)
{
if (requestCache.IsAvailable is false)
{
return mediaService.GetById(key);
}
var cacheKey = string.Format(MediaCacheKeyFormat, key);
IMedia? media = requestCache.GetCacheItem<IMedia?>(cacheKey);
if (media is null)
{
media = mediaService.GetById(key);
if (media is not null)
{
requestCache.Set(cacheKey, media);
}
}
return media;
}
/// <summary>
/// Adds the specified <see cref="IMedia"/> item to the request cache using its unique key.
/// </summary>
/// <param name="media">The media item to cache.</param>
/// <param name="requestCache">The request cache in which to store the media item.</param>
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
"Scheduled for removal in Umbraco 19.")]
protected static void CacheMediaById(IMedia media, IRequestCache requestCache)
{
if (requestCache.IsAvailable is false)
{
return;
}
var cacheKey = string.Format(MediaCacheKeyFormat, media.Key);
requestCache.Set(cacheKey, media);
}
/// <summary>
/// Determines whether the content item identified by the specified key is present in the request cache.
/// </summary>
/// <param name="key">The unique identifier for the content item to check for in the cache.</param>
/// <param name="requestCache">The request cache in which to look for the content item.</param>
/// <returns>true if the content item is already cached in the request cache; otherwise, false.</returns>
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
"Scheduled for removal in Umbraco 19.")]
protected static bool IsContentAlreadyCached(Guid key, IRequestCache requestCache)
{
if (requestCache.IsAvailable is false)
{
return false;
}
var cacheKey = string.Format(ContentCacheKeyFormat, key);
return requestCache.GetCacheItem<IContent?>(cacheKey) is not null;
}
/// <summary>
/// Determines whether the media item identified by the specified key is present in the request cache.
/// </summary>
/// <param name="key">The unique identifier for the media item to check for in the cache.</param>
/// <param name="requestCache">The request cache in which to look for the media item.</param>
/// <returns>true if the media item is already cached in the request cache; otherwise, false.</returns>
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
"Scheduled for removal in Umbraco 19.")]
protected static bool IsMediaAlreadyCached(Guid key, IRequestCache requestCache)
{
if (requestCache.IsAvailable is false)
{
return false;
}
var cacheKey = string.Format(MediaCacheKeyFormat, key);
return requestCache.GetCacheItem<IMedia?>(cacheKey) is not null;
}
}
@@ -0,0 +1,19 @@
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Optionally implemented by property editors, this defines a contract for caching entities that are referenced in block values.
/// </summary>
[Obsolete("This interface is available for support of request caching retrieved entities in property value editors that implement it. " +
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
"Scheduled for removal in Umbraco 19.")]
public interface ICacheReferencedEntities
{
/// <summary>
/// Caches the entities referenced by the provided block data values.
/// </summary>
/// <param name="values">An enumerable collection of block values that may contain the entities to be cached.</param>
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
"Scheduled for removal in Umbraco 19.")]
void CacheReferencedEntities(IEnumerable<object> values);
}
@@ -0,0 +1,34 @@
namespace Umbraco.Cms.Core.Security;
/// <summary>
/// Shared authorization logic for user group assignment.
/// </summary>
public static class UserGroupAssignmentAuthorization
{
/// <summary>
/// Returns the group aliases that the performing user is not authorized to assign.
/// </summary>
/// <param name="performingUserGroupAliases">The group aliases the performing user belongs to.</param>
/// <param name="requestedGroupAliases">The group aliases being assigned to the target user.</param>
/// <param name="existingGroupAliases">The group aliases the target user currently belongs to.</param>
/// <returns>
/// Group aliases that are being added but the performing user does not belong to.
/// An empty collection means the assignment is authorized.
/// </returns>
/// <remarks>
/// Non-admin users can remove any groups but can only add groups they themselves belong to.
/// Callers should check for admin status before calling this method, as admins bypass this check.
/// </remarks>
public static IReadOnlyList<string> GetUnauthorizedGroupAssignments(
IEnumerable<string> performingUserGroupAliases,
IEnumerable<string> requestedGroupAliases,
IEnumerable<string> existingGroupAliases)
{
var performingGroups = performingUserGroupAliases.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
var existingGroups = existingGroupAliases.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
return requestedGroupAliases
.Where(alias => existingGroups.Contains(alias) is false && performingGroups.Contains(alias) is false)
.ToArray();
}
}
@@ -175,7 +175,7 @@ internal sealed class MediaTypeEditingService : ContentTypeEditingServiceBase<IM
continue;
}
allowedFileExtensionsByMediaType[mediaType] = fileUploadConfiguration.FileExtensions;
allowedFileExtensionsByMediaType[mediaType] = fileUploadConfiguration.FileExtensions ?? []; // Although we never expect null here, legacy data type configuration did allow it.
}
return allowedFileExtensionsByMediaType;
@@ -2,7 +2,22 @@ using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Defines a service for asynchronously retrieving embeddable HTML markup for a specified resource using the oEmbed
/// protocol.
/// </summary>
public interface IOEmbedService
{
/// <summary>
/// Asynchronously retrieves the embeddable HTML markup for the specified resource.
/// </summary>
/// <remarks>The returned markup is suitable for embedding in web pages. The width and height parameters
/// may be ignored by some providers depending on their capabilities.</remarks>
/// <param name="url">The URI of the resource to retrieve markup for. Must be a valid, absolute URI.</param>
/// <param name="width">The optional maximum width, in pixels, for the embedded content. If null, the default width is used.</param>
/// <param name="height">The optional maximum height, in pixels, for the embedded content. If null, the default height is used.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests. The operation is canceled if the token is triggered.</param>
/// <returns>A task that represents the asynchronous operation. The result contains an Attempt with the HTML markup if
/// successful, or an oEmbed operation status indicating the reason for failure.</returns>
Task<Attempt<string, OEmbedOperationStatus>> GetMarkupAsync(Uri url, int? width, int? height, CancellationToken cancellationToken);
}
+18 -1
View File
@@ -1,4 +1,4 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -107,8 +107,25 @@ public interface IUserGroupService
/// <param name="userGroupKeys">The user groups the users should be part of.</param>
/// <param name="userKeys">The user whose groups we want to alter.</param>
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
[Obsolete("Please use the overload accepting all parameters. Scheduled for removal in Umbraco 19.")]
Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(ISet<Guid> userGroupKeys, ISet<Guid> userKeys);
/// <summary>
/// Updates the users to have the groups specified, with authorization checks based on the performing user.
/// </summary>
/// <param name="userGroupKeys">The user groups the users should be part of.</param>
/// <param name="userKeys">The user whose groups we want to alter.</param>
/// <param name="performingUserKey">The key of the user performing the operation.</param>
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
/// <remarks>
/// Non-admin users can only add groups they themselves belong to. Removing groups is always allowed.
/// </remarks>
// TODO (V18): Remove default implementation.
Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(ISet<Guid> userGroupKeys, ISet<Guid> userKeys, Guid performingUserKey)
#pragma warning disable CS0618 // Type or member is obsolete
=> UpdateUserGroupsOnUsersAsync(userGroupKeys, userKeys);
#pragma warning restore CS0618 // Type or member is obsolete
Task<Attempt<UserGroupOperationStatus>> AddUsersToUserGroupAsync(UsersToUserGroupManipulationModel addUsersModel, Guid performingUserKey);
Task<Attempt<UserGroupOperationStatus>> RemoveUsersFromUserGroupAsync(UsersToUserGroupManipulationModel removeUsersModel, Guid performingUserKey);
}
+11 -3
View File
@@ -6,22 +6,30 @@ using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Implements <see cref="IOEmbedService"/> for retrieving embeddable HTML markup using the oEmbed protocol.
/// </summary>
public class OEmbedService : IOEmbedService
{
private readonly EmbedProvidersCollection _embedProvidersCollection;
private readonly ILogger<OEmbedService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="OEmbedService"/> class.
/// </summary>
public OEmbedService(EmbedProvidersCollection embedProvidersCollection, ILogger<OEmbedService> logger)
{
_embedProvidersCollection = embedProvidersCollection;
_logger = logger;
}
/// <inheritdoc/>
public async Task<Attempt<string, OEmbedOperationStatus>> GetMarkupAsync(Uri url, int? maxWidth, int? maxHeight, CancellationToken cancellationToken)
{
// Find the first provider that supports the URL
IEmbedProvider? matchedProvider = _embedProvidersCollection
.FirstOrDefault(provider => provider.UrlSchemeRegex.Any(regex=>new Regex(regex, RegexOptions.IgnoreCase).IsMatch(url.OriginalString)));
.FirstOrDefault(provider => provider.UrlSchemeRegex
.Any(regex => new Regex(regex, RegexOptions.IgnoreCase).IsMatch(url.OriginalString)));
if (matchedProvider is null)
{
@@ -39,8 +47,8 @@ public class OEmbedService : IOEmbedService
}
catch (Exception e)
{
_logger.LogError(e, "Unexpected exception happened while trying to get oembed markup. Provider: {Provider}",matchedProvider.GetType().Name);
Attempt.FailWithStatus(OEmbedOperationStatus.UnexpectedException, string.Empty, e);
_logger.LogError(e, "Unexpected exception happened while trying to get oEmbed markup. Provider: {Provider}", matchedProvider.GetType().Name);
return Attempt.FailWithStatus(OEmbedOperationStatus.UnexpectedException, string.Empty, e);
}
return Attempt.FailWithStatus(OEmbedOperationStatus.ProviderReturnedInvalidResult, string.Empty);
+51 -1
View File
@@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Persistence;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services.AuthorizationStatus;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Extensions;
@@ -210,9 +211,24 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
public async Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
// TODO (V19): Collapse the following three methods into a single one, once the obsolete overload
// of UpdateUserGroupsOnUsersAsync is removed from the interface.
public Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
ISet<Guid> userGroupKeys,
ISet<Guid> userKeys)
=> UpdateUserGroupsOnUsersInternalAsync(userGroupKeys, userKeys, performingUserKey: null);
public Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
ISet<Guid> userGroupKeys,
ISet<Guid> userKeys,
Guid performingUserKey)
=> UpdateUserGroupsOnUsersInternalAsync(userGroupKeys, userKeys, performingUserKey);
private async Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersInternalAsync(
ISet<Guid> userGroupKeys,
ISet<Guid> userKeys,
Guid? performingUserKey)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
@@ -222,6 +238,40 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
.Select(x => x.ToReadOnlyGroup())
.ToArray();
// Authorize the performing user if provided.
if (performingUserKey.HasValue)
{
IUser? performingUser = await _userService.GetAsync(performingUserKey.Value);
if (performingUser is null)
{
scope.Complete();
return Attempt.Fail(UserGroupOperationStatus.MissingUser);
}
if (performingUser.IsAdmin() is false)
{
string[] performingUserGroupAliases = performingUser.Groups.Select(g => g.Alias).ToArray();
string[] requestedGroupAliases = userGroups.Select(g => g.Alias).ToArray();
foreach (IUser user in users)
{
IEnumerable<string> existingGroupAliases = user.Groups.Select(g => g.Alias);
IReadOnlyList<string> unauthorized = UserGroupAssignmentAuthorization
.GetUnauthorizedGroupAssignments(performingUserGroupAliases, requestedGroupAliases, existingGroupAliases);
if (unauthorized.Count > 0)
{
_logger.LogInformation(
"The performing user is not allowed to assign user group(s) '{GroupAliases}' because they do not belong to them.",
string.Join(", ", unauthorized));
scope.Complete();
return Attempt.Fail(UserGroupOperationStatus.Unauthorized);
}
}
}
}
// This means that we're potentially de-admining a user, which might cause the admin group to be empty.
if (userGroupKeys.Contains(Constants.Security.AdminGroupKey) is false)
{
@@ -10,7 +10,7 @@ using Umbraco.Cms.Core.Configuration.Models;
namespace Umbraco.Cms.Infrastructure.Examine.DependencyInjection;
/// <summary>
/// Configures the index options to construct the Examine indexes
/// Configures the index options to construct the Examine indexes.
/// </summary>
public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirectoryIndexOptions>
{
@@ -18,6 +18,9 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
private readonly IUmbracoIndexConfig _umbracoIndexConfig;
private readonly IDeliveryApiContentIndexFieldDefinitionBuilder _deliveryApiContentIndexFieldDefinitionBuilder;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigureIndexOptions"/> class.
/// </summary>
public ConfigureIndexOptions(
IUmbracoIndexConfig umbracoIndexConfig,
IOptions<IndexCreatorSettings> settings,
@@ -28,24 +31,27 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
_deliveryApiContentIndexFieldDefinitionBuilder = deliveryApiContentIndexFieldDefinitionBuilder;
}
/// <inheritdoc/>
public void Configure(string? name, LuceneDirectoryIndexOptions options)
{
// When creating FieldDefinitions with Umbraco defaults, pass in any already defined to avoid overwriting
// those added via a package or custom code.
switch (name)
{
case Constants.UmbracoIndexes.InternalIndexName:
options.Analyzer = new CultureInvariantWhitespaceAnalyzer();
options.Validator = _umbracoIndexConfig.GetContentValueSetValidator();
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
break;
case Constants.UmbracoIndexes.ExternalIndexName:
options.Analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion);
options.Validator = _umbracoIndexConfig.GetPublishedContentValueSetValidator();
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
break;
case Constants.UmbracoIndexes.MembersIndexName:
options.Analyzer = new CultureInvariantWhitespaceAnalyzer();
options.Validator = _umbracoIndexConfig.GetMemberValueSetValidator();
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
break;
case Constants.UmbracoIndexes.DeliveryApiContentIndexName:
options.Analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion);
@@ -64,6 +70,7 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
}
}
/// <inheritdoc/>
public void Configure(LuceneDirectoryIndexOptions options)
=> throw new NotImplementedException("This is never called and is just part of the interface");
}
@@ -20,6 +20,7 @@ public static partial class UmbracoBuilderExtensions
// Add post migration notification handlers
builder.AddNotificationHandler<UmbracoPlanExecutedNotification, ClearCsrfCookieHandler>();
return builder;
}
}
@@ -30,11 +30,29 @@ public class UmbracoFieldDefinitionCollection : FieldDefinitionCollection
new(UmbracoExamineFieldNames.VariesByCultureFieldName, FieldDefinitionTypes.Raw),
};
/// <summary>
/// Initializes a new instance of the <see cref="UmbracoFieldDefinitionCollection"/> class containing
/// the default Umbraco field definitions.
/// </summary>
public UmbracoFieldDefinitionCollection()
: base(UmbracoIndexFieldDefinitions)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="UmbracoFieldDefinitionCollection"/> class containing the containing
/// the default Umbraco field definitions, augmented or overridden by the provided definitions.
/// </summary>
/// <param name="definitions">Existing collection of field definitions.</param>
public UmbracoFieldDefinitionCollection(FieldDefinitionCollection definitions)
: base(UmbracoIndexFieldDefinitions)
{
foreach (FieldDefinition definition in definitions)
{
AddOrUpdate(definition);
}
}
/// <summary>
/// Overridden to dynamically add field definitions for culture variations
/// </summary>
@@ -1,6 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
@@ -28,7 +30,10 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
private readonly IRuntimeState _runtimeState;
private readonly IUmbracoVersion _umbracoVersion;
private readonly UnattendedSettings _unattendedSettings;
private readonly DistributedCache _distributedCache;
private readonly ILogger<UnattendedUpgrader> _logger;
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
public UnattendedUpgrader(
IProfilingLogger profilingLogger,
IUmbracoVersion umbracoVersion,
@@ -36,13 +41,36 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
IRuntimeState runtimeState,
PackageMigrationRunner packageMigrationRunner,
IOptions<UnattendedSettings> unattendedSettings)
: this(
profilingLogger,
umbracoVersion,
databaseBuilder,
runtimeState,
packageMigrationRunner,
unattendedSettings,
StaticServiceProvider.Instance.GetRequiredService<DistributedCache>(),
StaticServiceProvider.Instance.GetRequiredService<ILogger<UnattendedUpgrader>>())
{
_profilingLogger = profilingLogger ?? throw new ArgumentNullException(nameof(profilingLogger));
_umbracoVersion = umbracoVersion ?? throw new ArgumentNullException(nameof(umbracoVersion));
_databaseBuilder = databaseBuilder ?? throw new ArgumentNullException(nameof(databaseBuilder));
_runtimeState = runtimeState ?? throw new ArgumentNullException(nameof(runtimeState));
}
public UnattendedUpgrader(
IProfilingLogger profilingLogger,
IUmbracoVersion umbracoVersion,
DatabaseBuilder databaseBuilder,
IRuntimeState runtimeState,
PackageMigrationRunner packageMigrationRunner,
IOptions<UnattendedSettings> unattendedSettings,
DistributedCache distributedCache,
ILogger<UnattendedUpgrader> logger)
{
_profilingLogger = profilingLogger;
_umbracoVersion = umbracoVersion;
_databaseBuilder = databaseBuilder;
_runtimeState = runtimeState;
_packageMigrationRunner = packageMigrationRunner;
_unattendedSettings = unattendedSettings.Value;
_distributedCache = distributedCache;
_logger = logger;
}
public async Task HandleAsync(RuntimeUnattendedUpgradeNotification notification, CancellationToken cancellationToken)
@@ -109,8 +137,13 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
try
{
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult
.PackageMigrationComplete;
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete;
// Migration plans may have changed published content, so refresh the distributed cache to ensure consistency on first request.
_distributedCache.RefreshAllPublishedSnapshot();
_logger.LogInformation(
"Migration plans run: {Plans}. Triggered refresh of distributed published content cache.",
string.Join(", ", pendingMigrations));
}
catch (Exception ex)
{
@@ -7,6 +7,7 @@ using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Migrations;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Security;
@@ -51,9 +52,12 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
private readonly DistributedCache _distributedCache;
private readonly IScopeAccessor _scopeAccessor;
private readonly ICoreScopeProvider _scopeProvider;
private readonly IPublishedContentTypeFactory _publishedContentTypeFactory;
private bool _rebuildCache;
private bool _invalidateBackofficeUserAccess;
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
public MigrationPlanExecutor(
ICoreScopeProvider scopeProvider,
IScopeAccessor scopeAccessor,
@@ -65,6 +69,33 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
IKeyValueService keyValueService,
IServiceScopeFactory serviceScopeFactory,
AppCaches appCaches)
: this(
scopeProvider,
scopeAccessor,
loggerFactory,
migrationBuilder,
databaseFactory,
databaseCacheRebuilder,
distributedCache,
keyValueService,
serviceScopeFactory,
appCaches,
StaticServiceProvider.Instance.GetRequiredService<IPublishedContentTypeFactory>())
{
}
public MigrationPlanExecutor(
ICoreScopeProvider scopeProvider,
IScopeAccessor scopeAccessor,
ILoggerFactory loggerFactory,
IMigrationBuilder migrationBuilder,
IUmbracoDatabaseFactory databaseFactory,
IDatabaseCacheRebuilder databaseCacheRebuilder,
DistributedCache distributedCache,
IKeyValueService keyValueService,
IServiceScopeFactory serviceScopeFactory,
AppCaches appCaches,
IPublishedContentTypeFactory publishedContentTypeFactory)
{
_scopeProvider = scopeProvider;
_scopeAccessor = scopeAccessor;
@@ -76,6 +107,7 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
_serviceScopeFactory = serviceScopeFactory;
_appCaches = appCaches;
_distributedCache = distributedCache;
_publishedContentTypeFactory = publishedContentTypeFactory;
_logger = _loggerFactory.CreateLogger<MigrationPlanExecutor>();
}
@@ -301,6 +333,7 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
_appCaches.IsolatedCaches.ClearAllCaches();
await _databaseCacheRebuilder.RebuildAsync(false);
_distributedCache.RefreshAllPublishedSnapshot();
_publishedContentTypeFactory.ClearDataTypeCache();
}
private async Task RevokeBackofficeTokens()
@@ -1381,9 +1381,13 @@ AND umbracoNode.id <> @id",
}
else if (ev.Key.langId.HasValue)
{
// This should never happen! If a property culture is flagged as edited then the culture must exist at the document level
throw new PanicException(
$"The existing DocumentCultureVariationDto was not found for node {ev.Key.nodeId} and language {ev.Key.langId}");
// This can happen when a property changes from invariant to variant and the content
// was only created in non-default languages. The invariant property data gets migrated
// to the default language, but no DocumentCultureVariationDto exists for the default
// language because the content was never created in that language.
// In this case, we simply skip updating the edited flag since there's no document
// culture variation record to update.
continue;
}
}
@@ -135,6 +135,8 @@ public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockVal
BlockEditorData<TValue, TLayout>? currentBlockEditorData = SafeParseBlockEditorData(currentValue);
BlockEditorData<TValue, TLayout>? blockEditorData = SafeParseBlockEditorData(editorValue.Value);
CacheReferencedEntities(blockEditorData);
// We can skip MapBlockValueFromEditor if both editorValue and currentValue values are empty.
if (IsBlockEditorDataEmpty(currentBlockEditorData) && IsBlockEditorDataEmpty(blockEditorData))
{
@@ -43,6 +43,45 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
_languageService = languageService;
}
/// <summary>
/// Caches referenced entities for all property values with supporting property editors within the specified block editor data
/// optimising subsequent retrieval of entities when parsing and converting property values.
/// </summary>
/// <remarks>
/// This method iterates through all property values associated with data editors in the provided
/// block editor data and invokes caching for referenced entities where supported by the property editor.
/// </remarks>
/// <param name="blockEditorData">The block editor data containing content and settings property values to analyze for referenced entities.</param>
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
"Scheduled for removal in Umbraco 19.")]
protected void CacheReferencedEntities(BlockEditorData<TValue, TLayout>? blockEditorData)
{
// Group property values by their associated data editor alias.
IEnumerable<IGrouping<string, BlockPropertyValue>> valuesByDataEditors = (blockEditorData?.BlockValue.ContentData ?? []).Union(blockEditorData?.BlockValue.SettingsData ?? [])
.SelectMany(x => x.Values)
.Where(x => x.EditorAlias is not null && x.Value is not null)
.GroupBy(x => x.EditorAlias!);
// Iterate through each group and cache referenced entities if supported by the data editor.
foreach (IGrouping<string, BlockPropertyValue> valueByDataEditor in valuesByDataEditors)
{
IDataEditor? dataEditor = _propertyEditors[valueByDataEditor.Key];
if (dataEditor is null)
{
continue;
}
IDataValueEditor valueEditor = dataEditor.GetValueEditor();
if (valueEditor is ICacheReferencedEntities valueEditorWithPrecaching)
{
valueEditorWithPrecaching.CacheReferencedEntities(valueByDataEditor.Select(x => x.Value!));
}
}
}
/// <inheritdoc />
public abstract IEnumerable<UmbracoEntityReference> GetReferences(object? value);
@@ -52,10 +52,8 @@ public class MediaPicker3PropertyEditor : DataEditor
/// <summary>
/// Defines the value editor for the media picker property editor.
/// </summary>
internal sealed class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference
internal sealed class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference, ICacheReferencedEntities
{
private const string MediaCacheKeyFormat = nameof(MediaPicker3PropertyValueEditor) + "_Media_{0}";
private readonly IDataTypeConfigurationCache _dataTypeReadCache;
private readonly IJsonSerializer _jsonSerializer;
private readonly IMediaImportService _mediaImportService;
@@ -107,6 +105,27 @@ public class MediaPicker3PropertyEditor : DataEditor
Validators.Add(validators);
}
/// <inheritdoc/>
public void CacheReferencedEntities(IEnumerable<object> values)
{
var mediaKeys = values
.SelectMany(value => Deserialize(_jsonSerializer, value))
.Select(dto => dto.MediaKey)
.Distinct()
.Where(x => IsMediaAlreadyCached(x, _appCaches.RequestCache) is false)
.ToList();
if (mediaKeys.Count == 0)
{
return;
}
IEnumerable<IMedia> mediaItems = _mediaService.GetByIds(mediaKeys);
foreach (IMedia media in mediaItems)
{
CacheMediaById(media, _appCaches.RequestCache);
}
}
/// <inheritdoc/>
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
{
@@ -208,31 +227,13 @@ public class MediaPicker3PropertyEditor : DataEditor
foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos)
{
IMedia? media = GetMediaById(mediaWithCropsDto.MediaKey);
IMedia? media = GetAndCacheMediaById(mediaWithCropsDto.MediaKey, _appCaches.RequestCache, _mediaService);
mediaWithCropsDto.MediaTypeAlias = media?.ContentType.Alias ?? unknownMediaType;
}
return mediaWithCropsDtos.Where(m => m.MediaTypeAlias != unknownMediaType).ToList();
}
private IMedia? GetMediaById(Guid key)
{
// Cache media lookups in case the same media is handled multiple times across a save operation,
// which is possible, particularly if we have multiple languages and blocks.
var cacheKey = string.Format(MediaCacheKeyFormat, key);
IMedia? media = _appCaches.RequestCache.GetCacheItem<IMedia?>(cacheKey);
if (media is null)
{
media = _mediaService.GetById(key);
if (media is not null)
{
_appCaches.RequestCache.Set(cacheKey, media);
}
}
return media;
}
private List<MediaWithCropsDto> HandleTemporaryMediaUploads(List<MediaWithCropsDto> mediaWithCropsDtos, MediaPicker3Configuration configuration)
{
var invalidDtos = new List<MediaWithCropsDto>();
@@ -240,7 +241,7 @@ public class MediaPicker3PropertyEditor : DataEditor
foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos)
{
// if the media already exist, don't bother with it
if (GetMediaById(mediaWithCropsDto.MediaKey) != null)
if (GetAndCacheMediaById(mediaWithCropsDto.MediaKey, _appCaches.RequestCache, _mediaService) != null)
{
continue;
}
@@ -480,18 +481,7 @@ public class MediaPicker3PropertyEditor : DataEditor
foreach (var typeAlias in distinctTypeAliases)
{
// Cache media type lookups since the same media type is likely to be used multiple times in validation,
// particularly if we have multiple languages and blocks.
var cacheKey = string.Format(MediaTypeCacheKeyFormat, typeAlias);
string? typeKey = _appCaches.RequestCache.GetCacheItem<string?>(cacheKey);
if (typeKey is null)
{
typeKey = _mediaTypeService.Get(typeAlias)?.Key.ToString();
if (typeKey is not null)
{
_appCaches.RequestCache.Set(cacheKey, typeKey);
}
}
string? typeKey = GetMediaTypeKey(typeAlias);
if (typeKey is null || allowedTypes.Contains(typeKey) is false)
{
@@ -506,6 +496,31 @@ public class MediaPicker3PropertyEditor : DataEditor
return [];
}
private string? GetMediaTypeKey(string typeAlias)
{
// Cache media type lookups since the same media type is likely to be used multiple times in validation,
// particularly if we have multiple languages and blocks.
string? GetMediaTypeKeyFromService(string typeAlias) => _mediaTypeService.Get(typeAlias)?.Key.ToString();
if (_appCaches.RequestCache.IsAvailable is false)
{
return GetMediaTypeKeyFromService(typeAlias);
}
var cacheKey = string.Format(MediaTypeCacheKeyFormat, typeAlias);
string? typeKey = _appCaches.RequestCache.GetCacheItem<string?>(cacheKey);
if (typeKey is null)
{
typeKey = GetMediaTypeKeyFromService(typeAlias);
if (typeKey is not null)
{
_appCaches.RequestCache.Set(cacheKey, typeKey);
}
}
return typeKey;
}
}
/// <summary>
@@ -3,7 +3,10 @@
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
@@ -19,14 +22,16 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference
public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference, ICacheReferencedEntities
{
private readonly ILogger<MultiUrlPickerValueEditor> _logger;
private readonly IPublishedUrlProvider _publishedUrlProvider;
private readonly IJsonSerializer _jsonSerializer;
private readonly IContentService _contentService;
private readonly IMediaService _mediaService;
private readonly AppCaches _appCaches;
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
public MultiUrlPickerValueEditor(
ILogger<MultiUrlPickerValueEditor> logger,
ILocalizedTextService localizedTextService,
@@ -37,19 +42,102 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference
IIOHelper ioHelper,
IContentService contentService,
IMediaService mediaService)
: this(
logger,
localizedTextService,
shortStringHelper,
attribute,
publishedUrlProvider,
jsonSerializer,
ioHelper,
contentService,
mediaService,
StaticServiceProvider.Instance.GetRequiredService<AppCaches>())
{
}
public MultiUrlPickerValueEditor(
ILogger<MultiUrlPickerValueEditor> logger,
ILocalizedTextService localizedTextService,
IShortStringHelper shortStringHelper,
DataEditorAttribute attribute,
IPublishedUrlProvider publishedUrlProvider,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
IContentService contentService,
IMediaService mediaService,
AppCaches appCaches)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_logger = logger;
_publishedUrlProvider = publishedUrlProvider;
_jsonSerializer = jsonSerializer;
_contentService = contentService;
_mediaService = mediaService;
_appCaches = appCaches;
Validators.Add(new TypedJsonValidatorRunner<LinkDisplay[], MultiUrlPickerConfiguration>(
_jsonSerializer,
new MinMaxValidator(localizedTextService)));
}
/// <inheritdoc/>
public void CacheReferencedEntities(IEnumerable<object> values)
{
var dtos = values
.Select(value =>
{
var asString = value is string str ? str : value.ToString();
if (string.IsNullOrEmpty(asString))
{
return null;
}
return _jsonSerializer.Deserialize<List<LinkDto>>(asString);
})
.WhereNotNull()
.SelectMany(x => x)
.Where(x => x.Type == Constants.UdiEntityType.Document || x.Type == Constants.UdiEntityType.Media)
.ToList();
IList<Guid> contentKeys = GetKeys(Constants.UdiEntityType.Document, dtos);
IList<Guid> mediaKeys = GetKeys(Constants.UdiEntityType.Media, dtos);
if (contentKeys.Count > 0)
{
IEnumerable<IContent> contentItems = _contentService.GetByIds(contentKeys);
foreach (IContent content in contentItems)
{
CacheContentById(content, _appCaches.RequestCache);
}
}
if (mediaKeys.Count > 0)
{
IEnumerable<IMedia> mediaItems = _mediaService.GetByIds(mediaKeys);
foreach (IMedia media in mediaItems)
{
CacheMediaById(media, _appCaches.RequestCache);
}
}
}
private IList<Guid> GetKeys(string entityType, IEnumerable<LinkDto> dtos) =>
dtos
.Where(x => x.Type == entityType)
.Select(x => x.Unique ?? (x.Udi is not null ? x.Udi.Guid : Guid.Empty))
.Where(x => x != Guid.Empty)
.Distinct()
.Where(x => IsAlreadyCached(x, entityType) is false)
.ToList();
private bool IsAlreadyCached(Guid key, string entityType) => entityType switch
{
Constants.UdiEntityType.Document => IsContentAlreadyCached(key, _appCaches.RequestCache),
Constants.UdiEntityType.Media => IsMediaAlreadyCached(key, _appCaches.RequestCache),
_ => false,
};
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
{
var asString = value == null ? string.Empty : value is string str ? str : value.ToString();
@@ -105,7 +193,7 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference
if (dto.Udi.EntityType == Constants.UdiEntityType.Document)
{
url = _publishedUrlProvider.GetUrl(dto.Udi.Guid, UrlMode.Relative, culture);
IContent? c = _contentService.GetById(dto.Udi.Guid);
IContent? c = GetAndCacheContentById(dto.Udi.Guid, _appCaches.RequestCache, _contentService);
if (c is not null)
{
@@ -119,7 +207,7 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference
else if (dto.Udi.EntityType == Constants.UdiEntityType.Media)
{
url = _publishedUrlProvider.GetMediaUrl(dto.Udi.Guid, UrlMode.Relative, culture);
IMedia? m = _mediaService.GetById(dto.Udi.Guid);
IMedia? m = GetAndCacheMediaById(dto.Udi.Guid, _appCaches.RequestCache, _mediaService);
if (m is not null)
{
published = m.Trashed is false;
@@ -207,6 +295,12 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference
[DataMember(Name = "target")]
public string? Target { get; set; }
[DataMember(Name = "unique")]
public Guid? Unique { get; set; }
[DataMember(Name = "type")]
public string? Type { get; set; }
[DataMember(Name = "udi")]
public GuidUdi? Udi { get; set; }
@@ -108,7 +108,7 @@ internal sealed class DatabaseCacheRebuilder : IDatabaseCacheRebuilder
_logger.LogWarning(
"Database cache was serialized using {CurrentSerializer}. Currently configured cache serializer {Serializer}. Rebuilding database cache.",
currentSerializer,
currentSerializer == 0 ? "None" : currentSerializer,
serializer);
using (_profilingLogger.TraceDuration<DatabaseCacheRebuilder>($"Rebuilding database cache with {serializer} serializer"))
@@ -38,7 +38,7 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory
/// <inheritdoc/>
public IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview)
{
var cacheKey = $"{nameof(PublishedContentFactory)}DocumentCache_{contentCacheNode.Id}_{preview}";
var cacheKey = $"{nameof(PublishedContentFactory)}DocumentCache_{contentCacheNode.Id}_{preview}_{contentCacheNode.Data?.VersionDate.Ticks ?? 0}";
IPublishedContent? publishedContent = null;
if (_appCaches.RequestCache.IsAvailable)
{
@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;
namespace Umbraco.Cms.Web.Common.Repositories;
@@ -11,21 +14,35 @@ internal sealed class WebProfilerRepository : IWebProfilerRepository
private const string QueryName = "umbDebug";
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ICookieManager _cookieManager;
private readonly GlobalSettings _globalSettings;
public WebProfilerRepository(IHttpContextAccessor httpContextAccessor)
public WebProfilerRepository(IHttpContextAccessor httpContextAccessor, ICookieManager cookieManager, IOptions<GlobalSettings> globalSettings)
{
_httpContextAccessor = httpContextAccessor;
_cookieManager = cookieManager;
_globalSettings = globalSettings.Value;
}
public void SetStatus(int userId, bool status)
{
if (status)
{
_httpContextAccessor.GetRequiredHttpContext().Response.Cookies.Append(CookieName, "1", new CookieOptions { Expires = DateTime.Now.AddYears(1) });
// This cookie enables debug profiling on the front-end without needing query strings or headers.
// It uses SameSite=Strict, so it only works when the BackOffice and front-end share the same domain.
// It's marked httpOnly to prevent JavaScript access (the server reads it, not client-side code).
// No expiration is set, so it's a session cookie and will be deleted when the browser closes.
// For cross-site setups, use the query string (?umbDebug=true) or header (X-UMB-DEBUG) instead.
_cookieManager.SetCookieValue(
CookieName,
"1",
httpOnly: true,
secure: _globalSettings.UseHttps,
sameSiteMode: "Strict");
}
else
{
_httpContextAccessor.GetRequiredHttpContext().Response.Cookies.Delete(CookieName);
_cookieManager.ExpireCookie(CookieName);
}
}
@@ -43,6 +60,6 @@ internal sealed class WebProfilerRepository : IWebProfilerRepository
return xUmbDebug;
}
return request.Cookies.ContainsKey(CookieName);
return _cookieManager.HasCookie(CookieName);
}
}
+11 -1
View File
@@ -26,6 +26,7 @@ If you have an existing Vite server running, you can run the task **Backoffice A
### Run a Front-end server against a local Umbraco instance
#### 1. Configure Umbraco instance
Enable the front-end server communicating with the Backend server(Umbraco instance) you need need to correct the `appsettings.json` of your project.
For code contributions use the backend project of `/src/Umbraco.Web.UI`.
@@ -38,7 +39,11 @@ Open this file in an editor: `/src/Umbraco.Web.UI/appsettings.Development.json`
"BackOfficeHost": "http://localhost:5173",
"AuthorizeCallbackPathName": "/oauth_complete",
"AuthorizeCallbackLogoutPathName": "/logout",
"AuthorizeCallbackErrorPathName": "/error",
"AuthorizeCallbackErrorPathName": "/error",,
"BackOfficeTokenCookie": {
"Enabled": true,
"SameSite": "None"
}
},
},
}
@@ -46,10 +51,15 @@ Open this file in an editor: `/src/Umbraco.Web.UI/appsettings.Development.json`
This will override the backoffice host URL, enabling the Client to run from a different origin.
> [!NOTE]
> If you get stuck in a login loop, try clearing your browser cookies for localhost, and make sure that the `BackOfficeTokenCookie` settings are correct. Namely, that `SameSite` should be set to `None` when running the front-end server separately.
#### 2. Start Umbraco
Then start the backend server by running the command: `dotnet run` in the `/src/Umbraco.Web.UI` folder.
#### 3. Start Frontend server
Now start the frontend server by running the command: `npm run dev:server` in the `/src/Umbraco.Web.UI.Client` folder.
Finally open `http://localhost:5173` in your browser.
+8
View File
@@ -2,6 +2,14 @@
This package contains the types for the Umbraco Backoffice.
## Preview
A live preview of the latest backoffice build from the main branch is available at:
**[backofficepreview.umbraco.com](https://backofficepreview.umbraco.com/)**
This preview is automatically deployed via GitHub Actions whenever changes are pushed to main or version branches.
## Installation
```bash
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "@umbraco-cms/backoffice",
"version": "16.4.0-rc",
"version": "16.5.0-rc",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@umbraco-cms/backoffice",
"version": "16.4.0-rc",
"version": "16.5.0-rc",
"license": "MIT",
"workspaces": [
"./src/packages/*",
@@ -64,8 +64,8 @@
"web-component-analyzer": "^2.0.0"
},
"engines": {
"node": ">=22",
"npm": ">=10.9"
"node": ">=22.17.1",
"npm": ">=10.9.2"
}
},
"node_modules/@adobe/css-tools": {
@@ -17062,7 +17062,7 @@
"src/packages/core": {
"name": "@umbraco-backoffice/core",
"dependencies": {
"@hey-api/openapi-ts": "0.85.0",
"@hey-api/openapi-ts": "^0.85.0",
"@types/diff": "^7.0.2",
"diff": "^7.0.0",
"uuid": "^13.0.0"
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@umbraco-cms/backoffice",
"license": "MIT",
"version": "16.4.0-rc",
"version": "16.6.0-rc",
"type": "module",
"exports": {
".": null,
@@ -212,8 +212,8 @@
"generate:ui-api-docs": "npm run generate:check-const-test && typedoc --options typedoc.config.js"
},
"engines": {
"node": ">=22",
"npm": ">=10.9"
"node": ">=22.17.1",
"npm": ">=10.9.2"
},
"dependencies": {
"element-internals-polyfill": "^3.0.2"
@@ -83,8 +83,9 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement {
const clickedSectionAlias = manifest.alias;
// If the clicked section is the same as the current section, we just load the original section path to load the section root
if (this._currentSectionAlias === clickedSectionAlias) {
// If preventUrlRetention is set to true then go to the section root.
// Or if the clicked section is the current active one, then navigate to the section root
if (manifest?.meta.preventUrlRetention === true || this._currentSectionAlias === clickedSectionAlias) {
const sectionPath = this.#getSectionPath(manifest);
history.pushState(null, '', sectionPath);
return;
@@ -63,7 +63,7 @@ export class UmbPreviewElement extends UmbLitElement {
src=${this._previewUrl}
title="Page preview"
@load=${this.#onIFrameLoad}
sandbox="allow-scripts allow-same-origin"></iframe>
sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
</div>
</div>
<div id="menu">
@@ -2525,13 +2525,19 @@ export default {
profiling: {
performanceProfiling: 'Performance profiling',
performanceProfilingDescription:
"<p>Umbraco currently runs in debug mode. This means you can use the built-in performance profiler to assess the performance when rendering pages.</p><p>If you want to activate the profiler for a specific page rendering, simply add <strong>umbDebug=true</strong> to the querystring when requesting the page.</p><p>If you want the profiler to be activated by default for all page renderings, you can use the toggle below. It will set a cookie in your browser, which then activates the profiler automatically. In other words, the profiler will only be active by default in <em>your</em> browser - not everyone else's.</p>",
"<p>Umbraco currently runs in debug mode. This means you can use the built-in performance profiler to assess the performance when rendering pages.</p><p>If you want to activate the profiler for a specific page rendering, simply add <strong>umbDebug=true</strong> to the querystring when requesting the page.</p><p>If you want the profiler to be activated by default for all page renderings, you can use the toggle below. It will set a cookie in your browser, which then activates the profiler automatically. In other words, the profiler will only be active by default in <em>your</em> browser - not everyone else's.</p><p><strong>Note:</strong> This will only work if the Backoffice is currently located on the same URL as the front-end website.</p>",
activateByDefault: 'Activate the profiler by default',
reminder: 'Friendly reminder',
reminderDescription:
'<p>You should never let a production site run in debug mode. Debug mode is turned off by setting <strong>Umbraco:CMS:Hosting:Debug</strong> to <strong>false</strong> in appsettings.json, appsettings.{Environment}.json or via an environment variable.</p>',
profilerEnabledDescription:
"<p>Umbraco currently does not run in debug mode, so you can't use the built-in profiler. This is how it should be for a production site.</p><p>Debug mode is turned on by setting <strong>Umbraco:CMS:Hosting:Debug</strong> to <strong>true</strong> in appsettings.json, appsettings.{Environment}.json or via an environment variable.</p>",
errorEnablingProfilerTitle: 'Error enabling profiler',
errorEnablingProfilerDescription:
'It was not possible to enable the profiler. Check that you are accessing the Backoffice on the same URL as the front-end website, and try again. If the problem persists, please check the log for more details.',
errorDisablingProfilerTitle: 'Error disabling profiler',
errorDisablingProfilerDescription:
'It was not possible to disable the profiler. Try again, and if the problem persists, please check the log for more details.',
},
settingsDashboardVideos: {
trainingHeadline: 'Hours of Umbraco training videos are only a click away',
@@ -2869,29 +2875,52 @@ export default {
ar: 'العربية',
bs: 'Bosanski',
cs: 'Česky',
'cs-cz': 'Česky (Czechia)',
cy: 'Cymraeg',
'cy-gb': 'Cymraeg (UK)',
da: 'Dansk',
'da-dk': 'Dansk (Danmark)',
de: 'Deutsch',
'de-de': 'Deutsch (Deutschland)',
'de-ch': 'Deutsch (Schweiz)',
en: 'English (UK)',
'en-us': 'English (US)',
es: 'Español',
'es-es': 'Español (España)',
fr: 'Français',
he: 'Hebrew',
'fr-fr': 'Français (France)',
'fr-ch': 'Français (Suisse)',
he: 'עברית',
'he-il': 'עברית (ישראל)',
hr: 'Hrvatski',
'hr-hr': 'Hrvatski (Hrvatska)',
it: 'Italiano',
'it-it': 'Italiano (Italia)',
'it-ch': 'Italiano (Svizzera)',
ja: '日本語',
'ja-jp': '日本語 (日本)',
ko: '한국어',
'ko-kr': '한국어 (한국)',
nb: 'Norsk Bokmål',
'nb-no': 'Norsk (Bokmål)',
nl: 'Nederlands',
'nl-nl': 'Nederlands (Nederland)',
pl: 'Polski',
'pl-pl': 'Polski (Polska)',
pt: 'Português',
'pt-br': 'Português (Brasil)',
ro: 'Romana',
ro: 'Română',
'ro-ro': 'Română (România)',
ru: 'Русский',
'ru-ru': 'Русский (Россия)',
sv: 'Svenska',
'sv-se': 'Svenska (Sverige)',
tr: 'Türkçe',
'tr-tr': 'Türkçe (Türkiye Cumhuriyeti)',
uk: 'Українська',
'uk-ua': 'Українська (Україна)',
zh: '中文',
'zh-cn': '中文(简体,中国)',
'zh-tw': '中文(正體,台灣)',
vi: 'Tiếng Việt',
},
@@ -2836,34 +2836,4 @@ export default {
resetUrlMessage: 'Bạn có chắc chắn muốn đặt lại URL này không?',
resetUrlLabel: 'Đặt lại',
},
uiCulture: {
ar: 'العربية',
bs: 'Bosanski',
cs: 'Česky',
cy: 'Cymraeg',
da: 'Dansk',
de: 'Deutsch',
en: 'English (UK)',
'en-us': 'English (US)',
es: 'Español',
fr: 'Français',
he: 'Hebrew',
hr: 'Hrvatski',
it: 'Italiano',
ja: '日本語',
ko: '한국어',
nb: 'Norsk Bokmål',
nl: 'Nederlands',
pl: 'Polski',
pt: 'Português',
'pt-br': 'Português (Brasil)',
ro: 'Romana',
ru: 'Русский',
sv: 'Svenska',
tr: 'Türkçe',
uk: 'Українська',
zh: '中文',
'zh-tw': '中文(正體,台灣)',
vi: 'Tiếng Việt',
},
} as UmbLocalizationDictionary;
@@ -77,6 +77,41 @@ export class RedirectRequestHandler extends AuthorizationRequestHandler {
});
}
/**
* Cleanup all stale authorization requests and configurations from storage.
* This scans localStorage for any keys matching the appauth patterns and removes them,
* including the authorization request handle key.
*/
public cleanupStaleAuthorizationData(): Promise<void> {
// Check if we're in a browser environment with localStorage
if (typeof window === 'undefined' || !window.localStorage) {
return Promise.resolve();
}
const keysToRemove: string[] = [];
// Scan localStorage for all appauth-related keys
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (
key &&
(key.includes('_appauth_authorization_request') ||
key.includes('_appauth_authorization_service_configuration') ||
key === AUTHORIZATION_REQUEST_HANDLE_KEY)
) {
keysToRemove.push(key);
}
}
// Remove all found stale keys
const removePromises = keysToRemove.map((key) => this.storageBackend.removeItem(key));
return Promise.all(removePromises).then(() => {
if (keysToRemove.length > 0) {
log(`Cleaned up ${keysToRemove.length} stale authorization data entries`);
}
});
}
/**
* Attempts to introspect the contents of storage backend and completes the
* request.
@@ -119,12 +154,8 @@ export class RedirectRequestHandler extends AuthorizationRequestHandler {
} else {
authorizationResponse = new AuthorizationResponse({ code: code, state: state });
}
// cleanup state
return Promise.all([
this.storageBackend.removeItem(AUTHORIZATION_REQUEST_HANDLE_KEY),
this.storageBackend.removeItem(authorizationRequestKey(handle)),
this.storageBackend.removeItem(authorizationServiceConfigurationKey(handle)),
]).then(() => {
// cleanup all authorization data including current and stale entries
return this.cleanupStaleAuthorizationData().then(() => {
log('Delivering authorization response');
return {
request: request,
@@ -134,7 +165,10 @@ export class RedirectRequestHandler extends AuthorizationRequestHandler {
});
} else {
log('Mismatched request (state and request_uri) dont match.');
return Promise.resolve(null);
// cleanup all authorization data even on mismatch to prevent stale PKCE data
return this.cleanupStaleAuthorizationData().then(() => {
return null;
});
}
})
);
@@ -35,6 +35,7 @@ export class FetchRequestor extends Requestor {
const requestInit: RequestInit = {};
requestInit.method = settings.method;
requestInit.mode = 'cors';
requestInit.credentials = settings.credentials ?? 'include';
if (settings.data) {
if (settings.method && settings.method.toUpperCase() === 'POST') {
@@ -198,6 +198,9 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
@state()
private _isReadOnly: boolean = false;
@state()
private _limitMax?: number;
constructor() {
super();
@@ -294,6 +297,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
}
async #setupRangeValidation(rangeLimit: UmbNumberRangeValueType | undefined) {
this._limitMax = rangeLimit?.max;
if (this.#rangeUnderflowValidator) {
this.removeValidator(this.#rangeUnderflowValidator);
this.#rangeUnderflowValidator = undefined;
@@ -408,6 +412,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
}
#renderCreateButtonGroup() {
if (this._limitMax === 1 && this._layoutEntries.length > 0) return nothing;
if (this._areaKey === null || this._layoutEntries.length === 0) {
return html` <uui-button-group id="createButton">
${this.#renderCreateButton()} ${this.#renderPasteButton()}
@@ -17,7 +17,7 @@ import type {
UmbPropertyEditorUiElement,
UmbPropertyEditorConfigCollection,
} from '@umbraco-cms/backoffice/property-editor';
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
import { jsonStringComparison, observeMultiple } from '@umbraco-cms/backoffice/observable-api';
import { UMB_PROPERTY_CONTEXT, UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property';
import { UmbFormControlMixin, UmbValidationContext } from '@umbraco-cms/backoffice/validation';
import type { UmbBlockTypeGroup } from '@umbraco-cms/backoffice/block-type';
@@ -181,15 +181,22 @@ export class UmbPropertyEditorUIBlockGridElement
]).pipe(debounceTime(20)),
([layouts, contents, settings, exposes]) => {
if (layouts.length === 0) {
if (this.value === undefined) {
return;
}
super.value = undefined;
} else {
super.value = {
const newValue = {
...super.value,
layout: { [UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]: layouts },
contentData: contents,
settingsData: settings,
expose: exposes,
};
if (jsonStringComparison(this.value, newValue)) {
return;
}
super.value = newValue;
}
// If we don't have a value set from the outside or an internal value, we don't want to set the value.
@@ -292,10 +292,6 @@ export class UmbInlineListBlockElement extends UmbLitElement {
padding-left: var(--uui-size-2, 6px);
}
#name {
font-weight: 700;
}
uui-tag {
margin-left: 0.5em;
margin-bottom: -0.3em;
@@ -25,7 +25,7 @@ import {
UmbFormControlMixin,
UmbValidationContext,
} from '@umbraco-cms/backoffice/validation';
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
import { jsonStringComparison, observeMultiple } from '@umbraco-cms/backoffice/observable-api';
import { debounceTime } from '@umbraco-cms/backoffice/external/rxjs';
import { UMB_CONTENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content';
@@ -339,15 +339,22 @@ export class UmbPropertyEditorUIBlockListElement
]).pipe(debounceTime(20)),
([layouts, contents, settings, exposes]) => {
if (layouts.length === 0) {
if (this.value === undefined) {
return;
}
super.value = undefined;
} else {
super.value = {
const newValue = {
...super.value,
layout: { [UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS]: layouts },
contentData: contents,
settingsData: settings,
expose: exposes,
};
if (jsonStringComparison(this.value, newValue)) {
return;
}
super.value = newValue;
}
// If we don't have a value set from the outside or an internal value, we don't want to set the value.
@@ -389,11 +396,8 @@ export class UmbPropertyEditorUIBlockListElement
}
#renderCreateButtonGroup() {
if (this.readonly && this._layouts.length > 0) {
return nothing;
} else {
return html`<uui-button-group>${this.#renderCreateButton()}${this.#renderPasteButton()}</uui-button-group>`;
}
if (this._layouts.length > 0 && (this._limitMax === 1 || this.readonly)) return nothing;
return html`<uui-button-group>${this.#renderCreateButton()}${this.#renderPasteButton()}</uui-button-group>`;
}
#renderInlineCreateButton(index: number) {
@@ -14,7 +14,7 @@ import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'
*/
@customElement('umb-block-workspace-view-edit-content-no-router')
export class UmbBlockWorkspaceViewEditContentNoRouterElement extends UmbLitElement implements UmbWorkspaceViewElement {
//private _hasRootProperties = false;
// private _hasRootProperties = false;
@state()
private _hasRootGroups = false;
@@ -25,9 +25,6 @@ export class UmbBlockWorkspaceViewEditContentNoRouterElement extends UmbLitEleme
@state()
private _activeTabKey?: string | null | undefined;
//@state()
//private _activeTabName?: string | null | undefined;
#blockWorkspace?: typeof UMB_BLOCK_WORKSPACE_CONTEXT.TYPE;
#tabsStructureHelper = new UmbContentTypeContainerStructureHelper(this);
@@ -67,46 +64,45 @@ export class UmbBlockWorkspaceViewEditContentNoRouterElement extends UmbLitEleme
#checkDefaultTabName() {
if (!this._tabs || !this.#blockWorkspace) return;
// Find the default tab to grab:
// Find the default tab to grab
if (this._activeTabKey === undefined) {
if (this._hasRootGroups) {
//this._activeTabName = null;
this._activeTabKey = null;
} else if (this._tabs.length > 0) {
//this._activeTabName = this._tabs[0].name;
this._activeTabKey = this._tabs[0].key;
const tab = this._tabs[0];
this._activeTabKey = tab.ownerId ?? tab.ids[0];
}
}
}
#setTabName(tabName: string | undefined | null, tabKey: string | null | undefined) {
//this._activeTabName = tabName;
#setTabKey(tabKey: string | null | undefined) {
this._activeTabKey = tabKey;
}
override render() {
if (!this._tabs) return;
return html`
${this._tabs.length > 1 || (this._tabs.length === 1 && this._hasRootGroups)
? html` <uui-tab-group slot="header">
? html`<uui-tab-group slot="header">
${this._hasRootGroups && this._tabs.length > 0
? html`
<uui-tab
label="Content"
.active=${null === this._activeTabKey}
@click=${() => this.#setTabName(null, null)}
>Content</uui-tab
>
`
? html`<uui-tab
label=${this.localize.term('general_generic')}
.active=${this._activeTabKey === null}
@click=${() => this.#setTabKey(null)}
>Content</uui-tab
>`
: nothing}
${repeat(
this._tabs,
(tab) => tab.name,
(tab) => {
const tabKey = tab.ownerId ?? tab.ids[0];
return html`<uui-tab
label=${tab.name ?? 'Unnamed'}
.active=${tab.key === this._activeTabKey}
@click=${() => this.#setTabName(tab.name, tab.key)}
label=${this.localize.string(tab.name ?? '#general_unnamed')}
.active=${this._activeTabKey === tabKey}
@click=${() => this.#setTabKey(tabKey)}
>${tab.name}</uui-tab
>`;
},
@@ -420,6 +420,8 @@ export abstract class UmbContentDetailWorkspaceContextBase<
const repo = new UmbDataTypeDetailRepository(this);
const propertyTypes = await this.structure.getContentTypeProperties();
const contentTypeVariesByCulture = this.structure.getVariesByCulture();
const contentTypeVariesBySegment = this.structure.getVariesBySegment();
const valueDefinitions = await Promise.all(
propertyTypes.map(async (property) => {
// TODO: Implement caching for data-type requests. [NL]
@@ -438,8 +440,9 @@ export abstract class UmbContentDetailWorkspaceContextBase<
propertyEditorSchemaAlias: dataType.editorAlias,
config: dataType.values,
typeArgs: {
variesByCulture: property.variesByCulture,
variesBySegment: property.variesBySegment,
// Only vary if the content type varies:
variesByCulture: contentTypeVariesByCulture ? property.variesByCulture : false,
variesBySegment: contentTypeVariesBySegment ? property.variesBySegment : false,
} as UmbPropertyTypePresetModelTypeModel,
} as UmbPropertyTypePresetModel;
}),
@@ -108,7 +108,7 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
#onAliasChange() {
// TODO: Why can I not get the correct value via event? Is it an issue in uui library too?
const alias = generateAlias(this._aliasInput.value.toString());
const alias = this._aliasInput.value.toString();
this.updateValue({ alias });
}
@@ -247,6 +247,10 @@ export class UmbAuthFlow {
// clear the internal state
this.#tokenResponse.setValue(undefined);
// Also cleanup any OAuth/PKCE artifacts that may still be in localStorage
// This is a defense-in-depth measure during logout
await this.#authorizationHandler.cleanupStaleAuthorizationData();
}
/**
@@ -361,6 +365,7 @@ export class UmbAuthFlow {
const token = await this.performWithFreshTokens();
const request = new Request(this.#unlink_endpoint, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ loginProvider, providerKey }),
});
@@ -454,6 +459,7 @@ export class UmbAuthFlow {
const token = await this.performWithFreshTokens();
const request = await fetch(`${this.#link_key_endpoint}?provider=${provider}`, {
credentials: 'include',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
@@ -221,6 +221,7 @@ export class UmbInputMultipleTextStringElement extends UmbFormControlMixin<undef
#renderAddButton() {
if (this.disabled || this.readonly) return nothing;
if (this.max === 1 && this._items.length > 0) return nothing;
return html`
<uui-button
color="default"
@@ -1 +1,14 @@
export { client as umbHttpClient } from '@umbraco-cms/backoffice/external/backend-api';
import { client } from '@umbraco-cms/backoffice/external/backend-api';
/**
* Pre-configure the client with default credentials for cookie-based authentication.
* This ensures all requests include cookies by default, which is required for
* cookie-based authentication in Umbraco 17.0+.
*
* Extensions using this client will automatically get credentials: 'include'.
*/
client.setConfig({
credentials: 'include',
});
export { client as umbHttpClient };
@@ -30,6 +30,9 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
@state()
private _colorList = umbracoColors.filter((color) => !color.legacy);
@state()
private _isSearching = false;
constructor() {
super();
this.consumeContext(UMB_ICON_REGISTRY_CONTEXT, (context) => {
@@ -44,8 +47,10 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
if (!this.#icons) return;
const value = this._searchInput?.value;
if (value) {
this._isSearching = value.length > 0;
this._iconsFiltered = this.#icons.filter((icon) => icon.name.toLowerCase().includes(value.toLowerCase()));
} else {
this._isSearching = false;
this._iconsFiltered = this.#icons;
}
}
@@ -54,8 +59,12 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
const isActivate = e.type === 'click' || (e.type === 'keyup' && (e as KeyboardEvent).key === 'Enter');
if (!isActivate) return;
const nextIcon = this.value.icon === iconName ? '' : iconName;
this.modalContext?.updateValue({ icon: nextIcon });
if (this.data?.showEmptyOption) {
const nextIcon = this.value.icon === iconName ? '' : iconName;
this.modalContext?.updateValue({ icon: nextIcon });
} else {
this.modalContext?.updateValue({ icon: iconName });
}
}
#onColorChange(e: UUIColorSwatchesEvent) {
@@ -72,37 +81,23 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
<umb-body-layout headline=${this.localize.term('defaultdialogs_selectIcon')}>
<div id="container">
${this.renderSearch()}
<hr />
<uui-color-swatches
value=${ifDefined(this.value.color)}
label=${this.localize.term('defaultdialogs_colorSwitcher')}
@change=${this.#onColorChange}>
${
// TODO: Missing localization for the color aliases. [NL]
this._colorList.map(
(color) => html`
<uui-color-swatch
label=${color.alias}
title=${color.alias}
value=${color.alias}
style="--uui-swatch-color: var(${color.varName})">
</uui-color-swatch>
`,
)
}
</uui-color-swatches>
<hr />
${this.renderColors()}
<uui-scroll-container id="icons">
<uui-button
class=${!this.value.icon ? 'selected' : ''}
label=${this.localize.term('defaultdialogs_noIcon')}
title=${this.localize.term('defaultdialogs_noIcon')}
@click=${this.#clearIcon}
@keyup=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') this.#clearIcon();
}}>
<uui-icon style="opacity:.35" name=${ifDefined(this.data?.placeholder)}></uui-icon> </uui-button
>${this.renderIcons()}</uui-scroll-container
${this.data?.showEmptyOption && !this._isSearching
? html`
<uui-button
class=${!this.value.icon ? 'selected' : ''}
label=${this.localize.term('defaultdialogs_noIcon')}
title=${this.localize.term('defaultdialogs_noIcon')}
@click=${this.#clearIcon}
@keyup=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') this.#clearIcon();
}}>
<uui-icon style="opacity:.35" name=${ifDefined(this.data?.placeholder)}></uui-icon>
</uui-button>
`
: nothing}
${this.renderIcons()}</uui-scroll-container
>
</div>
<uui-button
@@ -130,9 +125,34 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
${umbFocus()}>
<uui-icon name="search" slot="prepend" id="search_icon"></uui-icon>
</uui-input>
<hr />
`;
}
renderColors() {
return this.data?.hideColors === true
? nothing
: html`<uui-color-swatches
value=${ifDefined(this.value.color)}
label=${this.localize.term('defaultdialogs_colorSwitcher')}
@change=${this.#onColorChange}>
${
this._colorList.map(
(color) => html`
<uui-color-swatch
label=${color.alias}
title=${color.alias}
value=${color.alias}
style="--uui-swatch-color: var(${color.varName})">
</uui-color-swatch>
`,
)
}
</uui-color-swatches>
<hr />
`;
}
renderIcons() {
return this._iconsFiltered
? repeat(
@@ -2,6 +2,8 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
export interface UmbIconPickerModalData {
placeholder?: string;
showEmptyOption?: boolean;
hideColors?: boolean;
}
export interface UmbIconPickerModalValue {
@@ -4,281 +4,335 @@ export const manifests: Array<ManifestLocalization> = [
{
type: 'localization',
alias: 'Umb.Localization.AR',
weight: 100,
name: 'Arabic Backoffice UI Localization',
meta: {
culture: 'ar',
},
meta: { culture: 'ar' },
js: () => import('../../../assets/lang/ar.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.BS',
weight: 100,
name: 'Bosnian Backoffice UI Localization',
meta: {
culture: 'bs',
},
meta: { culture: 'bs' },
js: () => import('../../../assets/lang/bs.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.CS',
weight: 100,
name: 'Czech Backoffice UI Localization',
meta: {
culture: 'cs',
},
meta: { culture: 'cs' },
js: () => import('../../../assets/lang/cs.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.CS_CZ',
name: 'Czech (Czechia) Backoffice UI Localization',
meta: { culture: 'cs-CZ' },
},
{
type: 'localization',
alias: 'Umb.Localization.CY',
weight: 100,
name: 'Welsh Backoffice UI Localization',
meta: {
culture: 'cy',
},
meta: { culture: 'cy' },
js: () => import('../../../assets/lang/cy.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.CY_GB',
name: 'Welsh (UK) Backoffice UI Localization',
meta: { culture: 'cy-GB' },
},
{
type: 'localization',
alias: 'Umb.Localization.DA',
weight: 100,
name: 'Danish Backoffice UI Localization',
meta: {
culture: 'da',
},
meta: { culture: 'da' },
js: () => import('../../../assets/lang/da.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.DA-DK',
name: 'Danish (Denmark) Backoffice UI Localization',
meta: { culture: 'da-DK' },
},
{
type: 'localization',
alias: 'Umb.Localization.DE',
weight: 100,
name: 'German Backoffice UI Localization',
meta: {
culture: 'de',
},
meta: { culture: 'de' },
js: () => import('../../../assets/lang/de.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.DE_DE',
name: 'German (Germany) Backoffice UI Localization',
meta: { culture: 'de-DE' },
},
{
type: 'localization',
alias: 'Umb.Localization.DE_CH',
name: 'German (Switzerland) Backoffice UI Localization',
meta: { culture: 'de-CH' },
},
{
type: 'localization',
alias: 'Umb.Localization.EN',
weight: 100,
name: 'English (United Kingdom) Backoffice UI Localization',
meta: {
culture: 'en',
},
meta: { culture: 'en' },
js: () => import('../../../assets/lang/en.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.EN_US',
weight: 100,
name: 'English (United States) Backoffice UI Localization',
meta: {
culture: 'en-US',
},
meta: { culture: 'en-US' },
js: () => import('../../../assets/lang/en-us.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.ES',
weight: 100,
name: 'Spanish Backoffice UI Localization',
meta: {
culture: 'es',
},
meta: { culture: 'es' },
js: () => import('../../../assets/lang/es.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.ES_ES',
name: 'Spanish (Spain) Backoffice UI Localization',
meta: { culture: 'es-ES' },
},
{
type: 'localization',
alias: 'Umb.Localization.FR',
weight: 100,
name: 'French Backoffice UI Localization',
meta: {
culture: 'fr',
},
meta: { culture: 'fr' },
js: () => import('../../../assets/lang/fr.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.FR_FR',
name: 'French (France) Backoffice UI Localization',
meta: { culture: 'fr-FR' },
},
{
type: 'localization',
alias: 'Umb.Localization.FR_CH',
name: 'French (Switzerland) Backoffice UI Localization',
meta: { culture: 'fr-CH' },
},
{
type: 'localization',
alias: 'Umb.Localization.HE',
weight: 100,
name: 'Hebrew Backoffice UI Localization',
meta: {
culture: 'he',
},
meta: { culture: 'he' },
js: () => import('../../../assets/lang/he.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.HE_IL',
name: 'Hebrew (Israel) Backoffice UI Localization',
meta: { culture: 'he-IL' },
},
{
type: 'localization',
alias: 'Umb.Localization.HR',
weight: 100,
name: 'Croatian Backoffice UI Localization',
meta: {
culture: 'hr',
},
meta: { culture: 'hr' },
js: () => import('../../../assets/lang/hr.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.HR_HR',
name: 'Croatian (Croatia) Backoffice UI Localization',
meta: { culture: 'hr-HR' },
},
{
type: 'localization',
alias: 'Umb.Localization.IT',
weight: 100,
name: 'Italian Backoffice UI Localization',
meta: {
culture: 'it',
},
meta: { culture: 'it' },
js: () => import('../../../assets/lang/it.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.IT_IT',
name: 'Italian (Italy) Backoffice UI Localization',
meta: { culture: 'it-IT' },
},
{
type: 'localization',
alias: 'Umb.Localization.IT_CH',
name: 'Italian (Switzerland) Backoffice UI Localization',
meta: { culture: 'it-CH' },
},
{
type: 'localization',
alias: 'Umb.Localization.JA',
weight: 100,
name: 'Japanese Backoffice UI Localization',
meta: {
culture: 'ja',
},
meta: { culture: 'ja' },
js: () => import('../../../assets/lang/ja.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.JA_JP',
name: 'Japanese (Japan) Backoffice UI Localization',
meta: { culture: 'ja-JP' },
},
{
type: 'localization',
alias: 'Umb.Localization.KO',
weight: 100,
name: 'Korean Backoffice UI Localization',
meta: {
culture: 'ko',
},
meta: { culture: 'ko' },
js: () => import('../../../assets/lang/ko.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.KO_KR',
name: 'Korean (Korea) Backoffice UI Localization',
meta: { culture: 'ko-KR' },
},
{
type: 'localization',
alias: 'Umb.Localization.NB',
weight: 100,
name: 'Norwegian Backoffice UI Localization',
meta: {
culture: 'nb',
},
meta: { culture: 'nb' },
js: () => import('../../../assets/lang/nb.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.NB_NO',
name: 'Norwegian (Norway) Backoffice UI Localization',
meta: { culture: 'nb-NO' },
},
{
type: 'localization',
alias: 'Umb.Localization.NL',
weight: 100,
name: 'Dutch Backoffice UI Localization',
meta: {
culture: 'nl',
},
meta: { culture: 'nl' },
js: () => import('../../../assets/lang/nl.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.NL_NL',
name: 'Dutch (Netherlands) Backoffice UI Localization',
meta: { culture: 'nl-NL' },
},
{
type: 'localization',
alias: 'Umb.Localization.PL',
weight: 100,
name: 'Polish Backoffice UI Localization',
meta: {
culture: 'pl',
},
meta: { culture: 'pl' },
js: () => import('../../../assets/lang/pl.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.PL_PL',
name: 'Polish (Poland) Backoffice UI Localization',
meta: { culture: 'pl-PL' },
},
{
type: 'localization',
alias: 'Umb.Localization.PT',
weight: 100,
name: 'Portuguese Backoffice UI Localization',
meta: {
culture: 'pt',
},
meta: { culture: 'pt' },
js: () => import('../../../assets/lang/pt.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.PT_BR',
weight: 100,
name: 'Portuguese (Brazil) Backoffice UI Localization',
meta: {
culture: 'pt-BR',
},
meta: { culture: 'pt-BR' },
js: () => import('../../../assets/lang/pt-br.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.RO',
weight: 100,
name: 'Romanian Backoffice UI Localization',
meta: {
culture: 'ro',
},
meta: { culture: 'ro' },
js: () => import('../../../assets/lang/ro.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.RO_RO',
name: 'Romanian (Romania) Backoffice UI Localization',
meta: { culture: 'ro-RO' },
},
{
type: 'localization',
alias: 'Umb.Localization.RU',
weight: 100,
name: 'Russian Backoffice UI Localization',
meta: {
culture: 'ru',
},
meta: { culture: 'ru' },
js: () => import('../../../assets/lang/ru.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.RU_RU',
name: 'Russian (Russia) Backoffice UI Localization',
meta: { culture: 'ru-RU' },
},
{
type: 'localization',
alias: 'Umb.Localization.SV',
weight: 100,
name: 'Swedish Backoffice UI Localization',
meta: {
culture: 'sv',
},
meta: { culture: 'sv' },
js: () => import('../../../assets/lang/sv.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.SV_SE',
name: 'Swedish (Sweden) Backoffice UI Localization',
meta: { culture: 'sv-SE' },
},
{
type: 'localization',
alias: 'Umb.Localization.TR',
weight: 100,
name: 'Turkish Backoffice UI Localization',
meta: {
culture: 'tr',
},
meta: { culture: 'tr' },
js: () => import('../../../assets/lang/tr.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.TR_TR',
name: 'Turkish (Türkiye) Backoffice UI Localization',
meta: { culture: 'tr-TR' },
},
{
type: 'localization',
alias: 'Umb.Localization.UK',
weight: 100,
name: 'Ukrainian Backoffice UI Localization',
meta: {
culture: 'uk',
},
meta: { culture: 'uk' },
js: () => import('../../../assets/lang/uk.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.UK_UA',
name: 'Ukrainian (Ukraine) Backoffice UI Localization',
meta: { culture: 'uk-UA' },
},
{
type: 'localization',
alias: 'Umb.Localization.ZH',
weight: 100,
name: 'Chinese Backoffice UI Localization',
meta: {
culture: 'zh',
},
meta: { culture: 'zh' },
js: () => import('../../../assets/lang/zh.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.ZH_CN',
name: 'Chinese (Simplified, China) Backoffice UI Localization',
meta: { culture: 'zh-CN' },
},
{
type: 'localization',
alias: 'Umb.Localization.ZH_TW',
weight: 100,
name: 'Chinese (Taiwan) Backoffice UI Localization',
meta: {
culture: 'zh-TW',
},
meta: { culture: 'zh-TW' },
js: () => import('../../../assets/lang/zh-tw.js'),
},
{
type: 'localization',
alias: 'Umb.Localization.VI',
weight: 100,
name: 'Vietnamese Backoffice UI Localization',
meta: {
culture: 'vi',
},
meta: { culture: 'vi' },
js: () => import('../../../assets/lang/vi.js'),
},
];
@@ -10,7 +10,7 @@
"@types/diff": "^7.0.2",
"diff": "^7.0.0",
"uuid": "^13.0.0",
"@hey-api/openapi-ts": "0.85.0"
"@hey-api/openapi-ts": "^0.85.0"
},
"devDependencies": {
"lucide-static": "^0.544.0",
@@ -44,6 +44,7 @@ function createXhrRequest<T>(options: XhrRequestOptions): UmbCancelablePromise<T
return new UmbCancelablePromise<T>(async (resolve, reject, onCancel) => {
const xhr = new XMLHttpRequest();
xhr.open(options.method, `${baseUrl}${options.url}`, true);
xhr.withCredentials = options.withCredentials ?? true;
// Set default headers
if (options.token) {
@@ -7,6 +7,7 @@ export interface XhrRequestOptions extends UmbTryExecuteOptions {
baseUrl?: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
url: string;
withCredentials?: boolean;
body?: unknown;
token?: string | (() => undefined | string | Promise<string | undefined>);
headers?: Record<string, string>;
@@ -12,4 +12,5 @@ export interface ManifestSection
export interface MetaSection {
label: string;
pathname: string;
preventUrlRetention?: boolean;
}
@@ -214,7 +214,7 @@ export class UmbInputDocumentElement extends UmbFormControlMixin<string | undefi
}
#renderAddButton() {
if (this.selection.length >= this.max) return nothing;
if (this.selection.length > 0 && this.max === 1) return nothing;
if (this.readonly && this.selection.length > 0) {
return nothing;
} else {
@@ -22,6 +22,7 @@ export class UmbDocumentPermissionServerDataSource {
this.#host,
fetch(`/umbraco/management/api/v1/document/${id}/permissions`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
@@ -407,6 +407,7 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin<
#renderAddButton() {
if (this.readonly) return nothing;
if (this.max === 1 && this._cards.length > 0) return nothing;
return html`
<uui-button
id="btn-add"
@@ -468,6 +469,8 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin<
css`
:host {
position: relative;
display: block;
width: 100%;
}
.container {
display: grid;
@@ -1,4 +1,4 @@
import { getMimeTypeFromExtension } from '../../components/index.js';
import { getMimeTypeFromExtension } from './utils.js';
import type { ManifestFileUploadPreview } from './file-upload-preview.extension.js';
import type { UmbFileUploadPreviewElement as UmbFileUploadPreviewElementInterface } from './file-upload-preview.interface.js';
import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
@@ -16,9 +16,12 @@ export default class UmbInputUploadFieldSvgElement extends UmbLitElement impleme
static override readonly styles = [
css`
:host {
height: 100%;
min-height: 240px;
max-height: 400px;
width: fit-content;
min-width: 240px;
max-width: 100%;
}
@@ -30,6 +30,8 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
return this._progressItems;
}
#dragCounter = 0;
protected override _manager = new UmbMediaDropzoneManager(this);
public progressItems = () => this._manager.progressItems;
public progress = () => this._manager.progress;
@@ -39,6 +41,7 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
document.addEventListener('dragenter', this.#handleDragEnter.bind(this));
document.addEventListener('dragleave', this.#handleDragLeave.bind(this));
document.addEventListener('dragover', this.#handleDragOver.bind(this));
document.addEventListener('drop', this.#handleDrop.bind(this));
// TODO: Revisit this. I am not sure why it is needed to call these methods here when they are already called in the constructor of the parent class.
@@ -65,6 +68,7 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
super.disconnectedCallback();
document.removeEventListener('dragenter', this.#handleDragEnter.bind(this));
document.removeEventListener('dragleave', this.#handleDragLeave.bind(this));
document.removeEventListener('dragover', this.#handleDragOver.bind(this));
document.removeEventListener('drop', this.#handleDrop.bind(this));
}
@@ -78,21 +82,35 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
#handleDragEnter(e: DragEvent) {
if (this.disabled) return;
// Avoid collision with UmbSorterController
const types = e.dataTransfer?.types;
if (!types?.length || !types?.includes('Files')) return;
// Normalize types for Safari
const types = Array.from(e.dataTransfer?.types || []).map((t) => t.toLowerCase());
if (!types.includes('files')) return;
this.#dragCounter++;
this.toggleAttribute('dragging', true);
e.preventDefault();
}
#handleDragOver(e: DragEvent) {
e.preventDefault();
}
#handleDragLeave() {
if (this.disabled) return;
this.toggleAttribute('dragging', false);
this.#dragCounter--;
if (this.#dragCounter <= 0) {
this.toggleAttribute('dragging', false);
this.#dragCounter = 0;
}
}
#handleDrop(event: DragEvent) {
event.preventDefault();
if (this.disabled) return;
this.#dragCounter = 0;
this.toggleAttribute('dragging', false);
}
@@ -15,6 +15,8 @@ import { UMB_MODAL_MANAGER_CONTEXT, UmbModalBaseElement } from '@umbraco-cms/bac
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace';
import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal';
import '../../components/input-upload-field/file-upload-preview.element.js';
@customElement('umb-image-cropper-editor-modal')
export class UmbImageCropperEditorModalElement extends UmbModalBaseElement<
UmbImageCropperEditorModalData<any>,
@@ -1,8 +1,9 @@
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { css, html, customElement, state, query, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
import { css, html, customElement, state, query, when } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { ProfilingService } from '@umbraco-cms/backoffice/external/backend-api';
import { tryExecute } from '@umbraco-cms/backoffice/resources';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
@customElement('umb-dashboard-performance-profiling')
export class UmbDashboardPerformanceProfilingElement extends UmbLitElement {
@@ -13,55 +14,114 @@ export class UmbDashboardPerformanceProfilingElement extends UmbLitElement {
@state()
private _isDebugMode = true;
@state()
private _isLoading = true;
@query('#toggle')
private _toggle!: HTMLInputElement;
private _notificationContext: typeof UMB_NOTIFICATION_CONTEXT.TYPE | undefined;
constructor() {
super();
this.consumeContext(UMB_NOTIFICATION_CONTEXT, (notificationContext) => {
this._notificationContext = notificationContext;
});
}
#setToggle(value: boolean) {
this._toggle.checked = value;
this._profilingStatus = value;
this._isLoading = false;
}
override firstUpdated() {
this._getProfilingStatus();
override async firstUpdated() {
const status = await this.#getProfilingStatus();
this.#setToggle(status);
}
private async _getProfilingStatus() {
async #getProfilingStatus() {
const { data } = await tryExecute(this, ProfilingService.getProfilingStatus());
if (!data) return;
this._profilingStatus = data.enabled ?? false;
return data?.enabled ?? false;
}
private async _changeProfilingStatus() {
const { error } = await tryExecute(
this,
ProfilingService.putProfilingStatus({ body: { enabled: !this._profilingStatus } }),
);
async #disableProfilingStatus() {
this._isLoading = true;
const { error } = await tryExecute(this, ProfilingService.putProfilingStatus({ body: { enabled: false } }));
if (error) {
this.#setToggle(this._profilingStatus);
} else {
this.#setToggle(!this._profilingStatus);
this.#setToggle(true);
return;
}
// Test that it was actually disabled
const status = await this.#getProfilingStatus();
if (status) {
this.#setToggle(true);
this._notificationContext?.peek('warning', {
data: {
headline: this.localize.term('profiling_errorDisablingProfilerTitle'),
message: this.localize.term('profiling_errorDisablingProfilerDescription'),
},
});
return;
}
this.#setToggle(false);
}
async #enableProfilingStatus() {
this._isLoading = true;
const { error } = await tryExecute(this, ProfilingService.putProfilingStatus({ body: { enabled: true } }));
if (error) {
this.#setToggle(false);
return;
}
// Test that it was actually enabled
const status = await this.#getProfilingStatus();
if (!status) {
this.#setToggle(false);
this._notificationContext?.peek('warning', {
data: {
headline: this.localize.term('profiling_errorEnablingProfilerTitle'),
message: this.localize.term('profiling_errorEnablingProfilerDescription'),
},
});
return;
}
this.#setToggle(true);
}
#renderProfilingStatus() {
return this._isDebugMode
? html`
${unsafeHTML(this.localize.term('profiling_performanceProfilingDescription'))}
<umb-localize key="profiling_performanceProfilingDescription"></umb-localize>
<uui-toggle
id="toggle"
label=${this.localize.term('profiling_activateByDefault')}
label-position="left"
?checked="${this._profilingStatus}"
@change="${this._changeProfilingStatus}"></uui-toggle>
?disabled="${this._isLoading}"
@change="${() =>
this._profilingStatus ? this.#disableProfilingStatus() : this.#enableProfilingStatus()}"></uui-toggle>
<h4>${this.localize.term('profiling_reminder')}</h4>
${when(this._isLoading, () => html`<uui-loader-circle></uui-loader-circle>`)}
${unsafeHTML(this.localize.term('profiling_reminderDescription'))}
<h4>
<umb-localize key="profiling_reminder"></umb-localize>
</h4>
<umb-localize key="profiling_reminderDescription"></umb-localize>
`
: html` ${unsafeHTML(this.localize.term('profiling_profilerEnabledDescription'))} `;
: html`<umb-localize key="profiling_profilerEnabledDescription"></umb-localize>`;
}
override render() {
@@ -16,6 +16,12 @@ export const manifests: Array<UmbExtensionManifest> = [
description: 'Icon name to show when no icon is selected',
propertyEditorUiAlias: 'Umb.PropertyEditorUi.IconPicker',
},
{
alias: 'hideColors',
label: 'Hide colors',
description: 'Hide color swatches from modal',
propertyEditorUiAlias: 'Umb.PropertyEditorUi.Toggle',
},
],
},
},
@@ -9,28 +9,45 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { extractUmbColorVariable } from '@umbraco-cms/backoffice/resources';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
/**
* @element umb-property-editor-ui-icon-picker
*/
@customElement('umb-property-editor-ui-icon-picker')
export class UmbPropertyEditorUIIconPickerElement extends UmbLitElement implements UmbPropertyEditorUiElement {
//
export class UmbPropertyEditorUIIconPickerElement
extends UmbFormControlMixin<string, typeof UmbLitElement, undefined>(UmbLitElement, undefined)
implements UmbPropertyEditorUiElement
{
@property({ type: Boolean })
mandatory = false;
protected override firstUpdated(): void {
this.addValidator(
'valueMissing',
() => 'Icon is required',
() => this.mandatory && !this._icon,
);
}
@property()
public set value(v: string) {
this._value = v ?? '';
const parts = this._value.split(' ');
public override set value(v: string) {
const val = v ?? '';
super.value = val;
const parts = val.split(' ');
if (parts.length === 2) {
this._icon = parts[0];
this._color = parts[1].replace('color-', '');
} else {
this._icon = this._value;
this._icon = val;
this._color = '';
}
}
public get value() {
return this._value;
public override get value() {
return (super.value as string) ?? '';
}
private _value = '';
@state()
private _icon = '';
@@ -41,10 +58,14 @@ export class UmbPropertyEditorUIIconPickerElement extends UmbLitElement implemen
@state()
private _placeholderIcon = '';
@state()
private _hideColors = false;
public set config(config: UmbPropertyEditorConfigCollection | undefined) {
if (!config) return;
const placeholder = config.getValueByAlias('placeholder');
this._placeholderIcon = typeof placeholder === 'string' ? placeholder : '';
this._hideColors = config.getValueByAlias('hideColors') as boolean;
}
private async _openModal() {
@@ -53,7 +74,7 @@ export class UmbPropertyEditorUIIconPickerElement extends UmbLitElement implemen
icon: this._icon,
color: this._color,
},
data: { placeholder: this._placeholderIcon },
data: { placeholder: this._placeholderIcon, showEmptyOption: !this.mandatory, hideColors: this._hideColors },
}).catch(() => undefined);
if (!data) return;
@@ -1,6 +1,6 @@
import type { UmbPropertyEditorRteValueType } from '../types.js';
import { UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants.js';
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
import { jsonStringComparison, observeMultiple } from '@umbraco-cms/backoffice/observable-api';
import { property, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbBlockRteEntriesContext, UmbBlockRteManagerContext } from '@umbraco-cms/backoffice/block-rte';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
@@ -243,9 +243,12 @@ export abstract class UmbPropertyEditorUiRteElementBase
([layouts, contents, settings, exposes]) => {
if (layouts.length === 0) {
if (super.value?.markup === undefined) {
if (this.value === undefined) {
return;
}
super.value = undefined;
} else {
super.value = {
const newValue = {
...super.value,
blocks: {
layout: {},
@@ -254,9 +257,13 @@ export abstract class UmbPropertyEditorUiRteElementBase
expose: [],
},
};
if (jsonStringComparison(this.value, newValue)) {
return;
}
super.value = newValue;
}
} else {
super.value = {
const newValue = {
markup: this._markup,
blocks: {
layout: { [UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]: layouts },
@@ -265,6 +272,10 @@ export abstract class UmbPropertyEditorUiRteElementBase
expose: exposes,
},
};
if (jsonStringComparison(this.value, newValue)) {
return;
}
super.value = newValue;
}
// If we don't have a value set from the outside or an internal value, we don't want to set the value.
@@ -0,0 +1,86 @@
import { expect } from '@open-wc/testing';
import { UmbMarked } from './ufm.context.js';
describe('UmbMarked sanitization', () => {
describe('XSS prevention on custom elements', () => {
it('should strip onclick from custom elements', async () => {
const markup = await UmbMarked.parseInline('<uui-button onclick="alert(1)">Click</uui-button>');
expect(markup).to.not.include('onclick');
expect(markup).to.include('<uui-button>');
});
it('should strip onload from custom elements', async () => {
const markup = await UmbMarked.parseInline('<umb-test onload="alert(1)">Test</umb-test>');
expect(markup).to.not.include('onload');
expect(markup).to.include('<umb-test>');
});
it('should strip onmouseover from custom elements', async () => {
const markup = await UmbMarked.parseInline('<uui-box onmouseover="alert(1)">Hover</uui-box>');
expect(markup).to.not.include('onmouseover');
expect(markup).to.include('<uui-box>');
});
it('should strip onfocus from custom elements', async () => {
const markup = await UmbMarked.parseInline('<ufm-label-value onfocus="alert(1)" alias="test"></ufm-label-value>');
expect(markup).to.not.include('onfocus');
expect(markup).to.include('alias="test"');
});
it('should strip onerror from custom elements', async () => {
const markup = await UmbMarked.parseInline('<umb-test onerror="alert(1)">Test</umb-test>');
expect(markup).to.not.include('onerror');
});
});
describe('safe attributes on custom elements', () => {
it('should preserve class attribute', async () => {
const markup = await UmbMarked.parseInline('<uui-button class="primary">Click</uui-button>');
expect(markup).to.include('class="primary"');
});
it('should preserve alias attribute', async () => {
const markup = await UmbMarked.parseInline('<ufm-label-value alias="prop1"></ufm-label-value>');
expect(markup).to.include('alias="prop1"');
});
it('should preserve look attribute', async () => {
const markup = await UmbMarked.parseInline('<uui-button look="primary">Click</uui-button>');
expect(markup).to.include('look="primary"');
});
it('should preserve slot attribute', async () => {
const markup = await UmbMarked.parseInline('<umb-test slot="header">Test</umb-test>');
expect(markup).to.include('slot="header"');
});
it('should preserve label attribute', async () => {
const markup = await UmbMarked.parseInline('<uui-button label="Submit">Click</uui-button>');
expect(markup).to.include('label="Submit"');
});
});
describe('standard HTML sanitization', () => {
it('should strip script tags', async () => {
const markup = await UmbMarked.parseInline('<script>alert(1)</script>');
expect(markup).to.not.include('<script');
});
it('should allow standard markdown bold', async () => {
const markup = await UmbMarked.parseInline('**bold text**');
expect(markup).to.include('<strong>bold text</strong>');
});
it('should allow standard markdown links', async () => {
const markup = await UmbMarked.parseInline('[link](https://example.com)');
expect(markup).to.include('<a');
expect(markup).to.include('href="https://example.com"');
});
it('should strip non-allowed custom elements', async () => {
const markup = await UmbMarked.parseInline('<custom-evil onclick="alert(1)">Test</custom-evil>');
expect(markup).to.not.include('onclick');
expect(markup).to.not.include('<custom-evil');
});
});
});
@@ -15,7 +15,7 @@ const UmbDomPurifyConfig: DOMPurifyConfig = {
USE_PROFILES: { html: true },
CUSTOM_ELEMENT_HANDLING: {
tagNameCheck: /^(?:ufm|umb|uui)-.*$/,
attributeNameCheck: /.+/,
attributeNameCheck: /^(?!on)/,
allowCustomizedBuiltInElements: false,
},
};
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -14,11 +14,11 @@
"npm": ">=10.9"
},
"devDependencies": {
"@hey-api/openapi-ts": "0.85.0",
"@umbraco-cms/backoffice": "16.2.0",
"@hey-api/openapi-ts": "^0.85.0",
"@umbraco-cms/backoffice": "^16.2.0",
"msw": "^2.11.3",
"typescript": "^5.9.3",
"vite": "^7.1.9"
"vite": "^7.1.11"
},
"msw": {
"workerDirectory": [
@@ -9,7 +9,7 @@
"generate-client": "node scripts/generate-openapi.js https://localhost:44339/umbraco/swagger/umbracoextension/swagger.json"
},
"devDependencies": {
"@hey-api/openapi-ts": "0.85.0",
"@hey-api/openapi-ts": "^0.85.0",
"@umbraco-cms/backoffice": "^UMBRACO_VERSION_FROM_TEMPLATE",
"chalk": "^5.6.2",
"cross-env": "^10.1.0",
+4 -4
View File
@@ -8,7 +8,7 @@
"hasInstallScript": true,
"dependencies": {
"@umbraco/json-models-builders": "^2.0.40",
"@umbraco/playwright-testhelpers": "^16.0.55",
"@umbraco/playwright-testhelpers": "^16.0.60",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"node-fetch": "^2.6.7"
@@ -67,9 +67,9 @@
}
},
"node_modules/@umbraco/playwright-testhelpers": {
"version": "16.0.55",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.55.tgz",
"integrity": "sha512-715l112FHB7snWq0sY7e0fUD2ppWSSysBKHFhcQkGGw+3Gbo68Z6iXfeAketzKohWji19un4KC3mvZU0IICr9g==",
"version": "16.0.60",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.60.tgz",
"integrity": "sha512-/6CS4YtsNN3vtahaG35xj5BNYlJMcIPKdiUBrmilWCYAFzxIT9u6EegWBtua46bQxcdeqQgUvIAghsNvDPEk1A==",
"license": "MIT",
"dependencies": {
"@umbraco/json-models-builders": "2.0.40",
@@ -22,7 +22,7 @@
},
"dependencies": {
"@umbraco/json-models-builders": "^2.0.40",
"@umbraco/playwright-testhelpers": "^16.0.55",
"@umbraco/playwright-testhelpers": "^16.0.60",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"node-fetch": "^2.6.7"
@@ -417,3 +417,22 @@ test('can add a variant block element with invariant RTE Tiptap in the content',
await umbracoApi.documentType.ensureNameNotExists(customElementTypeName);
await umbracoApi.language.ensureNameNotExists('Danish');
});
// Tests regression issue: https://github.com/umbraco/Umbraco-CMS/issues/20680
test('can move away from a content node with a block grid after making no changes without seeing discard unsaved changes', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => {
// Arrange
const customDataTypeId = await umbracoApi.dataType.createBlockGridWithPermissions(customDataTypeName, elementTypeId, true, true);
const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId);
await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.goToContentWithName(contentName);
// Act
await umbracoUi.documentType.goToSection(ConstantHelper.sections.settings);
// Assert
// We do this to make sure that there is no discard changes button visible, if the discard changes was visible, we would not be able to go to the document type
await umbracoUi.documentType.goToDocumentType(documentTypeName);
});
@@ -364,3 +364,21 @@ test('can add a variant block element with invariant RTE Tiptap in the content',
await umbracoApi.documentType.ensureNameNotExists(customElementTypeName);
await umbracoApi.language.ensureNameNotExists('Danish');
});
// Tests regression issue: https://github.com/umbraco/Umbraco-CMS/issues/20680
test('can move away from a content node with a block list after making no changes without seeing discard unsaved changes', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => {
// Arrange
const customDataTypeId = await umbracoApi.dataType.createBlockListDataTypeWithABlock(customDataTypeName, elementTypeId);
const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId);
await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
await umbracoUi.content.goToContentWithName(contentName);
// Act
await umbracoUi.documentType.goToSection(ConstantHelper.sections.settings);
// Assert
// We do this to make sure that there is no discard changes button visible, if the discard changes was visible, we would not be able to go to the document type
await umbracoUi.documentType.goToDocumentType(documentTypeName);
});
@@ -106,7 +106,8 @@ test('can copy and paste a single block into the same document but different gro
await umbracoUi.content.doesBlockEditorBlockWithNameContainValue(elementGroupName, elementPropertyName, ConstantHelper.inputTypes.tipTap, blockPropertyValue);
});
test('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
// Remove skip after this issue is resolved: https://github.com/umbraco/Umbraco-CMS/issues/20680
test.skip('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
// Arrange
await umbracoApi.document.ensureNameNotExists(secondContentName);
await umbracoApi.document.createDefaultDocumentWithABlockGridEditorAndBlockWithValue(contentName, documentTypeName, blockGridDataTypeName, elementTypeId, AliasHelper.toAlias(elementPropertyName), blockPropertyValue, richTextDataTypeUiAlias);
@@ -106,7 +106,8 @@ test('can copy and paste a single block into the same document but different gro
await umbracoUi.content.doesBlockEditorBlockWithNameContainValue(elementGroupName, elementPropertyName, ConstantHelper.inputTypes.tipTap, blockPropertyValue);
});
test('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
// Remove skip after this issue is resolved: https://github.com/umbraco/Umbraco-CMS/issues/20680
test.skip('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
// Arrange
await umbracoApi.document.ensureNameNotExists(secondContentName);
await umbracoApi.document.createDefaultDocumentWithABlockListEditorAndBlockWithValue(contentName, documentTypeName, blockListDataTypeName, elementTypeId, AliasHelper.toAlias(elementPropertyName), blockPropertyValue, elementDataTypeUiAlias, groupName);
@@ -234,8 +234,8 @@ test('can duplicate a content node to other parent', async ({umbracoApi, umbraco
await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.duplicated);
await umbracoUi.content.isContentInTreeVisible(contentName);
await umbracoUi.content.isContentInTreeVisible(parentContentName);
await umbracoUi.content.openContentCaretButtonForName(parentContentName);
await umbracoUi.content.isChildContentInTreeVisible(parentContentName, contentName);
await umbracoUi.content.goToContentWithName(parentContentName);
await umbracoUi.content.isContentWithNameVisibleInList(contentName);
// Clean
await umbracoApi.document.ensureNameNotExists(parentContentName);
@@ -58,7 +58,7 @@ test('can publish content with the true/false data type', async ({umbracoApi, um
expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy();
const contentData = await umbracoApi.document.getByName(contentName);
expect(contentData.variants[0].state).toBe(expectedState);
expect(contentData.values).toEqual([]);
expect(contentData.values[0].value).toEqual(false);
});
test('can toggle the true/false value in the content', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => {
@@ -51,7 +51,7 @@ test('can create content using an invariant document blueprint', async ({umbraco
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateActionMenuOption();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
await umbracoUi.content.clickSaveButtonForContent();
// Assert
@@ -75,7 +75,7 @@ test('can create content using a variant document blueprint', async ({umbracoApi
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateActionMenuOption();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
await umbracoUi.content.clickSaveButtonForContent();
await umbracoUi.content.clickSaveButton();
@@ -104,7 +104,7 @@ test('can create content with different name using an invariant document bluepri
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateActionMenuOption();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
await umbracoUi.content.enterContentName(contentName);
await umbracoUi.content.clickSaveButtonForContent();
@@ -130,7 +130,7 @@ test('can create content with different name using a variant document blueprint'
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateActionMenuOption();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
await umbracoUi.content.enterContentName(contentName);
await umbracoUi.content.clickSaveButtonForContent();
await umbracoUi.content.clickSaveButton();
@@ -161,7 +161,7 @@ test('can create content using a document blueprint with block list', async ({um
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateActionMenuOption();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
await umbracoUi.content.clickSaveButtonForContent();
// Assert
@@ -187,7 +187,7 @@ test('can create content using a document blueprint with block grid', async ({um
await umbracoUi.content.clickActionsMenuAtRoot();
await umbracoUi.content.clickCreateActionMenuOption();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
await umbracoUi.content.clickSaveButtonForContent();
// Assert
@@ -197,4 +197,4 @@ test('can create content using a document blueprint with block grid', async ({um
expect(contentData.values[0].value.contentData[0].values[0].value.markup).toEqual(textContent);
const blockListValue = contentData.values.find(item => item.editorAlias === "Umbraco.BlockGrid")?.value;
expect(blockListValue).toBeTruthy();
});
});
@@ -275,7 +275,7 @@ test('max can not be less than min in a block grid editor', async ({umbracoApi,
// Assert
await umbracoUi.dataType.isFailedStateButtonVisible();
await umbracoUi.dataType.doesAmountContainErrorMessageWithText('The low value must not be exceed the high value');
await umbracoUi.dataType.doesAmountContainErrorMessageWithText('The low value must not exceed the high value.');
const dataTypeData = await umbracoApi.dataType.getByName(blockGridEditorName);
expect(dataTypeData.values[0].value.min).toBe(minAmount);
// The max value should not be updated

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