Compare commits

...
89 Commits
Author SHA1 Message Date
1f5c21c631 Fix pagination in Content Delivery API Index Helper (#19606)
* Refactor descendant enumeration in DeliveryApiContentIndexHelper

Improved loop condition to allow for processing of more than 10.000 descendants for indexing.

* Add failing test for original issue.

* Renamed variable for clarity.

---------

Co-authored-by: Brynjar Þorsteinsson <brynjar@vettvangur.is>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-07-01 08:11:00 +02:00
4ad18dc963 Preserve word boundaries when indexing RTE content with <br> tags (#19540)
* Preserve word boundaries when indexing RTE content with <br> tags

Replace <br> tags with spaces before HTML stripping to prevent word
concatenation in Examine index. Fixes issue where "John Smith<br>Company ABC"
was indexed as "John SmithCompany ABC" instead of "John Smith Company ABC".

- Add regex to replace <br> variants with spaces in RichTextPropertyIndexValueFactory
- Handles <br>, <br/> with spaces and attributes
- Maintains existing StripHtml() behavior for all other HTML tags

* Added unit test with test cases for expected index values

* Regex tweak to avoid matches on <break> <branything> etc

* Tweaked tests as per PR feedback

* Update tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/RichTextPropertyIndexValueFactoryTests.cs

Updated tests

Co-authored-by: Kenn Jacobsen <post@kennjacobsen.dk>

* Tweaked test expectations

---------

Co-authored-by: Emma Garland <emma.garland@rocksolidknowledge.com>
Co-authored-by: Emma L Garland <emmagarland77@gmail.com>
Co-authored-by: Kenn Jacobsen <post@kennjacobsen.dk>
2025-06-23 09:34:51 +00:00
Andy Butland 881535af7d Merge branch 'v13/dev' into v13/main 2025-06-13 07:20:11 +02:00
Laura NetoandGitHub 1e66fb6ab3 Add a warning to the CreateContentFromBlueprint method xml docs (#19542)
Add a remark to `ContentService.CreateContentFromBlueprint`
2025-06-13 07:15:19 +02:00
Andy Butland e1b9e5efad Merge branch 'release/13.9.1' into v13/dev
# Conflicts:
#	version.json
2025-06-10 09:56:08 +02:00
Andy ButlandandGitHub 28570b43e8 Fix issues with removal of user logins on change to external login provider configuration (13) (#19511)
* Ensure to delete related tokens when removing logins for removed external login providers.
Ensure to avoid removing logins for members.

* Applied suggestions from code review.

* Removed unnecessary <= check.
2025-06-10 08:19:52 +02:00
Andy Butland a01382d756 Merge branch 'release/13.9' into v13/dev 2025-06-05 10:00:12 +02:00
Andy Butland 1d6e7f15b9 Bumped version to 13.9.1. 2025-06-05 07:47:57 +02:00
Andy Butland 97cc3ca581 Bumped version to 13.9.0. 2025-06-05 06:24:08 +02:00
Sven GeusensandGitHub 0bcae3e87b Fix null member edgecase (#19463) 2025-06-03 03:32:03 +00:00
ebd228c3d7 Ensure tag operations are case insensitive on insert across database types (#19439)
* Ensure tag operations are case insensitve on insert across database types.

* Ensure tags provided in a single property are case insensitively distinct when saving the tags and relationships.

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

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

* Handle case sensitivity on insert with tag groups too.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-30 09:05:22 +02:00
Andy ButlandandGitHub 4b83a74bdb Resolved exception thrown from NuCache locking mechanism on near-simultaneous content publish requests (#19434)
* Removed the check before the wait that threw the recursive lock exception.
Added additional check to ensure we don't release a lock that's already released.

* Removed unnecessary check on releasing the lock.
2025-05-28 14:33:47 +02:00
Rowan BottemaandGitHub b348b84b63 Lock appropriate tree for media operations (#19422)
The MediaService currently locks the ContentTree for GetPagedOfType(s) operations, but it's querying the MediaTree. This ensures we lock the correct tree.
2025-05-27 06:51:30 +02:00
Andy Butland 7f4a8d5974 Merge branch 'v13/dev' into v13/main 2025-05-27 06:49:05 +02:00
Andy ButlandandGitHub 4d8ca457ec Removes unnecessary newlines from rich text as JSON delivery API output (#19391)
* Removes unnecessary newlines from rich text as JSON delivery API output.

* Fix case from PR feedback.
2025-05-23 12:19:43 +02:00
Andy ButlandandGitHub d677e948f1 Fix issue with requests to delivery API by path where URL segment contains special characters (#19390)
Fix issue with requests to delivery API by path where URL segment contains special characters.
2025-05-23 09:41:15 +02:00
Andy Butland d4e6af50bd Merge branch 'release/13.9' into v13/dev 2025-05-22 10:15:39 +02:00
Andy Butland c609cafa9b Fixed check for navigation to list after delete of member (#19364)
Fixed check for navigation to list after delete of member.
2025-05-22 10:14:52 +02:00
Andy ButlandandGitHub 5556b0fe0c Fixed check for navigation to list after delete of member (#19364)
Fixed check for navigation to list after delete of member.
2025-05-22 10:13:31 +02:00
Andy Butland eee04f4703 fix: check for NullRepresentationInCache in AppCacheExtensions (#19350)
* fix: add appcache null check

* Moved constant into standard location.
Removed now unnecessary comment.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
# Conflicts:
#	src/Umbraco.Core/Cache/AppCacheExtensions.cs
#	src/Umbraco.Core/Constants-Cache.cs
2025-05-22 08:58:37 +02:00
127c6c4b53 Optimize the member save as part of the member login process, by-passing locking and audit steps and handling only the expected update properties (#19308)
* Optimize the member save as part of the member login process, by-passing locking and audit steps and handling only the expected update properties.

* Added unit test to verify new behaviour.

* Update src/Umbraco.Infrastructure/Security/MemberUserStore.cs

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

* Updates from code review.

* Improved comments.

* Add state information to notification indicating whether a member is saved via only the update of login properties.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Migaroez <geusens@gmail.com>
2025-05-20 15:27:42 +01:00
7d6a1e54e6 Optimize the member save as part of the member login process, by-passing locking and audit steps and handling only the expected update properties (#19308)
* Optimize the member save as part of the member login process, by-passing locking and audit steps and handling only the expected update properties.

* Added unit test to verify new behaviour.

* Update src/Umbraco.Infrastructure/Security/MemberUserStore.cs

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

* Updates from code review.

* Improved comments.

* Add state information to notification indicating whether a member is saved via only the update of login properties.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Migaroez <geusens@gmail.com>
2025-05-20 13:21:23 +00:00
David ChallenerandGitHub 8433b2b637 User invite email fails if visited more than once without completing (#17901)
Don't change securitystamp until user created
2025-05-14 10:03:53 +01:00
87b2153794 Adds support for retrieval of data type references when data type is routed using a GUID (#19184)
* Adds support for retrieval of data type references when data type is routed using a GUID.

* Fixed typos in comments.

* Use IIdKeyMap to resolve ID instead of fetching datatype

* Use IDKeyMap instead

---------

Co-authored-by: mole <nikolajlauridsen@protonmail.ch>
2025-05-14 10:54:00 +02:00
Andy ButlandandGitHub 0fc5b2a95c Avoids unnecessary additional domain save notification publishing when sorting an already sorted collection of domains (#19106)
Avoids unnecessary additional domain save notification publishings when sorting an already sorted collection of domains.
2025-05-14 10:25:49 +02:00
Andreas ZerbstandGitHub 6c2f93e85d V13 QA Updated linux test images to match actual (#19309)
Updated linux images
2025-05-14 07:19:32 +00:00
4f1604fea1 V13/bugfix/partial cache (#19314)
* Make sure that each optional section of the cachekey always starts and ends with a -

* Move secondary logic of clearing the membercaches into its own replacable class

* Regsiter the new implementation

* Add a mock to the integration tests as appCaches are disabled

* Added header comments to components.

* Refactored cache key into a method and exposed for testing.
Added unit tests to verify behaviour.

* Verified also that regex matches only the supplied member and asserted on the key itself.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-05-14 08:46:07 +02:00
059766291b Fix for block grid styling regression issue #15973 (#19014)
Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-05-13 06:36:20 +02:00
e471c1fc8b [V13] User notifications not sent correctly when having more than 400 users (#18370)
Co-authored-by: Vedran Antović <vedran.antovic@isatis.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-05-13 06:35:42 +02:00
0fb91ef859 add single blocklist delete (#18073)
Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-05-12 10:36:39 +00:00
Andy Butland c0a0a34cbb Backport variable name updates and fix found porting the invalidation of sessions after removal of external login provider to 16. 2025-05-09 14:57:08 +02:00
Andy ButlandandGitHub 3d44a6fdf9 Invalidate external login session on removal of provider (#19273) 2025-05-09 10:22:13 +00:00
0eee307622 Added custom RichTextRegexValidator to validate markup instead of JSON (#19045)
Co-authored-by: Migaroez <geusens@gmail.com>
2025-05-08 14:51:06 +02:00
6a4aa04b47 Handle existing RichTextEditorValue when parsing from markup or JSON structure (#19266)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-08 10:29:40 +02:00
643f073945 TimedScope improvements and login duration clean-up (#19243)
Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-05-07 18:30:49 +02:00
Andy Butland 5ba40c65fc Merge branch 'release/13.8.1' into v13/dev
# Conflicts:
#	version.json
2025-05-06 07:13:40 +02:00
Andy Butland dcbbed4160 Fixed failures in unit tests. 2025-05-06 05:35:21 +02:00
Andy Butland e94e165593 Fixed pipeline definition. 2025-05-06 05:17:05 +02:00
Andy ButlandandGitHub 34709be6cc Merge commit from fork
* Backport user enumeration fix.

* Bump gitversion.

* Remove full path details from exception when requesting a path outside of the physical file system's root.

* Adds randomness to failed login duration.
2025-05-06 05:11:04 +02:00
83107bb31a Fix: #18421 - Added Max Length validation to PropertyTypeBasic Alias (#18427)
Co-authored-by: Daniel Nelson <daniel.nelson@cantarus.com>
2025-05-05 15:11:47 +02:00
Kenn JacobsenandGitHub 3caa43a5bf Fix root path regression for the Delivery API (#19216) 2025-05-03 15:26:20 +02:00
1efe860a89 V13: Clear Member Username Cache in Load Balanced Environments (#19191)
* Clear usernamekey

* Odd explaining comment

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

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

* Make UserNameCachePrefix readonly for better immutabilityly

* Move prefix to CacheKeys constants

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-29 18:03:28 +02:00
Andy Butland d49965549c Merge branch 'v13/contrib' into v13/dev 2025-04-29 14:26:00 +02:00
Dave WoestenborghsandGitHub 8d85259025 Upgrade examine to 3.7.1 (#19186) 2025-04-29 14:15:27 +02:00
Andreas ZerbstandGitHub 097d0456cc V13 QA updated pipeline for acceptance to avoid issue when installing playwright (#19140)
* Updated pipeline to install only the chromium browser

* Added junit as reporter for acceptance tests
2025-04-29 11:48:38 +00:00
mole a62fa93c77 Use windows agent for nuget push 2025-04-28 10:58:29 +02:00
Andy Butland a22dcf05f2 Merge branch 'v13/contrib' into v13/dev 2025-04-28 06:46:10 +02:00
afa6fa5ca2 attempted fix for Save and Preview button #15360 (#19138)
Co-authored-by: Navya Sinha <navya.sinha@method4.co.uk>
2025-04-25 21:28:46 +02:00
c6effef622 Handle file paths as not found in delivery API by route requests (#19063)
* Handle file paths as not found in delivery API by route requests.

* Move check earlier to handle redirect logic as well.

* Spelling: Changed "resolveable" to "resolvable"

---------

Co-authored-by: kjac <kja@umbraco.dk>
2025-04-22 06:46:33 +02:00
Andy Butland ab31fbb0aa Bump version to 13.8.1. 2025-04-17 11:14:17 +02:00
Andy Butland 7d9a2c6e70 Merge branch 'release/13.8.0' into v13/dev
# Conflicts:
#	version.json
2025-04-10 11:19:32 +02:00
Andy Butland a486d5df33 Bumped version to 13.8.0. 2025-04-10 07:38:20 +02:00
Andy Butland 3c8a621817 Merge branch 'v13/dev' into v13/contrib 2025-04-04 08:01:38 +02:00
Andy Butland 0e0aca55af Verify endpoint selection candidates with host attribute are ignored if request doesn't match the configured hosts. (#18820) 2025-04-04 06:54:56 +02:00
0f02584143 attempted fix for Datepicker v13 issue #16008 (#18903)
Co-authored-by: Navya Sinha <navya.sinha@method4.co.uk>
2025-04-04 03:52:48 +02:00
a3db45609a Move database cache rebuild to a background task with polling (13) (#18922)
* Converts rebuild database cache operation to submit and poll.

* Update src/Umbraco.Web.UI.Client/src/views/dashboard/settings/publishedsnapshotcache.controller.js

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

* Handle HTTP error in status retrieval.

* Fixed test build.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-03 21:58:56 +02:00
Andy ButlandandGitHub d60a2a217c Verify endpoint selection candidates with host attribute are ignored if request doesn't match the configured hosts. (#18820) 2025-04-03 21:13:45 +02:00
Andy Butland 3e9ff6b5cb Check we have matched a preview URL by ID when exiting preview. (#18841) 2025-04-03 10:39:47 +02:00
Andy ButlandandGitHub 05a7d337de Check we have matched a preview URL by ID when exiting preview. (#18841) 2025-04-03 10:32:27 +02:00
Jacob OvergaardandGitHub fdca086a47 build(deps): bump @umbraco-ui/uui from 1.12.2 to 1.13.0 (#18830) 2025-03-26 14:32:29 +00:00
Andreas ZerbstandGitHub 42a81beeac V13 hotfix sqlserver integration tests (#18744)
* Disabled encrypt

* Skips integration tests for SQl Server on releases

* Removed encrypt
2025-03-20 11:59:03 +01:00
Andy Butland 9284b9e0b1 Make preview check for delivery API content case insensitive. (#18731) 2025-03-20 06:58:57 +01:00
Andy ButlandandGitHub eb91f4fef4 Make preview check for delivery API content case insensitive. (#18731) 2025-03-20 06:53:43 +01:00
Andy Butland 68acc2aa51 Bumped version to 13.9.0-rc. 2025-03-19 16:39:34 +01:00
Andy ButlandandGitHub 5570583f70 Fixes issue with macro rendering in an RTE when GUIDs are used for backoffice document routes (#18691)
* Fixes issue with macro rendering in an RTE when GUIDs are used for backoffice document routes.

* Fixed null reference error.
2025-03-18 11:10:23 +01:00
Andy ButlandandGitHub eb979625d1 Render folders before files in static files picker. (#18701) 2025-03-18 11:05:54 +01:00
Andy ButlandandGitHub e1e5f527e4 Updates dependency on Examine to 3.7. (#18676) 2025-03-17 09:13:09 +01:00
Lan Nguyen ThuyandJacob Overgaard f016dbd223 Fix issue text overflow when user name is too long 2025-03-13 11:07:57 +01:00
Andy Butland b2aa04ae1a Merge branch 'release/13.7.2' into v13/dev
# Conflicts:
#	version.json
2025-03-12 12:18:45 +01:00
Andy ButlandandGitHub e270adc50f Reverts UI updates from publish with desdendants dialog. (#18647) 2025-03-12 08:10:34 +01:00
Andy Butland fd5b8de533 Bumped version to 13.7.2 2025-03-12 07:25:35 +01:00
Andy Butland e3d9b042c2 Avoids collection was modified issue when flowing identities to the authenticated user's principal. (#18527) 2025-03-12 07:24:50 +01:00
Andy Butland f4ae796b30 Merge branch 'v13/dev' of https://github.com/umbraco/Umbraco-CMS into v13/dev 2025-03-11 09:18:25 +01:00
Andy Butland 367d0386b6 Merge branch 'release/13.7.1' into v13/dev
# Conflicts:
#	version.json
2025-03-11 09:18:12 +01:00
Anders BjernerandMichael Latouche d3b60277d7 Fixed typo in TinyMCE's da.js 2025-03-10 15:38:50 +01:00
twoday-rolandkockandGitHub 78a8d017a2 bumped imagesharp to prevent CVE-2025-27598 (#18602) 2025-03-09 08:41:06 +01:00
1a205d8727 Handle multiline statements in migrations (#18478)
* Handle multiline statements in migrations

* Fixed failing unit tests and incorrect obsoletion.

* Fixed failing integration tests.

* Applied suggestion from code review.

---------

Co-authored-by: Sven Geusens <sge@umbraco.dk>
2025-03-04 17:27:33 +01:00
Andy ButlandandGitHub 396b5ea211 Avoids collection was modified issue when flowing identities to the authenticated user's principal. (#18527) 2025-03-04 12:12:29 +01:00
Andy ButlandandGitHub 3cf9099f56 Remove version from models builder generated code header when configured to do so. (#18501) 2025-03-04 11:52:20 +01:00
Andy Butland 32bfbadb8e Merge branch 'release/13.7.0' into v13/dev
# Conflicts:
#	version.json
2025-02-27 06:47:15 +01:00
Kenn JacobsenandGitHub 5ed09ebefa Backport ShowUnroutableContentWarnings to V13 (#18479) 2025-02-26 20:13:27 +01:00
Andy ButlandandGitHub de2114b8c5 Adds warning to publish descendants dialog when force re-publish is selected. (#18409) 2025-02-22 09:39:58 +01:00
Andy ButlandandGitHub 64607ddbaa Disable webhook firing if disable in configuration. (#18383) 2025-02-19 12:17:04 +01:00
Andy ButlandandGitHub db1d999721 Avoid exception when attempting to find member by Id when Id is not an expected Guid or integer, as can be the case with external member providers. (#18320) 2025-02-17 12:47:52 +01:00
Andy ButlandandGitHub 18047a7cfb Only filter post retrieval of entities for start nodes if working with entities that support start nodes. (#18287) 2025-02-17 12:45:51 +01:00
Andy ButlandandGitHub def7ebd48c Html encodes the user's name in the invite email. (#18343) 2025-02-17 12:25:12 +01:00
8c2b1ebdc5 V13: Introduce publishNotifications method on IMembershipMemberService (#18207)
* Introduce publishNotifications method on IMembershipMemberService.cs

* Fix test

* Add PublishNotificationSaveOptions

* Fix up according to comments

* Use numeric values for flag enum

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

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

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

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

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2025-02-12 13:30:41 +01:00
Andy ButlandandGitHub 9227517a50 Split force for publish descendants into separate options for publish unpublish and re-publish unedited (13) (#18249)
* Split force for publish descendents into separate options for publish unpublish and re-publish unedited.

* Added integration task verifying updated behaviour.

* Variant integration test.

* Update test data controller.

* Remove usued function parameters.

* Refactor to enum.

* Fixed flags enum.

* Variable name refactor.

* Applied changes from code review.

* Refactored method name.

* Aligned js boolean checks.
2025-02-12 11:30:27 +00:00
Andy Butland 5322d0f7b5 Bumped version to 13.8.0-rc. 2025-02-12 08:13:58 +01:00
129 changed files with 5705 additions and 3236 deletions
+1 -1
View File
@@ -35,7 +35,7 @@
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
</PropertyGroup>
<!-- Calculate version only once for the whole repository -->
<!-- Calculate version only once for the whole repository -->
<PropertyGroup>
<GitVersionBaseDirectory>$(MSBuildThisFileDirectory)</GitVersionBaseDirectory>
</PropertyGroup>
+2 -2
View File
@@ -45,8 +45,8 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="7.1.1" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="7.1.0" />
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
<PackageVersion Include="Examine" Version="3.5.0" />
<PackageVersion Include="Examine.Core" Version="3.5.0" />
<PackageVersion Include="Examine" Version="3.7.1" />
<PackageVersion Include="Examine.Core" Version="3.7.1" />
<PackageVersion Include="HtmlAgilityPack" Version="1.11.71" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
<PackageVersion Include="MailKit" Version="4.8.0" />
+34 -8
View File
@@ -338,7 +338,9 @@ stages:
# Integration Tests (SQL Server)
- job:
timeoutInMinutes: 120
condition: or(eq(stageDependencies.Build.A.outputs['build.NBGV_PublicRelease'], 'True'), ${{parameters.sqlServerIntegrationTests}})
# We are currently encountering issues when running SQL Server Linux tests Microsoft.Data.SqlClient.SqlException (0x80131904)
# condition: or(eq(stageDependencies.Build.A.outputs['build.NBGV_PublicRelease'], 'True'), ${{parameters.sqlServerIntegrationTests}})
condition: eq(${{parameters.sqlServerIntegrationTests}}, True)
displayName: Integration Tests (SQL Server)
strategy:
matrix:
@@ -529,8 +531,8 @@ stages:
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
# Install Playwright and dependencies
- pwsh: npx playwright install --with-deps
displayName: Install Playwright
- pwsh: npx playwright install chromium
displayName: Install Playwright only with Chromium browser
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
# Test
@@ -560,13 +562,24 @@ stages:
displayName: Copy Playwright results
condition: succeededOrFailed()
# Publish
# Publish test artifacts
- task: PublishPipelineArtifact@1
displayName: Publish test artifacts
condition: succeededOrFailed()
inputs:
targetPath: $(Build.ArtifactStagingDirectory)
artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)"
# Publish test results
- task: PublishTestResults@2
displayName: "Publish test results"
condition: succeededOrFailed()
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: '*.xml'
searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results"
testRunTitle: "$(Agent.JobName)"
- job:
displayName: E2E Tests (SQL Server)
condition: or(eq(stageDependencies.Build.A.outputs['build.NBGV_PublicRelease'], 'True'), ${{parameters.sqlServerAcceptanceTests}})
@@ -685,14 +698,13 @@ stages:
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
# Install Playwright and dependencies
- pwsh: npx playwright install --with-deps
displayName: Install Playwright
- pwsh: npx playwright install chromium
displayName: Install Playwright only with Chromium browser
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
# Test
- pwsh: $(testCommand)
displayName: Run Playwright tests
continueOnError: true
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
env:
CI: true
@@ -725,7 +737,7 @@ stages:
displayName: Copy Playwright results
condition: succeededOrFailed()
# Publish
# Publish test artifacts
- task: PublishPipelineArtifact@1
displayName: Publish test artifacts
condition: succeededOrFailed()
@@ -733,6 +745,16 @@ stages:
targetPath: $(Build.ArtifactStagingDirectory)
artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)"
# Publish test results
- task: PublishTestResults@2
displayName: "Publish test results"
condition: succeededOrFailed()
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: '*.xml'
searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results"
testRunTitle: "$(Agent.JobName)"
###############################################
## Release
###############################################
@@ -745,6 +767,8 @@ stages:
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.myGetDeploy}}))
jobs:
- job:
pool:
vmImage: "windows-latest" # NuGetCommand@2 is no longer supported on Ubuntu 24.04 so we'll use windows until an alternative is available.
displayName: Push to pre-release feed
steps:
- checkout: none
@@ -771,6 +795,8 @@ stages:
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.nuGetDeploy}}))
jobs:
- job:
pool:
vmImage: "windows-latest" # NuGetCommand@2 is no longer supported on Ubuntu 24.04 so we'll use windows until an alternative is available.
displayName: Push to NuGet
steps:
- checkout: none
@@ -145,6 +145,11 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
path = DecodePath(path);
path = path.Length == 0 ? "/" : path;
if (_apiContentPathResolver.IsResolvablePath(path) is false)
{
return NotFound();
}
IPublishedContent? contentItem = GetContent(path);
if (contentItem is not null)
{
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.DeliveryApi;
namespace Umbraco.Cms.Api.Delivery.Services;
@@ -11,5 +11,5 @@ internal sealed class RequestPreviewService : RequestHeaderHandler, IRequestPrev
}
/// <inheritdoc />
public bool IsPreview() => GetHeaderValue("Preview") == "true";
public bool IsPreview() => string.Equals(GetHeaderValue("Preview"), "true", StringComparison.OrdinalIgnoreCase);
}
@@ -36,7 +36,7 @@ internal abstract class RoutingServiceBase
}
protected static string GetContentRoute(DomainAndUri domainAndUri, Uri contentRoute)
=> $"{domainAndUri.ContentId}{DomainUtilities.PathRelativeToDomain(domainAndUri.Uri, contentRoute.AbsolutePath)}";
=> $"{domainAndUri.ContentId}{DomainUtilities.PathRelativeToDomain(domainAndUri.Uri, contentRoute.LocalPath)}"; // Use LocalPath over AbsolutePath to keep the path decoded.
protected DomainAndUri? GetDomainAndUriForRoute(Uri contentUrl)
{
@@ -13,6 +13,9 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.UnitTests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.Integration</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
</AssemblyAttribute>
+4 -2
View File
@@ -43,7 +43,7 @@ public static class AppCacheExtensions
public static T? GetCacheItem<T>(this IAppCache provider, string cacheKey)
{
var result = provider.Get(cacheKey);
if (result == null)
if (IsRetrievedItemNull(result))
{
return default;
}
@@ -54,11 +54,13 @@ public static class AppCacheExtensions
public static T? GetCacheItem<T>(this IAppCache provider, string cacheKey, Func<T> getCacheItem)
{
var result = provider.Get(cacheKey, () => getCacheItem());
if (result == null)
if (IsRetrievedItemNull(result))
{
return default;
}
return result.TryConvertTo<T>().Result;
}
private static bool IsRetrievedItemNull(object? result) => result is null or (object)Cms.Core.Constants.Cache.NullRepresentationInCache;
}
+2
View File
@@ -22,4 +22,6 @@ public static class CacheKeys
public const string ContentRecycleBinCacheKey = "recycleBin_content";
public const string MediaRecycleBinCacheKey = "recycleBin_media";
public const string MemberUserNameCachePrefix = "uRepo_userNameKey+";
}
@@ -0,0 +1,16 @@
namespace Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
/// <summary>
/// Defines behaviours for clearing of cached partials views that are configured to be cached individually by member.
/// </summary>
public interface IMemberPartialViewCacheInvalidator
{
/// <summary>
/// Clears the partial view cache items for the specified member ids.
/// </summary>
/// <param name="memberIds">The member Ids to clear the cache for.</param>
/// <remarks>
/// Called from the <see cref="MemberCacheRefresher"/> when a member is saved or deleted.
/// </remarks>
void ClearPartialViewCacheItems(IEnumerable<int> memberIds);
}
@@ -1,5 +1,8 @@
// using Newtonsoft.Json;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
@@ -15,10 +18,37 @@ public sealed class MemberCacheRefresher : PayloadCacheRefresherBase<MemberCache
public static readonly Guid UniqueId = Guid.Parse("E285DF34-ACDC-4226-AE32-C0CB5CF388DA");
private readonly IIdKeyMap _idKeyMap;
private readonly IMemberPartialViewCacheInvalidator _memberPartialViewCacheInvalidator;
public MemberCacheRefresher(AppCaches appCaches, IJsonSerializer serializer, IIdKeyMap idKeyMap, IEventAggregator eventAggregator, ICacheRefresherNotificationFactory factory)
: base(appCaches, serializer, eventAggregator, factory) =>
[Obsolete("Use the non obsoleted constructor instead. Scheduled for removal in v17")]
public MemberCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
IIdKeyMap idKeyMap,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory)
: this(
appCaches,
serializer,
idKeyMap,
eventAggregator,
factory,
StaticServiceProvider.Instance.GetRequiredService<IMemberPartialViewCacheInvalidator>())
{
}
public MemberCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
IIdKeyMap idKeyMap,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory,
IMemberPartialViewCacheInvalidator memberPartialViewCacheInvalidator)
: base(appCaches, serializer, eventAggregator, factory)
{
_idKeyMap = idKeyMap;
_memberPartialViewCacheInvalidator = memberPartialViewCacheInvalidator;
}
#region Indirect
@@ -67,17 +97,30 @@ public sealed class MemberCacheRefresher : PayloadCacheRefresherBase<MemberCache
private void ClearCache(params JsonPayload[] payloads)
{
AppCaches.ClearPartialViewCache();
// Clear the partial views cache for all partials that are cached by member, for the updates members.
_memberPartialViewCacheInvalidator.ClearPartialViewCacheItems(payloads.Select(p => p.Id));
Attempt<IAppPolicyCache?> memberCache = AppCaches.IsolatedCaches.Get<IMember>();
foreach (JsonPayload p in payloads)
{
_idKeyMap.ClearCache(p.Id);
if (memberCache.Success)
if (memberCache.Success is false)
{
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, int>(p.Id));
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, string>(p.Username));
continue;
}
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, int>(p.Id));
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, string>(p.Username));
// This specific cache key was introduced to fix an issue where the member username could not be the same as the member id, because the cache keys collided.
// This is done in a bit of a hacky way, because the cache key is created internally in the repository, but we need to clear it here.
// Ideally, we want to use a shared way of generating the key between this and the repository.
// Additionally, the RepositoryCacheKeys actually caches the string to avoid re-allocating memory; we would like to also use this in the repository
// See:
// https://github.com/umbraco/Umbraco-CMS/pull/17350
// https://github.com/umbraco/Umbraco-CMS/pull/17815
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, string>(CacheKeys.MemberUserNameCachePrefix + p.Username));
}
}
}
@@ -160,6 +160,7 @@ public class ContentSettings
internal const bool StaticDisableUnpublishWhenReferenced = false;
internal const bool StaticAllowEditInvariantFromNonDefault = false;
internal const bool StaticShowDomainWarnings = true;
internal const bool StaticShowUnroutableContentWarnings = true;
/// <summary>
/// Gets or sets a value for the content notification settings.
@@ -285,4 +286,10 @@ public class ContentSettings
/// </summary>
[DefaultValue(StaticShowDomainWarnings)]
public bool ShowDomainWarnings { get; set; } = StaticShowDomainWarnings;
/// <summary>
/// Gets or sets a value indicating whether to show unroutable content warnings.
/// </summary>
[DefaultValue(StaticShowUnroutableContentWarnings)]
public bool ShowUnroutableContentWarnings { get; set; } = StaticShowUnroutableContentWarnings;
}
@@ -2,6 +2,7 @@
// See LICENSE for more details.
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace Umbraco.Cms.Core.Configuration.Models;
@@ -27,6 +28,8 @@ public class SecuritySettings
internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60;
internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60;
internal const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000;
internal const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250;
/// <summary>
/// Gets or sets a value indicating whether to keep the user logged in.
@@ -125,4 +128,28 @@ public class SecuritySettings
/// </summary>
[DefaultValue(StaticAllowConcurrentLogins)]
public bool AllowConcurrentLogins { get; set; } = StaticAllowConcurrentLogins;
/// <summary>
/// Gets or sets the default duration (in milliseconds) of failed login attempts.
/// </summary>
/// <value>
/// The default duration (in milliseconds) of failed login attempts.
/// </value>
/// <remarks>
/// The user login endpoint ensures that failed login attempts take at least as long as the average successful login.
/// However, if no successful logins have occurred, this value is used as the default duration.
/// </remarks>
[Range(0, int.MaxValue)] // TODO (V17): Change property type to short and update maximum range to short.MaxValue
[DefaultValue(StaticUserDefaultFailedLoginDurationInMilliseconds)]
public long UserDefaultFailedLoginDurationInMilliseconds { get; set; } = StaticUserDefaultFailedLoginDurationInMilliseconds;
/// <summary>
/// Gets or sets the minimum duration (in milliseconds) of failed login attempts.
/// </summary>
/// <value>
/// The minimum duration (in milliseconds) of failed login attempts.
/// </value>
[Range(0, int.MaxValue)] // TODO (V17): Change property type to short and update maximum range to short.MaxValue
[DefaultValue(StaticUserMinimumFailedLoginDurationInMilliseconds)]
public long UserMinimumFailedLoginDurationInMilliseconds { get; set; } = StaticUserMinimumFailedLoginDurationInMilliseconds;
}
+15
View File
@@ -0,0 +1,15 @@
namespace Umbraco.Cms.Core;
public static partial class Constants
{
public static class Cache
{
/// <summary>
/// Defines the string used to represent a null value in the cache.
/// </summary>
/// <remarks>
/// Used in conjunction with the option to cache null values on the repository caches, so we
/// can distinguish a true null "not found" value and a cached null value.</remarks>
public const string NullRepresentationInCache = "*NULL*";
}
}
@@ -15,6 +15,27 @@ public class ApiContentPathResolver : IApiContentPathResolver
_apiPublishedContentCache = apiPublishedContentCache;
}
[Obsolete("No longer used in V15. Scheduled for removal in V15.")]
public virtual bool IsResolvablePath(string path)
{
// File requests will blow up with an downstream exception in GetRequiredPublishedSnapshot, which fails due to an UmbracoContext
// not being available for what's considered a static file request.
// See: https://github.com/umbraco/Umbraco-CMS/issues/19051
// Given a URL segment and hence route can't contain a period, we can safely assume that if the last segment of the path contains
// a period, it's a file request and should return null here.
if (IsFileRequest(path))
{
return false;
}
return true;
}
private static bool IsFileRequest(string path) => path
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.LastOrDefault()?
.Contains('.') is true;
public virtual IPublishedContent? ResolveContentPath(string path)
{
path = path.EnsureStartsWith("/");
@@ -4,5 +4,8 @@ namespace Umbraco.Cms.Core.DeliveryApi;
public interface IApiContentPathResolver
{
[Obsolete("No longer used in V15. Scheduled for removal in V15.")]
bool IsResolvablePath(string path) => true;
IPublishedContent? ResolveContentPath(string path);
}
@@ -294,6 +294,7 @@
Content.
</key>
<key alias="nestedContentDeleteAllItems">Are you sure you want to delete all items?</key>
<key alias="nestedContentDeleteItem">Are you sure you want to delete this item?</key>
<key alias="nestedContentNoContentTypes">No Content Types are configured for this property.</key>
<key alias="nestedContentAddElementType">Add Element Type</key>
<key alias="nestedContentSelectElementTypeModalTitle">Select Element Type</key>
@@ -331,6 +332,7 @@
<key alias="schedulePublishHelp">Select the date and time to publish and/or unpublish the content item.</key>
<key alias="createEmpty">Create new</key>
<key alias="createFromClipboard">Paste from clipboard</key>
<key alias="removeItem">Remove item</key>
<key alias="nodeIsInTrash">This item is in the Recycle Bin</key>
<key alias="noProperties">No content can be added for this item</key>
<key alias="variantSaveNotAllowed">Save is not allowed</key>
@@ -290,6 +290,7 @@
<![CDATA[<a href="https://docs.umbraco.com/umbraco-cms/fundamentals/data/scheduled-publishing#timezones" target="_blank" rel="noopener">What does this mean?</a>]]></key>
<key alias="nestedContentDeleteItem">Are you sure you want to delete this item?</key>
<key alias="nestedContentDeleteAllItems">Are you sure you want to delete all items?</key>
<key alias="nestedContentDeleteItem">Are you sure you want to delete this item?</key>
<key alias="nestedContentEditorNotSupported">Property %0% uses editor %1% which is not supported by Nested
Content.
</key>
@@ -330,6 +331,7 @@
<key alias="schedulePublishHelp">Select the date and time to publish and/or unpublish the content item.</key>
<key alias="createEmpty">Create new</key>
<key alias="createFromClipboard">Paste from clipboard</key>
<key alias="removeItem">Remove item</key>
<key alias="nodeIsInTrash">This item is in the Recycle Bin</key>
<key alias="variantSaveNotAllowed">Save is not allowed</key>
<key alias="variantPublishNotAllowed">Publish is not allowed</key>
+32 -6
View File
@@ -1,15 +1,17 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Diagnostics.CodeAnalysis;
namespace Umbraco.Extensions;
public static class IntExtensions
{
/// <summary>
/// Does something 'x' amount of times
/// Does something 'x' amount of times.
/// </summary>
/// <param name="n"></param>
/// <param name="action"></param>
/// <param name="n">Number of times to execute the action.</param>
/// <param name="action">The action to execute.</param>
public static void Times(this int n, Action<int> action)
{
for (var i = 0; i < n; i++)
@@ -19,11 +21,11 @@ public static class IntExtensions
}
/// <summary>
/// Creates a Guid based on an integer value
/// Creates a Guid based on an integer value.
/// </summary>
/// <param name="value"><see cref="int" /> value to convert</param>
/// <param name="value">The <see cref="int" /> value to convert.</param>
/// <returns>
/// <see cref="Guid" />
/// The converted <see cref="Guid" />.
/// </returns>
public static Guid ToGuid(this int value)
{
@@ -31,4 +33,28 @@ public static class IntExtensions
BitConverter.GetBytes(value).CopyTo(bytes, 0);
return new Guid(bytes);
}
/// <summary>
/// Restores a GUID previously created from an integer value using <see cref="ToGuid" />.
/// </summary>
/// <param name="value">The <see cref="Guid" /> value to convert.</param>
/// <param name="result">The converted <see cref="int" />.</param>
/// <returns>
/// True if the <see cref="int" /> value could be created, otherwise false.
/// </returns>
/// <remarks>
/// This is used with Umbraco entities that only have integer references in the database (e.g. users).
/// </remarks>
public static bool TryParseFromGuid(Guid value, [NotNullWhen(true)] out int? result)
{
if (value.ToString().EndsWith("-0000-0000-0000-000000000000") is false)
{
// We have a proper GUID, not one converted from an integer.
result = null;
return false;
}
result = BitConverter.ToInt32(value.ToByteArray());
return true;
}
}
+1 -1
View File
@@ -358,7 +358,7 @@ namespace Umbraco.Cms.Core.IO
// nothing prevents us to reach the file, security-wise, yet it is outside
// this filesystem's root - throw
throw new UnauthorizedAccessException($"File original: [{originalPath}] full: [{path}] is outside this filesystem's root.");
throw new UnauthorizedAccessException($"Requested path {originalPath} is outside this filesystem's root.");
}
/// <summary>
@@ -1,69 +1,69 @@
namespace Umbraco.Cms.Core.Models.ContentEditing;
/// <summary>
/// The action associated with saving a content item
/// The action associated with saving a content item.
/// </summary>
public enum ContentSaveAction
{
/// <summary>
/// Saves the content item, no publish
/// Saves the content item, no publish.
/// </summary>
Save = 0,
/// <summary>
/// Creates a new content item
/// Creates a new content item.
/// </summary>
SaveNew = 1,
/// <summary>
/// Saves and publishes the content item
/// Saves and publishes the content item.
/// </summary>
Publish = 2,
/// <summary>
/// Creates and publishes a new content item
/// Creates and publishes a new content item.
/// </summary>
PublishNew = 3,
/// <summary>
/// Saves and sends publish notification
/// Saves and sends publish notification.
/// </summary>
SendPublish = 4,
/// <summary>
/// Creates and sends publish notification
/// Creates and sends publish notification.
/// </summary>
SendPublishNew = 5,
/// <summary>
/// Saves and schedules publishing
/// Saves and schedules publishing.
/// </summary>
Schedule = 6,
/// <summary>
/// Creates and schedules publishing
/// Creates and schedules publishing.
/// </summary>
ScheduleNew = 7,
/// <summary>
/// Saves and publishes the content item including all descendants that have a published version
/// Saves and publishes the content item including all descendants that have a published version.
/// </summary>
PublishWithDescendants = 8,
/// <summary>
/// Creates and publishes the content item including all descendants that have a published version
/// Creates and publishes the new content item including all descendants that have a published version.
/// </summary>
PublishWithDescendantsNew = 9,
/// <summary>
/// Saves and publishes the content item including all descendants regardless of whether they have a published version
/// or not
/// or not.
/// </summary>
PublishWithDescendantsForce = 10,
/// <summary>
/// Creates and publishes the content item including all descendants regardless of whether they have a published
/// version or not
/// Creates and publishes the new content item including all descendants regardless of whether they have a published
/// version or not.
/// </summary>
PublishWithDescendantsForceNew = 11,
}
@@ -25,6 +25,7 @@ public class PropertyTypeBasic
[Required]
[RegularExpression(@"^([a-zA-Z]\w.*)$", ErrorMessage = "Invalid alias")]
[MaxLength(255, ErrorMessage = "Alias is too long")]
[DataMember(Name = "alias")]
public string Alias { get; set; } = null!;
@@ -0,0 +1,28 @@
namespace Umbraco.Cms.Core.Models;
/// <summary>
/// Describes the options available with publishing a content branch for force publishing.
/// </summary>
[Flags]
public enum PublishBranchFilter
{
/// <summary>
/// The default behavior is to publish only the published content that has changed.
/// </summary>
Default = 0,
/// <summary>
/// For publishing a branch, publish all changed content, including content that is not published.
/// </summary>
IncludeUnpublished = 1,
/// <summary>
/// For publishing a branch, force republishing of all published content, including content that has not changed.
/// </summary>
ForceRepublish = 2,
/// <summary>
/// For publishing a branch, publish all content, including content that is not published and content that has not changed.
/// </summary>
All = IncludeUnpublished | ForceRepublish,
}
@@ -0,0 +1,28 @@
namespace Umbraco.Cms.Core.Models;
/// <summary>
/// Specifies options for publishing notifcations when saving.
/// </summary>
[Flags]
public enum PublishNotificationSaveOptions
{
/// <summary>
/// Do not publish any notifications.
/// </summary>
None = 0,
/// <summary>
/// Only publish the saving notification.
/// </summary>
Saving = 1,
/// <summary>
/// Only publish the saved notification.
/// </summary>
Saved = 2,
/// <summary>
/// Publish all the notifications.
/// </summary>
All = Saving | Saved,
}
@@ -3,23 +3,29 @@ using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Core.Persistence.Repositories;
/// <summary>
/// Repository for external logins with Guid as key, so it can be shared for members and users
/// Repository for external logins with Guid as key, so it can be shared for members and users.
/// </summary>
public interface IExternalLoginWithKeyRepository : IReadWriteQueryRepository<int, IIdentityUserLogin>,
IQueryRepository<IIdentityUserToken>
{
/// <summary>
/// Replaces all external login providers for the user/member key
/// Replaces all external login providers for the user/member key.
/// </summary>
void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins);
/// <summary>
/// Replaces all external login provider tokens for the providers specified for the user/member key
/// Replaces all external login provider tokens for the providers specified for the user/member key.
/// </summary>
void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens);
/// <summary>
/// Deletes all external logins for the specified the user/member key
/// Deletes all external logins for the specified the user/member key.
/// </summary>
void DeleteUserLogins(Guid userOrMemberKey);
/// <summary>
/// Deletes external logins that aren't associated with the current collection of providers.
/// </summary>
/// <param name="currentLoginProviders">The names of the currently configured providers.</param>
void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
}
@@ -38,4 +38,11 @@ public interface IMemberRepository : IContentRepository<int, IMember>
/// <param name="query"></param>
/// <returns></returns>
int GetCountByQuery(IQuery<IMember>? query);
/// <summary>
/// Saves only the properties related to login for the member, using an optimized, non-locking update.
/// </summary>
/// <param name="member">The member to update.</param>
/// <returns>Used to avoid the full save of the member object after a login operation.</returns>
Task UpdateLoginPropertiesAsync(IMember member) => Task.CompletedTask;
}
@@ -1,4 +1,4 @@
using System.Linq.Expressions;
using System.Linq.Expressions;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Persistence.Querying;
@@ -109,5 +109,26 @@ public interface IUserRepository : IReadWriteQueryRepository<int, IUser>
void ClearLoginSession(Guid sessionId);
/// <summary>
/// Gets a page of users, ordered by Id and starting from the provided Id.
/// </summary>
/// <param name="id">The user Id to start retrieving users from.</param>
/// <param name="count">The number of users to return.</param>
/// <returns>A page of <see cref="IUser"/> instances.</returns>
[Obsolete("No longer used in Umbraco. Scheduled for removal in Umbraco 18.")]
IEnumerable<IUser> GetNextUsers(int id, int count);
/// <summary>
/// Gets a page of approved users, ordered by Id and starting from the provided Id.
/// </summary>
/// <param name="id">The user Id to start retrieving users from.</param>
/// <param name="count">The number of users to return.</param>
/// <returns>A page of <see cref="IUser"/> instances.</returns>
IEnumerable<IUser> GetNextApprovedUsers(int id, int count) => Enumerable.Empty<IUser>();
/// <summary>
/// Invalidates sessions for users that aren't associated with the current collection of providers.
/// </summary>
/// <param name="currentProviderKeys">The keys for the currently configured providers.</param>
void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentProviderKeys) { }
}
@@ -31,6 +31,12 @@ public interface IPublishedSnapshotService : IDisposable
/// </remarks>
IPublishedSnapshot CreatePublishedSnapshot(string? previewToken);
/// <summary>
/// Indicates if the database cache is in the process of being rebuilt.
/// </summary>
/// <returns></returns>
bool IsRebuilding() => false;
/// <summary>
/// Rebuilds internal database caches (but does not reload).
/// </summary>
@@ -61,6 +67,38 @@ public interface IPublishedSnapshotService : IDisposable
IReadOnlyCollection<int>? mediaTypeIds = null,
IReadOnlyCollection<int>? memberTypeIds = null);
/// <summary>
/// Rebuilds internal database caches (but does not reload).
/// </summary>
/// <param name="contentTypeIds">
/// If not null will process content for the matching content types, if empty will process all
/// content
/// </param>
/// <param name="mediaTypeIds">
/// If not null will process content for the matching media types, if empty will process all
/// media
/// </param>
/// <param name="memberTypeIds">
/// If not null will process content for the matching members types, if empty will process all
/// members
/// </param>
/// <param name="useBackgroundThread">Flag indicating whether to use a background thread for the operation and immediately return to the caller.</param>
/// <remarks>
/// <para>
/// Forces the snapshot service to rebuild its internal database caches. For instance, some caches
/// may rely on a database table to store pre-serialized version of documents.
/// </para>
/// <para>
/// This does *not* reload the caches. Caches need to be reloaded, for instance via
/// <see cref="DistributedCache" /> RefreshAllPublishedSnapshot method.
/// </para>
/// </remarks>
void Rebuild(
bool useBackgroundThread,
IReadOnlyCollection<int>? contentTypeIds = null,
IReadOnlyCollection<int>? mediaTypeIds = null,
IReadOnlyCollection<int>? memberTypeIds = null) => Rebuild(contentTypeIds, mediaTypeIds, memberTypeIds);
/// <summary>
/// Rebuilds all internal database caches (but does not reload).
@@ -77,6 +115,22 @@ public interface IPublishedSnapshotService : IDisposable
/// </remarks>
void RebuildAll() => Rebuild(Array.Empty<int>(), Array.Empty<int>(), Array.Empty<int>());
/// <summary>
/// Rebuilds all internal database caches (but does not reload).
/// </summary>
/// <param name="useBackgroundThread">Flag indicating whether to use a background thread for the operation and immediately return to the caller.</param>
/// <remarks>
/// <para>
/// Forces the snapshot service to rebuild its internal database caches. For instance, some caches
/// may rely on a database table to store pre-serialized version of documents.
/// </para>
/// <para>
/// This does *not* reload the caches. Caches need to be reloaded, for instance via
/// <see cref="DistributedCache" /> RefreshAllPublishedSnapshot method.
/// </para>
/// </remarks>
void RebuildAll(bool useBackgroundThread) => Rebuild(useBackgroundThread, Array.Empty<int>(), Array.Empty<int>(), Array.Empty<int>());
/* An IPublishedCachesService implementation can rely on transaction-level events to update
* its internal, database-level data, as these events are purely internal. However, it cannot
* rely on cache refreshers CacheUpdated events to update itself, as these events are external
+25 -23
View File
@@ -1963,17 +1963,14 @@ public class ContentService : RepositoryService, IContentService
}
// utility 'ShouldPublish' func used by SaveAndPublishBranch
private HashSet<string>? SaveAndPublishBranch_ShouldPublish(ref HashSet<string>? cultures, string c, bool published, bool edited, bool isRoot, bool force)
private HashSet<string>? SaveAndPublishBranch_ShouldPublish(ref HashSet<string>? cultures, string c, bool published, bool edited, bool isRoot, PublishBranchFilter publishBranchFilter)
{
// if published, republish
if (published)
{
if (cultures == null)
{
cultures = new HashSet<string>(); // empty means 'already published'
}
cultures ??= []; // empty means 'already published'
if (edited)
if (edited || publishBranchFilter.HasFlag(PublishBranchFilter.ForceRepublish))
{
cultures.Add(c); // <culture> means 'republish this culture'
}
@@ -1982,15 +1979,12 @@ public class ContentService : RepositoryService, IContentService
}
// if not published, publish if force/root else do nothing
if (!force && !isRoot)
if (!publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) && !isRoot)
{
return cultures; // null means 'nothing to do'
}
if (cultures == null)
{
cultures = new HashSet<string>();
}
cultures ??= [];
cultures.Add(c); // <culture> means 'publish this culture'
return cultures;
@@ -1998,6 +1992,10 @@ public class ContentService : RepositoryService, IContentService
/// <inheritdoc />
public IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, bool force, string culture = "*", int userId = Constants.Security.SuperUserId)
=> SaveAndPublishBranch(content, force ? PublishBranchFilter.IncludeUnpublished : PublishBranchFilter.Default, culture, userId);
/// <inheritdoc />
public IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string culture = "*", int userId = Constants.Security.SuperUserId)
{
// note: EditedValue and PublishedValue are objects here, so it is important to .Equals()
// and not to == them, else we would be comparing references, and that is a bad thing
@@ -2016,13 +2014,13 @@ public class ContentService : RepositoryService, IContentService
// invariant content type
if (!c.ContentType.VariesByCulture())
{
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, force);
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, publishBranchFilter);
}
// variant content type, specific culture
if (culture != "*")
{
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, culture, c.IsCulturePublished(culture), c.IsCultureEdited(culture), isRoot, force);
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, culture, c.IsCulturePublished(culture), c.IsCultureEdited(culture), isRoot, publishBranchFilter);
}
// variant content type, all cultures
@@ -2032,23 +2030,27 @@ public class ContentService : RepositoryService, IContentService
// others will have to 'republish this culture'
foreach (var x in c.AvailableCultures)
{
SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, force);
SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, publishBranchFilter);
}
return culturesToPublish;
}
// if not published, publish if force/root else do nothing
return force || isRoot
// if not published, publish if forcing unpublished/root else do nothing
return publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) || isRoot
? new HashSet<string> { "*" } // "*" means 'publish all'
: null; // null means 'nothing to do'
}
return SaveAndPublishBranch(content, force, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId);
return SaveAndPublishBranch(content, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId);
}
/// <inheritdoc />
public IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, bool force, string[] cultures, int userId = Constants.Security.SuperUserId)
=> SaveAndPublishBranch(content, force ? PublishBranchFilter.IncludeUnpublished : PublishBranchFilter.Default, cultures, userId);
/// <inheritdoc />
public IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId)
{
// note: EditedValue and PublishedValue are objects here, so it is important to .Equals()
// and not to == them, else we would be comparing references, and that is a bad thing
@@ -2064,7 +2066,7 @@ public class ContentService : RepositoryService, IContentService
// invariant content type
if (!c.ContentType.VariesByCulture())
{
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, force);
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, publishBranchFilter);
}
// variant content type, specific cultures
@@ -2074,24 +2076,23 @@ public class ContentService : RepositoryService, IContentService
// others will have to 'republish this culture'
foreach (var x in cultures)
{
SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, force);
SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, publishBranchFilter);
}
return culturesToPublish;
}
// if not published, publish if force/root else do nothing
return force || isRoot
// if not published, publish if forcing unpublished/root else do nothing
return publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) || isRoot
? new HashSet<string>(cultures) // means 'publish specified cultures'
: null; // null means 'nothing to do'
}
return SaveAndPublishBranch(content, force, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId);
return SaveAndPublishBranch(content, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId);
}
internal IEnumerable<PublishResult> SaveAndPublishBranch(
IContent document,
bool force,
Func<IContent, HashSet<string>?> shouldPublish,
Func<IContent, HashSet<string>, IReadOnlyCollection<ILanguage>, bool> publishCultures,
int userId = Constants.Security.SuperUserId)
@@ -3626,6 +3627,7 @@ public class ContentService : RepositoryService, IContentService
private static readonly string?[] ArrayOfOneNullString = { null };
/// <inheritdoc />
public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId)
{
if (blueprint == null)
+15 -1
View File
@@ -108,7 +108,7 @@ public class DomainService : RepositoryService, IDomainService
EventMessages eventMessages = EventMessagesFactory.Get();
IDomain[] domains = items.ToArray();
if (domains.Length == 0)
if (domains.Length == 0 || AreDomainsAlreadySorted(domains))
{
return OperationResult.Attempt.NoOperation(eventMessages);
}
@@ -144,4 +144,18 @@ public class DomainService : RepositoryService, IDomainService
return OperationResult.Attempt.Succeed(eventMessages);
}
private static bool AreDomainsAlreadySorted(IDomain[] domains)
{
// Check if the domains are already sorted by comparing the current sort order with what we'll set to be the new sort order.
for (int i = 0; i < domains.Length; i++)
{
if (domains[i].SortOrder != i)
{
return false;
}
}
return true;
}
}
@@ -80,4 +80,14 @@ public class ExternalLoginService : RepositoryService, IExternalLoginWithKeyServ
scope.Complete();
}
}
/// <inheritdoc />
public void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
_externalLoginRepository.DeleteUserLoginsForRemovedProviders(currentLoginProviders);
scope.Complete();
}
}
}
@@ -56,6 +56,9 @@ public interface IContentService : IContentServiceBase<IContent>
/// <summary>
/// Creates a new content item from a blueprint.
/// </summary>
/// <remarks>Warning: If you intend to save the resulting <c>IContent</c> as a content node, you must trigger a
/// <see cref="Notifications.ContentScaffoldedNotification"/> notification to ensure that the block ids are regenerated.
/// Failing to do so could lead to caching issues.</remarks>
IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId);
/// <summary>
@@ -431,6 +434,7 @@ public interface IContentService : IContentServiceBase<IContent>
/// published. The root of the branch is always published, regardless of <paramref name="force" />.
/// </para>
/// </remarks>
[Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead.")]
IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, bool force, string culture = "*", int userId = Constants.Security.SuperUserId);
/// <summary>
@@ -447,8 +451,47 @@ public interface IContentService : IContentServiceBase<IContent>
/// published. The root of the branch is always published, regardless of <paramref name="force" />.
/// </para>
/// </remarks>
[Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead.")]
IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, bool force, string[] cultures, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Saves and publishes a document branch.
/// </summary>
/// <param name="content">The root document.</param>
/// <param name="publishBranchFilter">A value indicating options for force publishing unpublished or re-publishing unchanged content.</param>
/// <param name="culture">A culture, or "*" for all cultures.</param>
/// <param name="userId">The identifier of the user performing the operation.</param>
/// <remarks>
/// <para>
/// Unless specified, all cultures are re-published. Otherwise, one culture can be specified. To act on more
/// than one culture, see the other overloads of this method.
/// </para>
/// <para>
/// The root of the branch is always published, regardless of <paramref name="publishBranchFilter" />.
/// </para>
/// </remarks>
IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string culture = "*", int userId = Constants.Security.SuperUserId)
#pragma warning disable CS0618 // Type or member is obsolete
=> SaveAndPublishBranch(content, publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished), culture, userId);
#pragma warning restore CS0618 // Type or member is obsolete
/// <summary>
/// Saves and publishes a document branch.
/// </summary>
/// <param name="content">The root document.</param>
/// <param name="publishBranchFilter">A value indicating options for force publishing unpublished or re-publishing unchanged content.</param>
/// <param name="cultures">The cultures to publish.</param>
/// <param name="userId">The identifier of the user performing the operation.</param>
/// <remarks>
/// <para>
/// The root of the branch is always published, regardless of <paramref name="publishBranchFilter" />.
/// </para>
/// </remarks>
IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId)
#pragma warning disable CS0618 // Type or member is obsolete
=> SaveAndPublishBranch(content, publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished), cultures, userId);
#pragma warning restore CS0618 // Type or member is obsolete
///// <summary>
///// Saves and publishes a document branch.
///// </summary>
@@ -5,47 +5,53 @@ namespace Umbraco.Cms.Core.Services;
public interface IExternalLoginWithKeyService : IService
{
/// <summary>
/// Returns all user logins assigned
/// Returns all user logins assigned.
/// </summary>
IEnumerable<IIdentityUserLogin> GetExternalLogins(Guid userOrMemberKey);
/// <summary>
/// Returns all user login tokens assigned
/// Returns all user login tokens assigned.
/// </summary>
IEnumerable<IIdentityUserToken> GetExternalLoginTokens(Guid userOrMemberKey);
/// <summary>
/// Returns all logins matching the login info - generally there should only be one but in some cases
/// there might be more than one depending on if an administrator has been editing/removing members
/// there might be more than one depending on if an administrator has been editing/removing members.
/// </summary>
IEnumerable<IIdentityUserLogin> Find(string loginProvider, string providerKey);
/// <summary>
/// Saves the external logins associated with the user
/// Saves the external logins associated with the user.
/// </summary>
/// <param name="userOrMemberKey">
/// The user or member key associated with the logins
/// The user or member key associated with the logins.
/// </param>
/// <param name="logins"></param>
/// <remarks>
/// This will replace all external login provider information for the user
/// This will replace all external login provider information for the user.
/// </remarks>
void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins);
/// <summary>
/// Saves the external login tokens associated with the user
/// Saves the external login tokens associated with the user.
/// </summary>
/// <param name="userOrMemberKey">
/// The user or member key associated with the logins
/// The user or member key associated with the logins.
/// </param>
/// <param name="tokens"></param>
/// <remarks>
/// This will replace all external login tokens for the user
/// This will replace all external login tokens for the user.
/// </remarks>
void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens);
/// <summary>
/// Deletes all user logins - normally used when a member is deleted
/// Deletes all user logins - normally used when a member is deleted.
/// </summary>
void DeleteUserLogins(Guid userOrMemberKey);
/// <summary>
/// Deletes external logins that aren't associated with the current collection of providers.
/// </summary>
/// <param name="currentLoginProviders">The names of the currently configured providers.</param>
void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
}
@@ -343,4 +343,11 @@ public interface IMemberService : IMembershipMemberService
/// <see cref="IEnumerable{IMember}" />
/// </returns>
IEnumerable<IMember>? GetMembersByPropertyValue(string propertyTypeAlias, DateTime value, ValuePropertyMatchType matchType = ValuePropertyMatchType.Exact);
/// <summary>
/// Saves only the properties related to login for the member, using an optimized, non-locking update.
/// </summary>
/// <param name="member">The member to update.</param>
/// <returns>Used to avoid the full save of the member object after a login operation.</returns>
Task UpdateLoginPropertiesAsync(IMember member) => Task.CompletedTask;
}
@@ -135,6 +135,14 @@ public interface IMembershipMemberService<T> : IService
/// <param name="entity"><see cref="IMember" /> or <see cref="IUser" /> to Save</param>
void Save(T entity);
/// <summary>
/// Saves an <see cref="IMembershipUser" />
/// </summary>
/// <remarks>An <see cref="IMembershipUser" /> can be of type <see cref="IMember" /> or <see cref="IUser" /></remarks>
/// <param name="entity"><see cref="IMember" /> or <see cref="IUser" /> to Save</param>
/// <param name="publishNotificationSaveOptions"> Enum for deciding which notifications to publish.</param>
void Save(T entity, PublishNotificationSaveOptions publishNotificationSaveOptions) => Save(entity);
/// <summary>
/// Saves a list of <see cref="IMembershipUser" /> objects
/// </summary>
+21
View File
@@ -231,8 +231,29 @@ public interface IUserService : IMembershipUserService
/// </returns>
IEnumerable<IUser> GetAllNotInGroup(int groupId);
/// <summary>
/// Gets a page of users, ordered by Id and starting from the provided Id.
/// </summary>
/// <param name="id">The user Id to start retrieving users from.</param>
/// <param name="count">The number of users to return.</param>
/// <returns>A page of <see cref="IUser"/> instances.</returns>
[Obsolete("No longer used in Umbraco. Scheduled for removal in Umbraco 18.")]
IEnumerable<IUser> GetNextUsers(int id, int count);
/// <summary>
/// Gets a page of approved users, ordered by Id and starting from the provided Id.
/// </summary>
/// <param name="id">The user Id to start retrieving users from.</param>
/// <param name="count">The number of users to return.</param>
/// <returns>A page of <see cref="IUser"/> instances.</returns>
IEnumerable<IUser> GetNextApprovedUsers(int id, int count) => Enumerable.Empty<IUser>();
/// <summary>
/// Invalidates sessions for users that aren't associated with the current collection of providers.
/// </summary>
/// <param name="currentLoginProviders">The keys for the currently configured providers.</param>
void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
#region User groups
/// <summary>
+2 -2
View File
@@ -418,7 +418,7 @@ namespace Umbraco.Cms.Core.Services
}
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
scope.ReadLock(Constants.Locks.ContentTree);
scope.ReadLock(Constants.Locks.MediaTree);
return _mediaRepository.GetPage(Query<IMedia>()?.Where(x => x.ContentTypeId == contentTypeId), pageIndex, pageSize, out totalRecords, filter, ordering);
}
@@ -441,7 +441,7 @@ namespace Umbraco.Cms.Core.Services
}
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
scope.ReadLock(Constants.Locks.ContentTree);
scope.ReadLock(Constants.Locks.MediaTree);
return _mediaRepository.GetPage(
Query<IMedia>()?.Where(x => contentTypeIds.Contains(x.ContentTypeId)), pageIndex, pageSize, out totalRecords, filter, ordering);
}
+60 -6
View File
@@ -743,7 +743,9 @@ namespace Umbraco.Cms.Core.Services
public void SetLastLogin(string username, DateTime date) => throw new NotImplementedException();
/// <inheritdoc />
public void Save(IMember member)
public void Save(IMember member) => Save(member, PublishNotificationSaveOptions.All);
public void Save(IMember member, PublishNotificationSaveOptions publishNotificationSaveOptions)
{
// trimming username and email to make sure we have no trailing space
member.Username = member.Username.Trim();
@@ -752,11 +754,15 @@ namespace Umbraco.Cms.Core.Services
EventMessages evtMsgs = EventMessagesFactory.Get();
using ICoreScope scope = ScopeProvider.CreateCoreScope();
var savingNotification = new MemberSavingNotification(member, evtMsgs);
if (scope.Notifications.PublishCancelable(savingNotification))
MemberSavingNotification? savingNotification = null;
if (publishNotificationSaveOptions.HasFlag(PublishNotificationSaveOptions.Saving))
{
scope.Complete();
return;
savingNotification = new MemberSavingNotification(member, evtMsgs);
if (scope.Notifications.PublishCancelable(savingNotification))
{
scope.Complete();
return;
}
}
if (string.IsNullOrWhiteSpace(member.Name))
@@ -768,7 +774,13 @@ namespace Umbraco.Cms.Core.Services
_memberRepository.Save(member);
scope.Notifications.Publish(new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification));
if (publishNotificationSaveOptions.HasFlag(PublishNotificationSaveOptions.Saved))
{
scope.Notifications.Publish(
savingNotification is null
? new MemberSavedNotification(member, evtMsgs)
: new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification));
}
Audit(AuditType.Save, 0, member.Id);
@@ -808,6 +820,48 @@ namespace Umbraco.Cms.Core.Services
scope.Complete();
}
/// <inheritdoc/>
/// <remarks>
/// <para>
/// Note that in this optimized member save operation for use in the login process, where we only handle login related
/// properties, we aren't taking any locks. If we were updating "content" properties, that could have relations between each
/// other, we should following what we do for documents and lock.
/// But here we are just updating these system fields, and it's fine if they work in a "last one wins" fashion without locking.
/// </para>
/// <para>
/// Note also that we aren't calling "Audit" here (as well as to optimize performance, this is deliberate, because this is not
/// a full save operation on the member that we'd want to audit who made the changes via the backoffice or API; rather it's
/// just the member logging in as themselves).
/// </para>
/// <para>
/// We are though publishing notifications, to maintain backwards compatibility for any solutions using these for
/// processing following a member login.
/// </para>
/// <para>
/// These notification handlers will ensure that the records to umbracoLog are also added in the same way as they
/// are for a full save operation.
/// </para>
/// </remarks>
public async Task UpdateLoginPropertiesAsync(IMember member)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
using ICoreScope scope = ScopeProvider.CreateCoreScope();
var savingNotification = new MemberSavingNotification(member, evtMsgs);
savingNotification.State.Add("LoginPropertiesOnly", true);
if (scope.Notifications.PublishCancelable(savingNotification))
{
scope.Complete();
return;
}
await _memberRepository.UpdateLoginPropertiesAsync(member);
scope.Notifications.Publish(new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification));
scope.Complete();
}
#endregion
#region Delete
@@ -96,7 +96,7 @@ public class NotificationService : INotificationService
// see notes above
var id = Constants.Security.SuperUserId;
const int pagesz = 400; // load batches of 400 users
const int UserBatchSize = 400; // load batches of 400 users
do
{
var notifications = GetUsersNotifications(new List<int>(), action, Enumerable.Empty<int>(), Constants.ObjectTypes.Document)?.ToList();
@@ -106,10 +106,10 @@ public class NotificationService : INotificationService
}
// users are returned ordered by id, notifications are returned ordered by user id
var users = _userService.GetNextUsers(id, pagesz).Where(x => x.IsApproved).ToList();
foreach (IUser user in users)
var approvedUsers = _userService.GetNextApprovedUsers(id, UserBatchSize).ToList();
foreach (IUser approvedUser in approvedUsers)
{
Notification[] userNotifications = notifications.Where(n => n.UserId == user.Id).ToArray();
Notification[] userNotifications = notifications.Where(n => n.UserId == approvedUser.Id).ToArray();
foreach (Notification notification in userNotifications)
{
// notifications are inherited down the tree - find the topmost entity
@@ -130,14 +130,14 @@ public class NotificationService : INotificationService
}
// queue notification
NotificationRequest req = CreateNotificationRequest(operatingUser, user, entityForNotification, prevVersionDictionary[entityForNotification.Id], actionName, siteUri, createSubject, createBody);
NotificationRequest req = CreateNotificationRequest(operatingUser, approvedUser, entityForNotification, prevVersionDictionary[entityForNotification.Id], actionName, siteUri, createSubject, createBody);
Enqueue(req);
break;
}
}
// load more users if any
id = users.Count == pagesz ? users.Last().Id + 1 : -1;
id = approvedUsers.Count == UserBatchSize ? approvedUsers.Last().Id + 1 : -1;
}
while (id > 0);
}
+20
View File
@@ -712,6 +712,7 @@ internal class UserService : RepositoryService, IUserService
}
}
/// <inheritdoc/>
public IEnumerable<IUser> GetNextUsers(int id, int count)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
@@ -720,6 +721,25 @@ internal class UserService : RepositoryService, IUserService
}
}
/// <inheritdoc/>
public IEnumerable<IUser> GetNextApprovedUsers(int id, int count)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _userRepository.GetNextApprovedUsers(id, count);
}
}
/// <inheritdoc />
public void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentLoginProviders)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
_userRepository.InvalidateSessionsForRemovedProviders(currentLoginProviders);
scope.Complete();
}
}
/// <summary>
/// Gets a list of <see cref="IUser" /> objects associated with a given group
/// </summary>
+166
View File
@@ -0,0 +1,166 @@
namespace Umbraco.Cms.Core;
/// <summary>
/// Makes a code block timed (take at least a certain amount of time). This class cannot be inherited.
/// </summary>
public sealed class TimedScope : IDisposable, IAsyncDisposable
{
private readonly TimeSpan _duration;
private readonly TimeProvider _timeProvider;
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly long _startingTimestamp;
/// <summary>
/// Gets the elapsed time.
/// </summary>
/// <value>
/// The elapsed time.
/// </value>
public TimeSpan Elapsed
=> _timeProvider.GetElapsedTime(_startingTimestamp);
/// <summary>
/// Gets the remaining time.
/// </summary>
/// <value>
/// The remaining time.
/// </value>
public TimeSpan Remaining
=> TryGetRemaining(out TimeSpan remaining) ? remaining : TimeSpan.Zero;
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
public TimedScope(long millisecondsDuration)
: this(TimeSpan.FromMilliseconds(millisecondsDuration))
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public TimedScope(long millisecondsDuration, CancellationToken cancellationToken)
: this(TimeSpan.FromMilliseconds(millisecondsDuration), cancellationToken)
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
/// <param name="timeProvider">The time provider.</param>
public TimedScope(long millisecondsDuration, TimeProvider timeProvider)
: this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider)
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
/// <param name="timeProvider">The time provider.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public TimedScope(long millisecondsDuration, TimeProvider timeProvider, CancellationToken cancellationToken)
: this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider, cancellationToken)
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope"/> class.
/// </summary>
/// <param name="duration">The duration the scope should at least take.</param>
public TimedScope(TimeSpan duration)
: this(duration, TimeProvider.System)
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="duration">The duration the scope should at least take.</param>
/// <param name="timeProvider">The time provider.</param>
public TimedScope(TimeSpan duration, TimeProvider timeProvider)
: this(duration, timeProvider, new CancellationTokenSource())
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="duration">The duration the scope should at least take.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public TimedScope(TimeSpan duration, CancellationToken cancellationToken)
: this(duration, TimeProvider.System, cancellationToken)
{ }
/// <summary>
/// Initializes a new instance of the <see cref="TimedScope" /> class.
/// </summary>
/// <param name="duration">The duration the scope should at least take.</param>
/// <param name="timeProvider">The time provider.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationToken cancellationToken)
: this(duration, timeProvider, CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
{ }
private TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationTokenSource cancellationTokenSource)
{
_duration = duration;
_timeProvider = timeProvider;
_cancellationTokenSource = cancellationTokenSource;
_startingTimestamp = timeProvider.GetTimestamp();
}
/// <summary>
/// Cancels the timed scope.
/// </summary>
public void Cancel()
=> _cancellationTokenSource.Cancel();
/// <summary>
/// Cancels the timed scope asynchronously.
/// </summary>
public async Task CancelAsync()
=> await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
/// </summary>
/// <remarks>
/// This will block using <see cref="Thread.Sleep(TimeSpan)" /> until the remaining time has elapsed, if not cancelled.
/// </remarks>
public void Dispose()
{
if (_cancellationTokenSource.IsCancellationRequested is false &&
TryGetRemaining(out TimeSpan remaining))
{
Thread.Sleep(remaining);
}
_cancellationTokenSource.Dispose();
}
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources asynchronously.
/// </summary>
/// <returns>
/// A task that represents the asynchronous dispose operation.
/// </returns>
/// <remarks>
/// This will delay using <see cref="Task.Delay(TimeSpan, TimeProvider, CancellationToken)" /> until the remaining time has elapsed, if not cancelled.
/// </remarks>
public async ValueTask DisposeAsync()
{
if (_cancellationTokenSource.IsCancellationRequested is false &&
TryGetRemaining(out TimeSpan remaining))
{
await Task.Delay(remaining, _timeProvider, _cancellationTokenSource.Token).ConfigureAwait(false);
}
_cancellationTokenSource.Dispose();
}
private bool TryGetRemaining(out TimeSpan remaining)
{
remaining = _duration.Subtract(Elapsed);
return remaining > TimeSpan.Zero;
}
}
@@ -51,6 +51,12 @@ public class WebhookFiring : IRecurringBackgroundJob
public async Task RunJobAsync()
{
if (_webhookSettings.Enabled is false)
{
_logger.LogInformation("WebhookFiring task will not run as it has been globally disabled via configuration");
return;
}
IEnumerable<WebhookRequest> requests;
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
{
@@ -24,8 +24,6 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB
private static readonly TEntity[] _emptyEntities = new TEntity[0]; // const
private readonly RepositoryCachePolicyOptions _options;
private const string NullRepresentationInCache = "*NULL*";
public DefaultRepositoryCachePolicy(IAppPolicyCache cache, IScopeAccessor scopeAccessor, RepositoryCachePolicyOptions options)
: base(cache, scopeAccessor) =>
_options = options ?? throw new ArgumentNullException(nameof(options));
@@ -139,10 +137,8 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB
return fromCache;
}
// Because TEntity can never be a string, we will never be in a position where the proxy value collides withs a real value.
// Therefore this point can only be reached if there is a proxy null value => becomes null when cast to TEntity above OR the item simply does not exist.
// If we've cached a "null" value, return null.
if (_options.CacheNullValues && Cache.GetCacheItem<string>(cacheKey) == NullRepresentationInCache)
if (_options.CacheNullValues && Cache.GetCacheItem<string>(cacheKey) == Constants.Cache.NullRepresentationInCache)
{
return null;
}
@@ -273,7 +269,7 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB
// a value that does exist but isn't yet cached, or a value that has been explicitly cached with a null value.
// Both would return null when we retrieve from the cache and we couldn't distinguish between the two.
// So we cache a special value that represents null, and then we can check for that value when we retrieve from the cache.
Cache.Insert(cacheKey, () => NullRepresentationInCache, TimeSpan.FromMinutes(5), true);
Cache.Insert(cacheKey, () => Constants.Cache.NullRepresentationInCache, TimeSpan.FromMinutes(5), true);
}
protected virtual void InsertEntities(TId[]? ids, TEntity[]? entities)
@@ -1,13 +1,10 @@
using HtmlAgilityPack;
using Microsoft.Extensions.DependencyInjection;
using HtmlAgilityPack;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Infrastructure.Extensions;
using Umbraco.Extensions;
@@ -101,9 +98,9 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich
// - non-#comment nodes
// - non-#text nodes
// - non-empty #text nodes
// - empty #text between inline elements (see #17037)
// - empty #text between inline elements (see #17037) but not #text with only newlines (see #19388)
HtmlNode[] childNodes = element.ChildNodes
.Where(c => c.Name != CommentNodeName && (c.Name != TextNodeName || c.NextSibling is not null || string.IsNullOrWhiteSpace(c.InnerText) is false))
.Where(c => c.Name != CommentNodeName && (c.Name != TextNodeName || IsNonEmptyElement(c)))
.ToArray();
var tag = TagName(element);
@@ -124,6 +121,9 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich
return createElement(tag, attributes, childElements);
}
private static bool IsNonEmptyElement(HtmlNode htmlNode) =>
string.IsNullOrWhiteSpace(htmlNode.InnerText) is false || htmlNode.InnerText.Any(c => c != '\n' && c != '\r');
private string TagName(HtmlNode htmlNode) => htmlNode.Name;
private void ReplaceLocalLinks(IPublishedSnapshot publishedSnapshot, Dictionary<string, object> attributes)
@@ -240,6 +240,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IBlockEditorElementTypeCache, BlockEditorElementTypeCache>();
builder.Services.AddSingleton<IRichTextRequiredValidator, RichTextRequiredValidator>();
builder.Services.AddSingleton<IRichTextRegexValidator, RichTextRegexValidator>();
return builder;
}
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
@@ -28,21 +28,28 @@ internal sealed class DeliveryApiContentIndexHelper : IDeliveryApiContentIndexHe
public void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action<IContent[]> actionToPerform)
{
const int pageSize = 10000;
var pageIndex = 0;
EnumerateApplicableDescendantsForContentIndex(rootContentId, actionToPerform, pageSize);
}
internal void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action<IContent[]> actionToPerform, int pageSize)
{
var itemIndex = 0;
long total;
IQuery<IContent> query = _umbracoDatabaseFactory.SqlContext.Query<IContent>().Where(content => content.Trashed == false);
IContent[] descendants;
IQuery<IContent> query = _umbracoDatabaseFactory.SqlContext.Query<IContent>().Where(content => content.Trashed == false);
do
{
descendants = _contentService
.GetPagedDescendants(rootContentId, pageIndex, pageSize, out _, query, Ordering.By("Path"))
.GetPagedDescendants(rootContentId, itemIndex / pageSize, pageSize, out total, query, Ordering.By("Path"))
.Where(descendant => _deliveryApiSettings.IsAllowedContentType(descendant.ContentType.Alias))
.ToArray();
actionToPerform(descendants.ToArray());
actionToPerform(descendants);
pageIndex++;
itemIndex += pageSize;
}
while (descendants.Length == pageSize);
while (descendants.Length > 0 && itemIndex < total);
}
}
@@ -70,6 +70,11 @@ public abstract class MigrationExpressionBase : IMigrationExpression
}
else
{
if (stmtBuilder.Length > 0)
{
stmtBuilder.Append(Environment.NewLine);
}
stmtBuilder.Append(line);
}
}
@@ -92,7 +92,6 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
/// <para>Each migration in the plan, may or may not run in a scope depending on the type of plan.</para>
/// <para>A plan can complete partially, the changes of each completed migration will be saved.</para>
/// </remarks>
[Obsolete("This will return an ExecutedMigrationPlan in V13")]
public ExecutedMigrationPlan ExecutePlan(MigrationPlan plan, string fromState)
{
plan.Validate();
@@ -51,7 +51,15 @@ public class TextBuilder : Builder
/// Outputs an "auto-generated" header to a string builder.
/// </summary>
/// <param name="sb">The string builder.</param>
public static void WriteHeader(StringBuilder sb) => TextHeaderWriter.WriteHeader(sb);
[Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 17.")]
public static void WriteHeader(StringBuilder sb) => WriteHeader(sb, true);
/// <summary>
/// Outputs an "auto-generated" header to a string builder.
/// </summary>
/// <param name="sb">The string builder.</param>
/// <param name="includeVersion">Flag indicating whether the tool version number should be included in the output.</param>
public static void WriteHeader(StringBuilder sb, bool includeVersion) => TextHeaderWriter.WriteHeader(sb, includeVersion);
/// <summary>
/// Outputs a generated model to a string builder.
@@ -60,7 +68,7 @@ public class TextBuilder : Builder
/// <param name="typeModel">The model to generate.</param>
public void Generate(StringBuilder sb, TypeModel typeModel)
{
WriteHeader(sb);
WriteHeader(sb, Config.IncludeVersionNumberInGeneratedModels);
foreach (var t in TypesUsing)
{
@@ -83,7 +91,7 @@ public class TextBuilder : Builder
/// <param name="typeModels">The models to generate.</param>
public void Generate(StringBuilder sb, IEnumerable<TypeModel> typeModels)
{
WriteHeader(sb);
WriteHeader(sb, Config.IncludeVersionNumberInGeneratedModels);
foreach (var t in TypesUsing)
{
@@ -8,13 +8,30 @@ internal static class TextHeaderWriter
/// Outputs an "auto-generated" header to a string builder.
/// </summary>
/// <param name="sb">The string builder.</param>
public static void WriteHeader(StringBuilder sb)
[Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 17.")]
public static void WriteHeader(StringBuilder sb) => WriteHeader(sb, true);
/// <summary>
/// Outputs an "auto-generated" header to a string builder.
/// </summary>
/// <param name="sb">The string builder.</param>
/// <param name="includeVersion">Flag indicating whether the tool version number should be included in the output.</param>
public static void WriteHeader(StringBuilder sb, bool includeVersion)
{
sb.Append("//------------------------------------------------------------------------------\n");
sb.Append("// <auto-generated>\n");
sb.Append("// This code was generated by a tool.\n");
sb.Append("//\n");
sb.AppendFormat("// Umbraco.ModelsBuilder.Embedded v{0}\n", ApiVersion.Current.Version);
if (includeVersion)
{
sb.AppendFormat("// Umbraco.ModelsBuilder.Embedded v{0}\n", ApiVersion.Current.Version);
}
else
{
sb.Append("// Umbraco.ModelsBuilder.Embedded\n");
}
sb.Append("//\n");
sb.Append("// Changes to this file will be lost if the code is regenerated.\n");
sb.Append("// </auto-generated>\n");
@@ -56,6 +56,19 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
public void DeleteUserLogins(Guid userOrMemberKey) =>
Database.Delete<ExternalLoginDto>("WHERE userOrMemberKey=@userOrMemberKey", new { userOrMemberKey });
/// <inheritdoc />
public void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders)
{
Sql<ISqlContext> sql = Sql()
.Select<ExternalLoginDto>(x => x.Id)
.From<ExternalLoginDto>()
.Where<ExternalLoginDto>(x => !x.LoginProvider.StartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix)) // Only remove external logins relating to backoffice users, not members.
.WhereNotIn<ExternalLoginDto>(x => x.LoginProvider, currentLoginProviders);
var toDelete = Database.Query<ExternalLoginDto>(sql).Select(x => x.Id).ToList();
DeleteExternalLogins(toDelete);
}
/// <inheritdoc />
public void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins)
{
@@ -94,13 +107,7 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
}
// do the deletes, updates and inserts
if (toDelete.Count > 0)
{
// Before we can remove the external login, we must remove the external login tokens associated with that external login,
// otherwise we'll get foreign key constraint errors
Database.DeleteMany<ExternalLoginTokenDto>().Where(x => toDelete.Contains(x.ExternalLoginId)).Execute();
Database.DeleteMany<ExternalLoginDto>().Where(x => toDelete.Contains(x.Id)).Execute();
}
DeleteExternalLogins(toDelete);
foreach (KeyValuePair<int, IExternalLogin> u in toUpdate)
{
@@ -110,6 +117,19 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
Database.InsertBulk(toInsert.Select(i => ExternalLoginFactory.BuildDto(userOrMemberKey, i)));
}
private void DeleteExternalLogins(List<int> externalLoginIds)
{
if (externalLoginIds.Count == 0)
{
return;
}
// Before we can remove the external login, we must remove the external login tokens associated with that external login,
// otherwise we'll get foreign key constraint errors
Database.DeleteMany<ExternalLoginTokenDto>().Where(x => externalLoginIds.Contains(x.ExternalLoginId)).Execute();
Database.DeleteMany<ExternalLoginDto>().Where(x => externalLoginIds.Contains(x.Id)).Execute();
}
/// <inheritdoc />
public void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens)
{
@@ -38,7 +38,6 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
private readonly ITagRepository _tagRepository;
private bool _passwordConfigInitialized;
private string? _passwordConfigJson;
private const string UsernameCacheKey = "uRepo_userNameKey+";
public MemberRepository(
IScopeAccessor scopeAccessor,
@@ -229,7 +228,7 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
}
public IMember? GetByUsername(string? username) =>
_memberByUsernameCachePolicy.GetByUserName(UsernameCacheKey, username, PerformGetByUsername, PerformGetAllByUsername);
_memberByUsernameCachePolicy.GetByUserName(CacheKeys.MemberUserNameCachePrefix, username, PerformGetByUsername, PerformGetAllByUsername);
public int[] GetMemberIds(string[] usernames)
{
@@ -511,7 +510,7 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
protected override void PersistDeletedItem(IMember entity)
{
_memberByUsernameCachePolicy.DeleteByUserName(UsernameCacheKey, entity.Username);
_memberByUsernameCachePolicy.DeleteByUserName(CacheKeys.MemberUserNameCachePrefix, entity.Username);
base.PersistDeletedItem(entity);
}
@@ -844,10 +843,55 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
OnUowRefreshedEntity(new MemberRefreshNotification(entity, new EventMessages()));
_memberByUsernameCachePolicy.DeleteByUserName(UsernameCacheKey, entity.Username);
_memberByUsernameCachePolicy.DeleteByUserName(CacheKeys.MemberUserNameCachePrefix, entity.Username);
entity.ResetDirtyProperties();
}
/// <inheritdoc/>
public async Task UpdateLoginPropertiesAsync(IMember member)
{
var updatedLastLoginDate = member.IsPropertyDirty(nameof(member.LastLoginDate));
var updatedSecurityStamp = member.IsPropertyDirty(nameof(member.SecurityStamp));
if (updatedLastLoginDate is false && updatedSecurityStamp is false)
{
return;
}
NPocoSqlExtensions.SqlUpd<MemberDto> GetMemberSetExpression(IMember member, NPocoSqlExtensions.SqlUpd<MemberDto> m)
{
var setExpression = new NPocoSqlExtensions.SqlUpd<MemberDto>(SqlContext);
if (updatedLastLoginDate)
{
setExpression.Set(x => x.LastLoginDate, member.LastLoginDate);
}
if (updatedSecurityStamp)
{
setExpression.Set(x => x.SecurityStampToken, member.SecurityStamp);
}
return setExpression;
}
member.UpdatingEntity();
Sql<ISqlContext> updateMemberQuery = Sql()
.Update<MemberDto>(m => GetMemberSetExpression(member, m))
.Where<MemberDto>(m => m.NodeId == member.Id);
await Database.ExecuteAsync(updateMemberQuery);
Sql<ISqlContext> updateContentVersionQuery = Sql()
.Update<ContentVersionDto>(m => m.Set(x => x.VersionDate, member.UpdateDate))
.Where<ContentVersionDto>(m => m.NodeId == member.Id && m.Current == true);
await Database.ExecuteAsync(updateContentVersionQuery);
OnUowRefreshedEntity(new MemberRefreshNotification(member, new EventMessages()));
_memberByUsernameCachePolicy.DeleteByUserName(CacheKeys.MemberUserNameCachePrefix, member.Username);
member.ResetDirtyProperties();
}
#endregion
}
@@ -128,10 +128,13 @@ internal class TagRepository : EntityRepositoryBase<int, ITag>, ITagRepository
var group = SqlSyntax.GetQuotedColumnName("group");
// insert tags
// - Note we are checking in the subquery for the existence of the tag, so we don't insert duplicates, using a case-insensitive comparison (the
// LOWER keyword is consistent across SQLite and SQLServer). This ensures consistent behavior across databases as by default, SQLServer will
// perform a case-insensitive comparison, while SQLite will not.
var sql1 = $@"INSERT INTO cmsTags (tag, {group}, languageId)
SELECT tagSet.tag, tagSet.{group}, tagSet.languageId
FROM {tagSetSql}
LEFT OUTER JOIN cmsTags ON (tagSet.tag = cmsTags.tag AND tagSet.{group} = cmsTags.{group} AND COALESCE(tagSet.languageId, -1) = COALESCE(cmsTags.languageId, -1))
LEFT OUTER JOIN cmsTags ON (LOWER(tagSet.tag) = LOWER(cmsTags.tag) AND LOWER(tagSet.{group}) = LOWER(cmsTags.{group}) AND COALESCE(tagSet.languageId, -1) = COALESCE(cmsTags.languageId, -1))
WHERE cmsTags.id IS NULL";
Database.Execute(sql1);
@@ -142,7 +145,7 @@ SELECT {contentId}, {propertyTypeId}, tagSet2.Id
FROM (
SELECT t.Id
FROM {tagSetSql}
INNER JOIN cmsTags as t ON (tagSet.tag = t.tag AND tagSet.{group} = t.{group} AND COALESCE(tagSet.languageId, -1) = COALESCE(t.languageId, -1))
INNER JOIN cmsTags as t ON (LOWER(tagSet.tag) = LOWER(t.tag) AND LOWER(tagSet.{group}) = LOWER(t.{group}) AND COALESCE(tagSet.languageId, -1) = COALESCE(t.languageId, -1))
) AS tagSet2
LEFT OUTER JOIN cmsTagRelationship r ON (tagSet2.id = r.tagId AND r.nodeId = {contentId} AND r.propertyTypeID = {propertyTypeId})
WHERE r.tagId IS NULL";
@@ -245,14 +248,18 @@ WHERE r.tagId IS NULL";
{
public bool Equals(ITag? x, ITag? y) =>
ReferenceEquals(x, y) // takes care of both being null
|| (x != null && y != null && x.Text == y.Text && x.Group == y.Group && x.LanguageId == y.LanguageId);
|| (x != null &&
y != null &&
string.Equals(x.Text, y.Text, StringComparison.OrdinalIgnoreCase) &&
string.Equals(x.Group, y.Group, StringComparison.OrdinalIgnoreCase) &&
x.LanguageId == y.LanguageId);
public int GetHashCode(ITag obj)
{
unchecked
{
var h = obj.Text.GetHashCode();
h = (h * 397) ^ obj.Group.GetHashCode();
var h = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Text);
h = (h * 397) ^ StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Group);
h = (h * 397) ^ (obj.LanguageId?.GetHashCode() ?? 0);
return h;
}
@@ -1053,13 +1053,25 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0
return sql;
}
public IEnumerable<IUser> GetNextUsers(int id, int count)
/// <inheritdoc/>
public IEnumerable<IUser> GetNextUsers(int id, int count) => PerformGetNextUsers(id, false, count);
/// <inheritdoc/>
public IEnumerable<IUser> GetNextApprovedUsers(int id, int count) => PerformGetNextUsers(id, true, count);
private IEnumerable<IUser> PerformGetNextUsers(int id, bool approvedOnly, int count)
{
Sql<ISqlContext> idsQuery = SqlContext.Sql()
.Select<UserDto>(x => x.Id)
.From<UserDto>()
.Where<UserDto>(x => x.Id >= id)
.OrderBy<UserDto>(x => x.Id);
.Where<UserDto>(x => x.Id >= id);
if (approvedOnly)
{
idsQuery = idsQuery.Where<UserDto>(x => x.Disabled == false);
}
idsQuery = idsQuery.OrderBy<UserDto>(x => x.Id);
// first page is index 1, not zero
var ids = Database.Page<int>(1, count, idsQuery).Items.ToArray();
@@ -1070,5 +1082,45 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0
: GetMany(ids).OrderBy(x => x.Id) ?? Enumerable.Empty<IUser>();
}
/// <inheritdoc />
public void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentLoginProviders)
{
// Get all the user or member keys associated with the removed providers.
Sql<ISqlContext> idsQuery = SqlContext.Sql()
.Select<ExternalLoginDto>(x => x.UserOrMemberKey)
.From<ExternalLoginDto>()
.WhereNotIn<ExternalLoginDto>(x => x.LoginProvider, currentLoginProviders);
List<Guid> userAndMemberKeysAssociatedWithRemovedProviders = Database.Fetch<Guid>(idsQuery);
if (userAndMemberKeysAssociatedWithRemovedProviders.Count == 0)
{
return;
}
// Filter for actual users and convert to integer IDs.
var userIdsAssociatedWithRemovedProviders = userAndMemberKeysAssociatedWithRemovedProviders
.Select(ConvertUserKeyToUserId)
.Where(x => x.HasValue)
.Select(x => x!.Value)
.ToList();
if (userIdsAssociatedWithRemovedProviders.Count == 0)
{
return;
}
// Invalidate the security stamps on the users associated with the removed providers.
Sql<ISqlContext> updateQuery = Sql()
.Update<UserDto>(u => u.Set(x => x.SecurityStampToken, "0".PadLeft(32, '0')))
.WhereIn<UserDto>(x => x.Id, userIdsAssociatedWithRemovedProviders);
Database.Execute(updateQuery);
}
private static int? ConvertUserKeyToUserId(Guid userOrMemberKey) =>
// User Ids are stored as integers in the umbracoUser table, but as a GUID representation
// of that integer in umbracoExternalLogin (converted via IntExtensions.ToGuid()).
// We need to parse that to get the user Ids to invalidate.
// Note also that umbracoExternalLogin contains members too, as proper GUIDs, so we need to ignore them.
IntExtensions.TryParseFromGuid(userOrMemberKey, out int? userId) ? userId : null;
#endregion
}
@@ -176,6 +176,7 @@ public class RichTextPropertyEditor : DataEditor
private readonly IJsonSerializer _jsonSerializer;
private readonly IBlockEditorElementTypeCache _elementTypeCache;
private readonly IRichTextRequiredValidator _richTextRequiredValidator;
private readonly IRichTextRegexValidator _richTextRegexValidator;
private readonly ILogger<RichTextPropertyValueEditor> _logger;
[Obsolete("Use non-obsolete constructor. This is schedules for removal in v16.")]
@@ -215,10 +216,11 @@ public class RichTextPropertyEditor : DataEditor
elementTypeCache,
propertyValidationService,
dataValueReferenceFactoryCollection,
StaticServiceProvider.Instance.GetRequiredService<IRichTextRequiredValidator>())
StaticServiceProvider.Instance.GetRequiredService<IRichTextRequiredValidator>(),
StaticServiceProvider.Instance.GetRequiredService<IRichTextRegexValidator>())
{
}
public RichTextPropertyValueEditor(
DataEditorAttribute attribute,
PropertyEditorCollection propertyEditors,
@@ -238,6 +240,49 @@ public class RichTextPropertyEditor : DataEditor
IPropertyValidationService propertyValidationService,
DataValueReferenceFactoryCollection dataValueReferenceFactoryCollection,
IRichTextRequiredValidator richTextRequiredValidator)
: this(
attribute,
propertyEditors,
dataTypeReadCache,
logger,
backOfficeSecurityAccessor,
localizedTextService,
shortStringHelper,
imageSourceParser,
localLinkParser,
pastedImages,
jsonSerializer,
ioHelper,
htmlSanitizer,
macroParameterParser,
elementTypeCache,
propertyValidationService,
dataValueReferenceFactoryCollection,
richTextRequiredValidator,
StaticServiceProvider.Instance.GetRequiredService<IRichTextRegexValidator>())
{
}
public RichTextPropertyValueEditor(
DataEditorAttribute attribute,
PropertyEditorCollection propertyEditors,
IDataTypeConfigurationCache dataTypeReadCache,
ILogger<RichTextPropertyValueEditor> logger,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
ILocalizedTextService localizedTextService,
IShortStringHelper shortStringHelper,
HtmlImageSourceParser imageSourceParser,
HtmlLocalLinkParser localLinkParser,
RichTextEditorPastedImages pastedImages,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
IHtmlSanitizer htmlSanitizer,
IHtmlMacroParameterParser macroParameterParser,
IBlockEditorElementTypeCache elementTypeCache,
IPropertyValidationService propertyValidationService,
DataValueReferenceFactoryCollection dataValueReferenceFactoryCollection,
IRichTextRequiredValidator richTextRequiredValidator,
IRichTextRegexValidator richTextRegexValidator)
: base(attribute, propertyEditors, dataTypeReadCache, localizedTextService, logger, shortStringHelper, jsonSerializer, ioHelper, dataValueReferenceFactoryCollection)
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
@@ -249,6 +294,7 @@ public class RichTextPropertyEditor : DataEditor
_macroParameterParser = macroParameterParser;
_elementTypeCache = elementTypeCache;
_richTextRequiredValidator = richTextRequiredValidator;
_richTextRegexValidator = richTextRegexValidator;
_jsonSerializer = jsonSerializer;
_logger = logger;
@@ -257,6 +303,8 @@ public class RichTextPropertyEditor : DataEditor
public override IValueRequiredValidator RequiredValidator => _richTextRequiredValidator;
public override IValueFormatValidator FormatValidator => _richTextRegexValidator;
/// <inheritdoc />
public override object? Configuration
{
@@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Extensions;
@@ -18,12 +18,20 @@ public static class RichTextPropertyEditorHelper
/// <returns>True if the parsing succeeds, false otherwise</returns>
/// <remarks>
/// The passed value can be:
/// - a <see cref="RichTextEditorValue"/> instance (which will be the case if the rich text property is hidden from the editor).
/// - a JSON string.
/// - a JSON object.
/// - a raw markup string (for backwards compatability).
/// </remarks>
public static bool TryParseRichTextEditorValue(object? value, IJsonSerializer jsonSerializer, ILogger logger, [NotNullWhen(true)] out RichTextEditorValue? richTextEditorValue)
{
if (value is RichTextEditorValue existingRichTextEditorValue)
{
// already a RichTextEditorValue instance
richTextEditorValue = existingRichTextEditorValue;
return true;
}
var stringValue = value as string ?? value?.ToString();
if (stringValue is null)
{
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Text.RegularExpressions;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
@@ -50,9 +51,11 @@ internal class RichTextPropertyIndexValueFactory : NestedPropertyIndexValueFacto
: null;
// index the stripped HTML values combined with "blocks values resume" value
var richTextWithoutMarkup = StripHtmlForIndexing(richTextEditorValue.Markup);
yield return new KeyValuePair<string, IEnumerable<object?>>(
property.Alias,
new object[] { $"{richTextEditorValue.Markup.StripHtml()} {blocksIndexValuesResume}" });
new object[] { $"{richTextWithoutMarkup} {blocksIndexValuesResume}" });
// store the raw value
yield return new KeyValuePair<string, IEnumerable<object?>>(
@@ -75,4 +78,28 @@ internal class RichTextPropertyIndexValueFactory : NestedPropertyIndexValueFacto
protected override IEnumerable<BlockItemData> GetDataItems(RichTextEditorValue input)
=> input.Blocks?.ContentData ?? new List<BlockItemData>();
/// <summary>
/// Strips HTML tags from content while preserving whitespace from line breaks.
/// This addresses the issue where &lt;br&gt; tags don't create word boundaries when HTML is stripped.
/// </summary>
/// <param name="html">The HTML content to strip</param>
/// <returns>Plain text with proper word boundaries</returns>
private static string StripHtmlForIndexing(string html)
{
if (string.IsNullOrWhiteSpace(html))
{
return string.Empty;
}
// Replace <br> and <br/> tags (with any amount of whitespace and attributes) with spaces
// This regex matches:
// - <br> (with / without spaces or attributes)
// - <br /> (with / without spaces or attributes)
html = Regex.Replace(html, @"<br\b[^>]*/?>\s*", " ", RegexOptions.IgnoreCase);
// Use the existing Microsoft StripHtml function for everything else
return html.StripHtml();
}
}
@@ -0,0 +1,5 @@
namespace Umbraco.Cms.Core.PropertyEditors.Validators;
internal interface IRichTextRegexValidator : IValueFormatValidator
{
}
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.PropertyEditors.Validators;
internal class RichTextRegexValidator : IRichTextRegexValidator
{
private readonly RegexValidator _regexValidator;
private readonly IJsonSerializer _jsonSerializer;
private readonly ILogger<RichTextRegexValidator> _logger;
public RichTextRegexValidator(
IJsonSerializer jsonSerializer,
ILogger<RichTextRegexValidator> logger,
RegexValidator regexValidator)
{
_jsonSerializer = jsonSerializer;
_logger = logger;
_regexValidator = regexValidator;
}
public IEnumerable<ValidationResult> ValidateFormat(object? value, string? valueType, string format) => _regexValidator.ValidateFormat(GetValue(value), valueType, format);
private object? GetValue(object? value) =>
RichTextPropertyEditorHelper.TryParseRichTextEditorValue(value, _jsonSerializer, _logger, out RichTextEditorValue? richTextEditorValue)
? richTextEditorValue?.Markup
: value;
}
@@ -110,7 +110,7 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
UpdateMemberProperties(memberEntity, user, out bool _);
// create the member
_memberService.Save(memberEntity);
_memberService.Save(memberEntity, PublishNotificationSaveOptions.Saving);
// We need to add roles now that the member has an Id. It do not work implicit in UpdateMemberProperties
_memberService.AssignRoles(
@@ -161,7 +161,7 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
}
/// <inheritdoc />
public override Task<IdentityResult> UpdateAsync(
public override async Task<IdentityResult> UpdateAsync(
MemberIdentityUser user,
CancellationToken cancellationToken = default)
{
@@ -189,9 +189,21 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.Logins));
var isTokensPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.LoginTokens));
if (UpdateMemberProperties(found, user, out var updateRoles))
IReadOnlyList<string> propertiesUpdated = UpdateMemberProperties(found, user, out var updateRoles);
if (propertiesUpdated.Count > 0)
{
_memberService.Save(found);
// As part of logging in members we update the last login date, and, if concurrent logins are disabled, the security stamp.
// If and only if we are updating these properties, we can avoid the overhead of a full save of the member with the associated
// locking, property updates, tag handling etc., and make a more efficient update.
if (UpdatingOnlyLoginProperties(propertiesUpdated))
{
await _memberService.UpdateLoginPropertiesAsync(found);
}
else
{
_memberService.Save(found);
}
if (updateRoles)
{
@@ -222,15 +234,21 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
}
scope.Complete();
return Task.FromResult(IdentityResult.Success);
return IdentityResult.Success;
}
catch (Exception ex)
{
return Task.FromResult(
IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = ex.Message }));
return IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = ex.Message });
}
}
private static bool UpdatingOnlyLoginProperties(IReadOnlyList<string> propertiesUpdated)
{
string[] loginPropertyUpdates = [nameof(MemberIdentityUser.LastLoginDateUtc), nameof(MemberIdentityUser.SecurityStamp)];
return (propertiesUpdated.Count == 2 && propertiesUpdated.ContainsAll(loginPropertyUpdates)) ||
(propertiesUpdated.Count == 1 && propertiesUpdated.ContainsAny(loginPropertyUpdates));
}
/// <inheritdoc />
public override Task<IdentityResult> DeleteAsync(
MemberIdentityUser user,
@@ -321,9 +339,20 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
throw new ArgumentNullException(nameof(userId));
}
IMember? user = Guid.TryParse(userId, out Guid key)
? _memberService.GetByKey(key)
: _memberService.GetById(UserIdToInt(userId));
// With external member providers we can get a ID here that's not a GUID or integer.
// We can't retrieve the member, but if that's the case we shouldn't throw an exception,
// just return null in the same way as when the member isn't found.
// See: https://github.com/umbraco/Umbraco-CMS/issues/14713
IMember? user = null;
if (Guid.TryParse(userId, out Guid key))
{
user = _memberService.GetByKey(key);
}
else if (TryUserIdToInt(userId, out int id))
{
user = _memberService.GetById(id);
}
if (user == null)
{
return Task.FromResult((MemberIdentityUser)null!)!;
@@ -670,9 +699,9 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
return user;
}
private bool UpdateMemberProperties(IMember member, MemberIdentityUser identityUser, out bool updateRoles)
private IReadOnlyList<string> UpdateMemberProperties(IMember member, MemberIdentityUser identityUser, out bool updateRoles)
{
var anythingChanged = false;
var updatedProperties = new List<string>();
updateRoles = false;
// don't assign anything if nothing has changed as this will trigger the track changes of the model
@@ -681,7 +710,7 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|| (identityUser.LastLoginDateUtc.HasValue &&
member.LastLoginDate?.ToUniversalTime() != identityUser.LastLoginDateUtc.Value))
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.LastLoginDateUtc));
// if the LastLoginDate is being set to MinValue, don't convert it ToLocalTime
DateTime dt = identityUser.LastLoginDateUtc == DateTime.MinValue
@@ -695,14 +724,14 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|| (identityUser.LastPasswordChangeDateUtc.HasValue && member.LastPasswordChangeDate?.ToUniversalTime() !=
identityUser.LastPasswordChangeDateUtc.Value))
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.LastPasswordChangeDateUtc));
member.LastPasswordChangeDate = identityUser.LastPasswordChangeDateUtc?.ToLocalTime() ?? DateTime.Now;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Comments))
&& member.Comments != identityUser.Comments && identityUser.Comments.IsNullOrWhiteSpace() == false)
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.Comments));
member.Comments = identityUser.Comments;
}
@@ -712,34 +741,34 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|| ((member.EmailConfirmedDate.HasValue == false || member.EmailConfirmedDate.Value == default) &&
identityUser.EmailConfirmed))
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.EmailConfirmed));
member.EmailConfirmedDate = identityUser.EmailConfirmed ? DateTime.Now : null;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Name))
&& member.Name != identityUser.Name && identityUser.Name.IsNullOrWhiteSpace() == false)
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.Name));
member.Name = identityUser.Name ?? string.Empty;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Email))
&& member.Email != identityUser.Email && identityUser.Email.IsNullOrWhiteSpace() == false)
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.Email));
member.Email = identityUser.Email!;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.AccessFailedCount))
&& member.FailedPasswordAttempts != identityUser.AccessFailedCount)
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.AccessFailedCount));
member.FailedPasswordAttempts = identityUser.AccessFailedCount;
}
if (member.IsLockedOut != identityUser.IsLockedOut)
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.IsLockedOut));
member.IsLockedOut = identityUser.IsLockedOut;
if (member.IsLockedOut)
@@ -751,14 +780,14 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
if (member.IsApproved != identityUser.IsApproved)
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.IsApproved));
member.IsApproved = identityUser.IsApproved;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.UserName))
&& member.Username != identityUser.UserName && identityUser.UserName.IsNullOrWhiteSpace() == false)
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.UserName));
member.Username = identityUser.UserName!;
}
@@ -766,33 +795,33 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
&& member.RawPasswordValue != identityUser.PasswordHash &&
identityUser.PasswordHash.IsNullOrWhiteSpace() == false)
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.PasswordHash));
member.RawPasswordValue = identityUser.PasswordHash;
member.PasswordConfiguration = identityUser.PasswordConfig;
}
if (member.PasswordConfiguration != identityUser.PasswordConfig)
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.PasswordConfig));
member.PasswordConfiguration = identityUser.PasswordConfig;
}
if (member.SecurityStamp != identityUser.SecurityStamp)
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.SecurityStamp));
member.SecurityStamp = identityUser.SecurityStamp;
}
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Roles)))
{
anythingChanged = true;
updatedProperties.Add(nameof(MemberIdentityUser.Roles));
updateRoles = true;
}
// reset all changes
identityUser.ResetDirtyProperties(false);
return anythingChanged;
return updatedProperties.AsReadOnly();
}
/// <inheritdoc />
@@ -321,17 +321,12 @@ public class ContentStore
{
if (_writeLock.CurrentCount != 0)
{
throw new InvalidOperationException("Write lock must be acquried.");
throw new InvalidOperationException("Write lock must be acquired.");
}
}
private void Lock(WriteLockInfo lockInfo, bool forceGen = false)
{
if (_writeLock.CurrentCount == 0)
{
throw new InvalidOperationException("Recursive locks not allowed");
}
if (_writeLock.Wait(_monitorTimeout))
{
lockInfo.Taken = true;
@@ -15,6 +15,7 @@ using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Infrastructure.HostedServices;
using Umbraco.Cms.Infrastructure.PublishedCache.DataSource;
using Umbraco.Cms.Infrastructure.PublishedCache.Persistence;
using Umbraco.Extensions;
@@ -31,6 +32,9 @@ internal class PublishedSnapshotService : IPublishedSnapshotService
// means faster execution, but uses memory - not sure if we want it
// so making it configurable.
public static readonly bool FullCacheWhenPreviewing = true;
private const string IsRebuildingDatabaseCacheRuntimeCacheKey = "temp_database_cache_rebuild_op";
private readonly NuCacheSettings _config;
private readonly ContentDataSerializer _contentDataSerializer;
private readonly IDefaultCultureAccessor _defaultCultureAccessor;
@@ -51,6 +55,8 @@ internal class PublishedSnapshotService : IPublishedSnapshotService
private readonly object _storesLock = new();
private readonly ISyncBootStateAccessor _syncBootStateAccessor;
private readonly IVariationContextAccessor _variationContextAccessor;
private readonly IBackgroundTaskQueue _backgroundTaskQueue;
private readonly IAppPolicyCache _runtimeCache;
private long _contentGen;
@@ -91,7 +97,9 @@ internal class PublishedSnapshotService : IPublishedSnapshotService
IPublishedModelFactory publishedModelFactory,
IHostingEnvironment hostingEnvironment,
IOptions<NuCacheSettings> config,
ContentDataSerializer contentDataSerializer)
ContentDataSerializer contentDataSerializer,
IBackgroundTaskQueue backgroundTaskQueue,
AppCaches appCaches)
{
_options = options;
_syncBootStateAccessor = syncBootStateAccessor;
@@ -111,6 +119,8 @@ internal class PublishedSnapshotService : IPublishedSnapshotService
_contentDataSerializer = contentDataSerializer;
_config = config.Value;
_publishedModelFactory = publishedModelFactory;
_backgroundTaskQueue = backgroundTaskQueue;
_runtimeCache = appCaches.RuntimeCache;
}
protected PublishedSnapshot? CurrentPublishedSnapshot
@@ -349,12 +359,66 @@ internal class PublishedSnapshotService : IPublishedSnapshotService
return new PublishedSnapshot(this, preview);
}
/// <inheritdoc />
public bool IsRebuilding() => _runtimeCache.Get(IsRebuildingDatabaseCacheRuntimeCacheKey) is not null;
/// <inheritdoc />
public void Rebuild(
IReadOnlyCollection<int>? contentTypeIds = null,
IReadOnlyCollection<int>? mediaTypeIds = null,
IReadOnlyCollection<int>? memberTypeIds = null)
=> _publishedContentService.Rebuild(contentTypeIds, mediaTypeIds, memberTypeIds);
=> Rebuild(false, contentTypeIds, mediaTypeIds, memberTypeIds);
/// <inheritdoc />
public void Rebuild(
bool useBackgroundThread,
IReadOnlyCollection<int>? contentTypeIds = null,
IReadOnlyCollection<int>? mediaTypeIds = null,
IReadOnlyCollection<int>? memberTypeIds = null)
{
if (useBackgroundThread)
{
_logger.LogInformation("Starting async background thread for rebuilding database cache.");
_backgroundTaskQueue.QueueBackgroundWorkItem(
cancellationToken =>
{
// Do not flow AsyncLocal to the child thread
using (ExecutionContext.SuppressFlow())
{
Task.Run(() => PerformRebuild(contentTypeIds, mediaTypeIds, memberTypeIds));
// immediately return so the request isn't waiting.
return Task.CompletedTask;
}
});
}
else
{
PerformRebuild(contentTypeIds, mediaTypeIds, memberTypeIds);
}
}
private void PerformRebuild(
IReadOnlyCollection<int>? contentTypeIds = null,
IReadOnlyCollection<int>? mediaTypeIds = null,
IReadOnlyCollection<int>? memberTypeIds = null)
{
try
{
SetIsRebuilding();
_publishedContentService.Rebuild(contentTypeIds, mediaTypeIds, memberTypeIds);
}
finally
{
ClearIsRebuilding();
}
}
private void SetIsRebuilding() => _runtimeCache.Insert(IsRebuildingDatabaseCacheRuntimeCacheKey, () => "tempValue", TimeSpan.FromMinutes(10));
private void ClearIsRebuilding() => _runtimeCache.Clear(IsRebuildingDatabaseCacheRuntimeCacheKey);
public async Task CollectAsync()
{
@@ -29,6 +29,11 @@ internal class PublishedSnapshotStatus : IPublishedSnapshotStatus
$"The current {typeof(IPublishedSnapshotService)} is not the default type. A status cannot be determined.";
}
if (_service.IsRebuilding())
{
return "Rebuild in progress. Please wait.";
}
// TODO: This should be private
_service.EnsureCaches();
@@ -74,6 +74,8 @@ public class AuthenticationController : UmbracoApiControllerBase
private readonly IUserService _userService;
private readonly WebRoutingSettings _webRoutingSettings;
private static long? _loginDurationAverage;
// TODO: We need to review all _userManager.Raise calls since many/most should be on the usermanager or signinmanager, very few should be here
[ActivatorUtilitiesConstructor]
public AuthenticationController(
@@ -415,42 +417,61 @@ public class AuthenticationController : UmbracoApiControllerBase
[Authorize(Policy = AuthorizationPolicies.DenyLocalLoginIfConfigured)]
public async Task<ActionResult<UserDetail?>> PostLogin(LoginModel loginModel)
{
// Start a timed scope to ensure failed responses return is a consistent time
var loginDuration = Math.Max(_loginDurationAverage ?? _securitySettings.UserDefaultFailedLoginDurationInMilliseconds, _securitySettings.UserMinimumFailedLoginDurationInMilliseconds);
await using var timedScope = new TimedScope(loginDuration, HttpContext.RequestAborted);
// Sign the user in with username/password, this also gives a chance for developers to
// custom verify the credentials and auto-link user accounts with a custom IBackOfficePasswordChecker
SignInResult result = await _signInManager.PasswordSignInAsync(
loginModel.Username, loginModel.Password, true, true);
if (result.Succeeded)
SignInResult result = await _signInManager.PasswordSignInAsync(loginModel.Username, loginModel.Password, true, true);
if (result.Succeeded is false)
{
// return the user detail
return GetUserDetail(_userService.GetByUsername(loginModel.Username));
}
BackOfficeIdentityUser? user = await _userManager.FindByNameAsync(loginModel.Username.Trim());
if (result.RequiresTwoFactor)
{
var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(loginModel.Username);
if (user is not null &&
await _userManager.CheckPasswordAsync(user, loginModel.Password))
{
// The credentials were correct, so cancel timed scope and provide a more detailed failure response
await timedScope.CancelAsync();
IUser? attemptedUser = _userService.GetByUsername(loginModel.Username);
// create a with information to display a custom two factor send code view
var verifyResponse =
new ObjectResult(new { twoFactorView = twofactorView, userId = attemptedUser?.Id })
if (result.RequiresTwoFactor)
{
StatusCode = StatusCodes.Status402PaymentRequired
};
var twofactorView = _backOfficeTwoFactorOptions.GetTwoFactorView(loginModel.Username);
return verifyResponse;
IUser? attemptedUser = _userService.GetByUsername(loginModel.Username);
// create a with information to display a custom two factor send code view
var verifyResponse =
new ObjectResult(new { twoFactorView = twofactorView, userId = attemptedUser?.Id })
{
StatusCode = StatusCodes.Status402PaymentRequired
};
return verifyResponse;
}
// TODO: We can check for these and respond differently if we think it's important
// result.IsLockedOut
// result.IsNotAllowed
}
// Return BadRequest (400), we don't want to return a 401 because that get's intercepted
// by our angular helper because it thinks that we need to re-perform the request once we are
// authorized and we don't want to return a 403 because angular will show a warning message indicating
// that the user doesn't have access to perform this function, we just want to return a normal invalid message.
return BadRequest();
}
// TODO: We can check for these and respond differently if we think it's important
// result.IsLockedOut
// result.IsNotAllowed
// Set initial or update average (successful) login duration
_loginDurationAverage = _loginDurationAverage is long average
? (average + (long)timedScope.Elapsed.TotalMilliseconds) / 2
: (long)timedScope.Elapsed.TotalMilliseconds;
// return BadRequest (400), we don't want to return a 401 because that get's intercepted
// by our angular helper because it thinks that we need to re-perform the request once we are
// authorized and we don't want to return a 403 because angular will show a warning message indicating
// that the user doesn't have access to perform this function, we just want to return a normal invalid message.
return BadRequest();
// Cancel the timed scope (we don't want to unnecessarily wait on a successful response)
await timedScope.CancelAsync();
// Return the user detail
return GetUserDetail(_userService.GetByUsername(loginModel.Username));
}
/// <summary>
@@ -230,10 +230,12 @@ public class BackOfficeController : UmbracoController
// sign the user in
DateTime? previousLastLoginDate = identityUser.LastLoginDateUtc;
var securityStamp = identityUser.SecurityStamp;
await _signInManager.SignInAsync(identityUser, false);
// reset the lastlogindate back to previous as the user hasn't actually logged in, to add a flag or similar to BackOfficeSignInManager would be a breaking change
// reset the lastlogindate and securitystamp back to previous as the user hasn't actually logged in, to add a flag or similar to BackOfficeSignInManager would be a breaking change
identityUser.LastLoginDateUtc = previousLastLoginDate;
identityUser.SecurityStamp = securityStamp;
await _userManager.UpdateAsync(identityUser);
return RedirectToLogin(new { flow = "invite-user", invite = "1" });
@@ -1002,6 +1002,7 @@ public class ContentController : ContentControllerBase
// if there's only one variant and the model state is not valid we cannot publish so change it to save
if (variantCount == 1)
{
switch (contentItem.Action)
{
case ContentSaveAction.Publish:
@@ -1147,25 +1148,6 @@ public class ContentController : ContentControllerBase
break;
case ContentSaveAction.PublishWithDescendants:
case ContentSaveAction.PublishWithDescendantsNew:
{
if (!await ValidatePublishBranchPermissionsAsync(contentItem))
{
globalNotifications.AddErrorNotification(
_localizedTextService.Localize(null, "publish"),
_localizedTextService.Localize("publish", "invalidPublishBranchPermissions"));
wasCancelled = false;
break;
}
var publishStatus = PublishBranchInternal(contentItem, false, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList();
var addedDomainWarnings = AddDomainWarnings(publishStatus, successfulCultures, globalNotifications, defaultCulture);
AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures);
if (addedDomainWarnings is false)
{
AddPublishRoutableErrorNotifications(publishStatus, globalNotifications, successfulCultures);
}
}
break;
case ContentSaveAction.PublishWithDescendantsForce:
case ContentSaveAction.PublishWithDescendantsForceNew:
{
@@ -1178,8 +1160,13 @@ public class ContentController : ContentControllerBase
break;
}
var publishStatus = PublishBranchInternal(contentItem, true, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList();
var publishStatus = PublishBranchInternal(contentItem, BuildPublishBranchFilter(contentItem.Action), cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList();
var addedDomainWarnings = AddDomainWarnings(publishStatus, successfulCultures, globalNotifications, defaultCulture);
AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures);
if (addedDomainWarnings is false)
{
AddPublishRoutableErrorNotifications(publishStatus, globalNotifications, successfulCultures);
}
}
break;
default:
@@ -1231,6 +1218,20 @@ public class ContentController : ContentControllerBase
return display;
}
private static PublishBranchFilter BuildPublishBranchFilter(ContentSaveAction contentSaveAction)
{
var includeUnpublished = contentSaveAction == ContentSaveAction.PublishWithDescendantsForce
|| contentSaveAction == ContentSaveAction.PublishWithDescendantsForceNew;
PublishBranchFilter publishBranchFilter = PublishBranchFilter.Default;
if (includeUnpublished)
{
publishBranchFilter |= PublishBranchFilter.IncludeUnpublished;
}
return publishBranchFilter;
}
private void AddPublishStatusNotifications(
IReadOnlyCollection<PublishResult> publishStatus,
SimpleNotificationModel globalNotifications,
@@ -1251,6 +1252,11 @@ public class ContentController : ContentControllerBase
SimpleNotificationModel globalNotifications,
string[]? successfulCultures)
{
if (_contentSettings.ShowUnroutableContentWarnings is false)
{
return;
}
IContent? content = publishStatus.FirstOrDefault()?.Content;
if (content is null)
{
@@ -1671,12 +1677,12 @@ public class ContentController : ContentControllerBase
return authorizationResult.Succeeded;
}
private IEnumerable<PublishResult> PublishBranchInternal(ContentItemSave contentItem, bool force, string? cultureForInvariantErrors, out bool wasCancelled, out string[]? successfulCultures)
private IEnumerable<PublishResult> PublishBranchInternal(ContentItemSave contentItem, PublishBranchFilter publishBranchFilter, string? cultureForInvariantErrors, out bool wasCancelled, out string[]? successfulCultures)
{
if (!contentItem.PersistedContent?.ContentType.VariesByCulture() ?? false)
{
//its invariant, proceed normally
IEnumerable<PublishResult> publishStatus = _contentService.SaveAndPublishBranch(contentItem.PersistedContent!, force, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1);
IEnumerable<PublishResult> publishStatus = _contentService.SaveAndPublishBranch(contentItem.PersistedContent!, publishBranchFilter, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1);
// TODO: Deal with multiple cancellations
wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent);
successfulCultures = null; //must be null! this implies invariant
@@ -1712,7 +1718,7 @@ public class ContentController : ContentControllerBase
{
//proceed to publish if all validation still succeeds
IEnumerable<PublishResult> publishStatus = _contentService.SaveAndPublishBranch(
contentItem.PersistedContent!, force, culturesToPublish.WhereNotNull().ToArray(), _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1);
contentItem.PersistedContent!, publishBranchFilter, culturesToPublish.WhereNotNull().ToArray(), _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1);
// TODO: Deal with multiple cancellations
wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent);
successfulCultures = contentItem.Variants.Where(x => x.Publish).Select(x => x.Culture).WhereNotNull()
@@ -1,20 +1,17 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Net.Mime;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Serialization;
@@ -37,6 +34,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
[PluginController(Constants.Web.Mvc.BackOfficeApiArea)]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes)]
[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))]
[ParameterSwapControllerActionSelector(nameof(GetReferences), "id", typeof(int), typeof(Guid))]
public class DataTypeController : BackOfficeNotificationsController
{
private readonly PropertyEditorCollection _propertyEditors;
@@ -51,6 +49,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IConfigurationEditorJsonSerializer _serializer;
private readonly IDataTypeUsageService _dataTypeUsageService;
private readonly IIdKeyMap _idKeyMap;
[Obsolete("Use constructor that takes IDataTypeUsageService, scheduled for removal in V12")]
public DataTypeController(
@@ -77,10 +76,42 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
localizedTextService,
backOfficeSecurityAccessor,
serializer,
StaticServiceProvider.Instance.GetRequiredService<IDataTypeUsageService>())
StaticServiceProvider.Instance.GetRequiredService<IDataTypeUsageService>(),
StaticServiceProvider.Instance.GetRequiredService<IIdKeyMap>())
{
}
[Obsolete("Use constructor that takes IDataTypeUsageService, scheduled for removal in V17")]
public DataTypeController(
PropertyEditorCollection propertyEditors,
IDataTypeService dataTypeService,
IOptionsSnapshot<ContentSettings> contentSettings,
IUmbracoMapper umbracoMapper,
PropertyEditorCollection propertyEditorCollection,
IContentTypeService contentTypeService,
IMediaTypeService mediaTypeService,
IMemberTypeService memberTypeService,
ILocalizedTextService localizedTextService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IConfigurationEditorJsonSerializer serializer,
IDataTypeUsageService dataTypeUsageService)
: this(
propertyEditors,
dataTypeService,
contentSettings,
umbracoMapper,
propertyEditorCollection,
contentTypeService,
mediaTypeService,
memberTypeService,
localizedTextService,
backOfficeSecurityAccessor,
serializer,
dataTypeUsageService,
StaticServiceProvider.Instance.GetRequiredService<IIdKeyMap>())
{
}
[ActivatorUtilitiesConstructor]
public DataTypeController(
PropertyEditorCollection propertyEditors,
@@ -94,7 +125,8 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
ILocalizedTextService localizedTextService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IConfigurationEditorJsonSerializer serializer,
IDataTypeUsageService dataTypeUsageService)
IDataTypeUsageService dataTypeUsageService,
IIdKeyMap entityService)
{
_propertyEditors = propertyEditors ?? throw new ArgumentNullException(nameof(propertyEditors));
_dataTypeService = dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService));
@@ -108,6 +140,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
_backOfficeSecurityAccessor = backOfficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backOfficeSecurityAccessor));
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
_dataTypeUsageService = dataTypeUsageService ?? throw new ArgumentNullException(nameof(dataTypeUsageService));
_idKeyMap = entityService ?? throw new ArgumentNullException(nameof(entityService));
}
/// <summary>
@@ -421,10 +454,10 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
}
/// <summary>
/// Returns the references (usages) for the data type
/// Returns the references (usages) for the data type.
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
/// <param name="id">Data type's integer Id.</param>
[HttpGet]
public DataTypeReferences GetReferences(int id)
{
var result = new DataTypeReferences();
@@ -462,6 +495,19 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
return result;
}
/// <summary>
/// Returns the references (usages) for the data type.
/// </summary>
/// <param name="id">Data type's key.</param>
[HttpGet]
public DataTypeReferences GetReferences(Guid id)
{
Attempt<int> dataType = _idKeyMap.GetIdForKey(id, UmbracoObjectTypes.DataType);
return dataType.Success
? GetReferences(dataType.Result)
: new DataTypeReferences();
}
[HttpGet]
public ActionResult<DataTypeHasValuesDisplay> HasValues(int id)
{
@@ -895,6 +895,7 @@ public class EntityController : UmbracoAuthorizedJsonController
// Filtering out child nodes after getting a paged result is an active choice here, even though the pagination might get off.
// This has been the case with this functionality in Umbraco for a long time.
.Where(entity => ignoreUserStartNodes ||
(objectType == UmbracoObjectTypes.Document || objectType == UmbracoObjectTypes.Media) is false ||
(ContentPermissions.IsInBranchOfStartNode(entity.Path, startNodeIds, startNodePaths, out var hasPathAccess) &&
hasPathAccess))
.Select(source =>
@@ -87,7 +87,7 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
[HttpGet]
public async Task<IActionResult> GetMacroResultAsHtmlForEditor(string macroAlias, int pageId,
[FromQuery] IDictionary<string, object> macroParams) =>
await GetMacroResultAsHtml(macroAlias, pageId, macroParams);
await GetMacroResultAsHtml(macroAlias, pageId.ToString(), macroParams);
/// <summary>
/// Gets a rendered macro as HTML for rendering in the rich text editor.
@@ -98,11 +98,24 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
/// <param name="model"></param>
/// <returns></returns>
[HttpPost]
[NonAction]
[Obsolete("This endpoint is no longer used.")]
public async Task<IActionResult> GetMacroResultAsHtmlForEditor(MacroParameterModel model) =>
await GetMacroResultAsHtml(model.MacroAlias, model.PageId.ToString(), model.MacroParams);
/// <summary>
/// Gets a rendered macro as HTML for rendering in the rich text editor.
/// Using HTTP POST instead of GET allows for more parameters to be passed as it's not dependent on URL-length
/// limitations like GET.
/// The method using GET is kept to maintain backwards compatibility
/// </summary>
/// <param name="model"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> GetMacroResultAsHtmlForEditor(MacroParameterModel2 model) =>
await GetMacroResultAsHtml(model.MacroAlias, model.PageId, model.MacroParams);
private async Task<IActionResult> GetMacroResultAsHtml(string? macroAlias, int pageId,
IDictionary<string, object>? macroParams)
private async Task<IActionResult> GetMacroResultAsHtml(string? macroAlias, string pageId, IDictionary<string, object>? macroParams)
{
IMacro? m = macroAlias is null ? null : _macroService.GetByAlias(macroAlias);
if (m == null)
@@ -111,11 +124,11 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
}
IUmbracoContext umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext();
IPublishedContent? publishedContent = umbracoContext.Content?.GetById(true, pageId);
IPublishedContent? publishedContent = GetPagePublishedContent(pageId, umbracoContext);
//if it isn't supposed to be rendered in the editor then return an empty string
//currently we cannot render a macro if the page doesn't yet exist
if (pageId == -1 || publishedContent == null || m.DontRender)
if (publishedContent == null || m.DontRender)
{
//need to create a specific content result formatted as HTML since this controller has been configured
//with only json formatters.
@@ -149,6 +162,21 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
}
}
private static IPublishedContent? GetPagePublishedContent(string pageId, IUmbracoContext umbracoContext)
{
if (int.TryParse(pageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int pageIdAsInt))
{
return umbracoContext.Content?.GetById(true, pageIdAsInt);
}
if (Guid.TryParse(pageId, out Guid pageIdAsGuid))
{
return umbracoContext.Content?.GetById(true, pageIdAsGuid);
}
return null;
}
[HttpPost]
public IActionResult CreatePartialViewMacroWithFile(CreatePartialViewMacroWithFileModel model)
{
@@ -180,6 +208,7 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
return Ok();
}
[Obsolete("This model is no longer used and has been replaced with MacroParameterModel2 that changes the type of the PageId property.")]
public class MacroParameterModel
{
public string? MacroAlias { get; set; }
@@ -187,6 +216,13 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
public IDictionary<string, object>? MacroParams { get; set; }
}
public class MacroParameterModel2
{
public string? MacroAlias { get; set; }
public string PageId { get; set; } = string.Empty;
public IDictionary<string, object>? MacroParams { get; set; }
}
public class CreatePartialViewMacroWithFileModel
{
public string? Filename { get; set; }
@@ -217,10 +217,8 @@ public partial class PreviewController : Controller
// are we attempting a redirect to the default route (by ID with optional culture)?
Match match = DefaultPreviewRedirectRegex().Match(redir ?? string.Empty);
if (match.Success)
if (match.Success && int.TryParse(match.Groups["id"].Value, out int id))
{
var id = int.Parse(match.Groups["id"].Value);
// first try to resolve the published URL
if (_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext) &&
umbracoContext.Content is not null)
@@ -37,13 +37,32 @@ public class PublishedSnapshotCacheStatusController : UmbracoAuthorizedApiContro
[HttpPost]
public string RebuildDbCache()
{
//Rebuild All
if (_publishedSnapshotService.IsRebuilding())
{
return "Rebuild already in progress.";
}
_publishedSnapshotService.RebuildAll();
return _publishedSnapshotStatus.GetStatus();
}
/// <summary>
/// Gets a status report
/// Rebuilds the Database cache on a background thread.
/// </summary>
[HttpPost]
public IActionResult RebuildDbCacheInBackground()
{
if (_publishedSnapshotService.IsRebuilding())
{
return BadRequest("Rebuild already in progress.");
}
_publishedSnapshotService.RebuildAll(true);
return Ok();
}
/// <summary>
/// Gets a status report.
/// </summary>
[HttpGet]
public string GetStatus() => _publishedSnapshotStatus.GetStatus();
@@ -664,10 +664,11 @@ public class UsersController : BackOfficeNotificationsController
var emailSubject = _localizedTextService.Localize("user", "inviteEmailCopySubject",
// Ensure the culture of the found user is used for the email!
UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings));
var name = userDisplay is null ? string.Empty : System.Web.HttpUtility.HtmlEncode(userDisplay.Name);
var emailBody = _localizedTextService.Localize("user", "inviteEmailCopyFormat",
// Ensure the culture of the found user is used for the email!
UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings),
new[] { userDisplay?.Name, from, WebUtility.HtmlEncode(message)!.ReplaceLineEndings("<br/>"), inviteUri.ToString(), senderEmail });
new[] { name, from, WebUtility.HtmlEncode(message)!.ReplaceLineEndings("<br/>"), inviteUri.ToString(), senderEmail });
// This needs to be in the correct mailto format including the name, else
// the name cannot be captured in the email sending notification.
@@ -7,6 +7,7 @@ using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Web.BackOffice.Authorization;
using Umbraco.Cms.Web.BackOffice.Middleware;
using Umbraco.Cms.Web.BackOffice.NotificationHandlers;
using Umbraco.Cms.Web.BackOffice.Security;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Cms.Web.Common.Security;
@@ -65,6 +66,8 @@ public static partial class UmbracoBuilderExtensions
builder.AddNotificationHandler<UserPasswordChangedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserPasswordResetNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UmbracoApplicationStartingNotification, ExternalLoginProviderStartupHandler>();
return builder;
}
@@ -0,0 +1,40 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Web.BackOffice.Security;
namespace Umbraco.Cms.Web.BackOffice.NotificationHandlers;
/// <summary>
/// Invalidates backoffice sessions and clears external logins for removed providers if the external login
/// provider setup has changed.
/// </summary>
internal sealed class ExternalLoginProviderStartupHandler : INotificationHandler<UmbracoApplicationStartingNotification>
{
private readonly IBackOfficeExternalLoginProviders _backOfficeExternalLoginProviders;
private readonly IRuntimeState _runtimeState;
private readonly IServerRoleAccessor _serverRoleAccessor;
public ExternalLoginProviderStartupHandler(
IBackOfficeExternalLoginProviders backOfficeExternalLoginProviders,
IRuntimeState runtimeState,
IServerRoleAccessor serverRoleAccessor)
{
_backOfficeExternalLoginProviders = backOfficeExternalLoginProviders;
_runtimeState = runtimeState;
_serverRoleAccessor = serverRoleAccessor;
}
public void Handle(UmbracoApplicationStartingNotification notification)
{
if (_runtimeState.Level != RuntimeLevel.Run ||
_serverRoleAccessor.CurrentServerRole == ServerRole.Subscriber)
{
return;
}
_backOfficeExternalLoginProviders.InvalidateSessionsIfExternalLoginProvidersChanged();
}
}
@@ -1,4 +1,8 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Web.BackOffice.Security;
@@ -7,13 +11,41 @@ public class BackOfficeExternalLoginProviders : IBackOfficeExternalLoginProvider
{
private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider;
private readonly Dictionary<string, BackOfficeExternalLoginProvider> _externalLogins;
private readonly IKeyValueService _keyValueService;
private readonly IExternalLoginWithKeyService _externalLoginWithKeyService;
private readonly IUserService _userService;
private readonly ILogger<BackOfficeExternalLoginProviders> _logger;
private const string ExternalLoginProvidersKey = "Umbraco.Cms.Web.BackOffice.Security.BackOfficeExternalLoginProviders";
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public BackOfficeExternalLoginProviders(
IEnumerable<BackOfficeExternalLoginProvider> externalLogins,
IAuthenticationSchemeProvider authenticationSchemeProvider)
: this(
externalLogins,
authenticationSchemeProvider,
StaticServiceProvider.Instance.GetRequiredService<IKeyValueService>(),
StaticServiceProvider.Instance.GetRequiredService<IExternalLoginWithKeyService>(),
StaticServiceProvider.Instance.GetRequiredService<IUserService>(),
StaticServiceProvider.Instance.GetRequiredService<ILogger<BackOfficeExternalLoginProviders>>())
{
}
public BackOfficeExternalLoginProviders(
IEnumerable<BackOfficeExternalLoginProvider> externalLogins,
IAuthenticationSchemeProvider authenticationSchemeProvider,
IKeyValueService keyValueService,
IExternalLoginWithKeyService externalLoginWithKeyService,
IUserService userService,
ILogger<BackOfficeExternalLoginProviders> logger)
{
_externalLogins = externalLogins.ToDictionary(x => x.AuthenticationType);
_authenticationSchemeProvider = authenticationSchemeProvider;
_keyValueService = keyValueService;
_externalLoginWithKeyService = externalLoginWithKeyService;
_userService = userService;
_logger = logger;
}
/// <inheritdoc />
@@ -66,4 +98,26 @@ public class BackOfficeExternalLoginProviders : IBackOfficeExternalLoginProvider
var found = _externalLogins.Values.Where(x => x.Options.DenyLocalLogin).ToList();
return found.Count > 0;
}
/// <inheritdoc />
public void InvalidateSessionsIfExternalLoginProvidersChanged()
{
var previousExternalLoginProvidersValue = _keyValueService.GetValue(ExternalLoginProvidersKey);
var currentExternalLoginProvidersValue = string.Join("|", _externalLogins.Keys.OrderBy(key => key));
if ((previousExternalLoginProvidersValue ?? string.Empty) != currentExternalLoginProvidersValue)
{
_logger.LogWarning(
"The configured external login providers have changed. Existing backoffice sessions using the removed providers will be invalidated and external login data removed.");
_userService.InvalidateSessionsForRemovedProviders(_externalLogins.Keys);
_externalLoginWithKeyService.DeleteUserLoginsForRemovedProviders(_externalLogins.Keys);
_keyValueService.SetValue(ExternalLoginProvidersKey, currentExternalLoginProvidersValue);
}
else if (previousExternalLoginProvidersValue is null)
{
_keyValueService.SetValue(ExternalLoginProvidersKey, string.Empty);
}
}
}
@@ -30,4 +30,11 @@ public interface IBackOfficeExternalLoginProviders
/// </summary>
/// <returns></returns>
bool HasDenyLocalLogin();
/// <summary>
/// Used during startup to see if the configured external login providers is different from the persisted information.
/// If they are different, this will invalidate backoffice sessions and clear external logins for removed providers
/// if the external login provider setup has changed.
/// </summary>
void InvalidateSessionsIfExternalLoginProvidersChanged() { }
}
@@ -98,16 +98,6 @@ public class StaticFilesTreeController : TreeController
private void AddPhysicalFiles(string path, FormCollection queryStrings, TreeNodeCollection nodes)
{
IEnumerable<string> files = _fileSystem.GetFiles(path)
.Where(x => x.StartsWith(AppPlugins) || x.StartsWith(Webroot));
foreach (var file in files)
{
var name = Path.GetFileName(file);
TreeNode node = CreateTreeNode(WebUtility.UrlEncode(file), path, queryStrings, name, Constants.Icons.DefaultIcon, false);
nodes.Add(node);
}
IEnumerable<string> directories = _fileSystem.GetDirectories(path);
foreach (var directory in directories)
@@ -117,6 +107,16 @@ public class StaticFilesTreeController : TreeController
TreeNode node = CreateTreeNode(WebUtility.UrlEncode(directory), path, queryStrings, name, Constants.Icons.Folder, hasChildren);
nodes.Add(node);
}
IEnumerable<string> files = _fileSystem.GetFiles(path)
.Where(x => x.StartsWith(AppPlugins) || x.StartsWith(Webroot));
foreach (var file in files)
{
var name = Path.GetFileName(file);
TreeNode node = CreateTreeNode(WebUtility.UrlEncode(file), path, queryStrings, name, Constants.Icons.DefaultIcon, false);
nodes.Add(node);
}
}
private void AddWebRootFiles(string path, FormCollection queryStrings, TreeNodeCollection nodes)
@@ -68,7 +68,13 @@ public static class HttpContextExtensions
// Otherwise we can't log in as both a member and a backoffice user
// For instance if you've enabled basic auth.
ClaimsPrincipal? authenticatedPrincipal = result.Principal;
IEnumerable<ClaimsIdentity> existingIdentities = httpContext.User.Identities.Where(x => x.IsAuthenticated && x.AuthenticationType != authenticatedPrincipal.Identity.AuthenticationType);
// Make sure to copy into a list before attempting to update the authenticated principal, so we don't attempt to modify
// the collection while iterating it.
// See: https://github.com/umbraco/Umbraco-CMS/issues/18509
var existingIdentities = httpContext.User.Identities
.Where(x => x.IsAuthenticated && x.AuthenticationType != authenticatedPrincipal.Identity.AuthenticationType)
.ToList();
authenticatedPrincipal.AddIdentities(existingIdentities);
httpContext.User = authenticatedPrincipal;
@@ -35,7 +35,7 @@ public class ConfigureSecurityStampOptions : IConfigureOptions<SecurityStampVali
// Adjust the security stamp validation interval to a shorter duration
// when concurrent logins are not allowed and the duration has the default interval value
// (currently defaults to 30 minutes), ensuring quicker re-validation.
if (securitySettings.AllowConcurrentLogins is false && options.ValidationInterval == TimeSpan.FromMinutes(30))
if (securitySettings.AllowConcurrentLogins is false && options.ValidationInterval == new SecurityStampValidatorOptions().ValidationInterval)
{
options.ValidationInterval = TimeSpan.FromSeconds(30);
}
@@ -15,7 +15,7 @@ tinymce.addI18n('da',{
"Strikethrough": "Gennemstreg",
"Superscript": "H\u00e6vet",
"Subscript": "S\u00e6nket",
"Clear formatting": "Nulstil formattering",
"Clear formatting": "Nulstil formatering",
"Align left": "Venstrejusteret",
"Align center": "Centreret",
"Align right": "H\u00f8jrejusteret",
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -19,8 +19,8 @@
},
"dependencies": {
"@microsoft/signalr": "8.0.7",
"@umbraco-ui/uui": "1.12.2",
"@umbraco-ui/uui-css": "1.12.1",
"@umbraco-ui/uui": "1.13.0",
"@umbraco-ui/uui-css": "1.13.0",
"ace-builds": "1.31.1",
"angular": "1.8.3",
"angular-animate": "1.8.3",
@@ -1033,9 +1033,12 @@
$scope.content.variants.forEach(variant => variant.save = false);
//ensure the save flag is set for the active variant
selectedVariant.save = true;
$scope.page.previewButtonState = "busy";
performSave({ saveMethod: $scope.saveMethod(), action: "save" }).then(function (data) {
$scope.page.previewButtonState = "success";
openPreviewWindow(url, urlTarget);
}, function (err) {
$scope.page.previewButtonState = "error";
//validation issues ....
});
}
@@ -1003,13 +1003,13 @@ function contentResource($q, $http, umbDataFormatter, umbRequestHelper) {
* @returns {Promise} resourcePromise object containing the saved content item.
*
*/
publishWithDescendants: function (content, isNew, force, files, showNotifications) {
publishWithDescendants: function (content, isNew, includeUnpublished, files, showNotifications) {
var endpoint = umbRequestHelper.getApiUrl(
"contentApiBaseUrl",
"PostSave");
var action = "publishWithDescendants";
if (force === true) {
if (includeUnpublished === true) {
action += "Force";
}
@@ -89,11 +89,16 @@
.umb-user-card__name {
font-size: 15px;
font-weight: bold;
text-align: center;
margin-bottom: 2px;
word-wrap: break-word;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: bold;
text-align: center;
margin-bottom: 2px;
word-wrap: break-word;
}
.umb-user-card__group {
@@ -107,3 +112,12 @@
text-align: center;
margin-top: auto;
}
.umb-user-name__last-login {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-wrap: break-word;
}
@@ -25,7 +25,8 @@
title="{{ngModel}}"
focus-when="{{!locked}}"
umb-select-when="{{!locked}}"
ng-blur="lock()" />
ng-blur="lock()"
ng-maxlength="255" />
</div>
@@ -46,6 +47,11 @@
ng-if="serverValidationField.length > 0"
ng-message="valServerField">{{lockedFieldForm.lockedField.errorMsg}}
</div>
<div class="umb-validation-label"
ng-class="{ '-left': validationPosition === 'left', '-right': validationPosition === 'right' }"
ng-if="ngModel.length > 255">
<localize key="general_invalid">Invalid</localize> <localize key="content_alias">alias</localize>
</div>
</div>
</ng-form>
@@ -28,11 +28,6 @@
vm.labels.includeUnpublished = value;
});
}
if (!vm.labels.includeUnpublished) {
localizationService.localize("content_includeUnpublished").then(value => {
vm.labels.includeUnpublished = value;
});
}
vm.variants.forEach(variant => {
variant.isMandatory = isMandatoryFilter(variant);
@@ -69,7 +64,6 @@
function toggleIncludeUnpublished() {
vm.includeUnpublished = !vm.includeUnpublished;
// make sure this value is pushed back to the scope
$scope.model.includeUnpublished = vm.includeUnpublished;
}
@@ -16,7 +16,6 @@
show-labels="true">
</umb-toggle>
</div>
</div>
<div ng-if="vm.displayVariants.length > 1">
@@ -1,4 +1,4 @@
function publishedSnapshotCacheController($scope, $http, umbRequestHelper, localizationService, overlayService) {
function publishedSnapshotCacheController($scope, $http, umbRequestHelper, localizationService, overlayService) {
var vm = this;
@@ -94,12 +94,23 @@
vm.working = true;
umbRequestHelper.resourcePromise(
$http.post(umbRequestHelper.getApiUrl("publishedSnapshotCacheStatusBaseUrl", "RebuildDbCache")),
'Failed to rebuild the cache.')
.then(function (result) {
vm.working = false;
vm.status = result;
});
$http.post(umbRequestHelper.getApiUrl("publishedSnapshotCacheStatusBaseUrl", "RebuildDbCacheInBackground")), "Failed to queue the rebuild task.")
.then(function () {
const interval = setInterval(function () {
$http.get(umbRequestHelper.getApiUrl("publishedSnapshotCacheStatusBaseUrl", "GetStatus"))
.then(function (result) {
if (!result.data.toString().startsWith("Rebuild in progress")) {
vm.working = false;
vm.status = result.data;
clearInterval(interval);
}
}, function () {
vm.working = false;
vm.status = "Could not retrieve rebuild cache status";
});
}, 2000);
});
}
function init() {
@@ -19,7 +19,7 @@ function MemberDeleteController($scope, memberResource, treeService, navigationS
treeService.removeNode($scope.currentNode);
//if the current edited item is the same one as we're deleting, we need to navigate elsewhere
if (editorState.current && editorState.current.key == $scope.currentNode.id) {
if (editorState.current && editorState.current.key.replace(/-/g, "") == $scope.currentNode.id) {
$location.path("/member/member/list/" + ($routeParams.listName ? $routeParams.listName : 'all-members'));
}
@@ -22,6 +22,8 @@
user-select: none;
border: none;
transition: border-color 120ms, background-color 120ms;
font-size:15px;
font-family: Lato, Helvetica Neue, Helvetica, Arial, sans-serif;
}
.blockelement-gridblock-editor > button:hover {
color: #2152A3;
@@ -37,6 +37,9 @@
let copyAllBlocksAction = null;
let deleteAllBlocksAction = null;
let pasteSingleBlockAction = null;
let resetSingleBlock = null;
let scopeOfExistence = null;
var inlineEditing = false;
var liveEditing = true;
@@ -124,12 +127,12 @@
vm.listWrapperStyles['max-width'] = vm.model.config.maxPropertyWidth;
}
// We need to ensure that the property model value is an object, this is needed for modelObject to recive a reference and keep that updated.
// We need to ensure that the property model value is an object, this is needed for modelObject to receive a reference and keep that updated.
if (typeof vm.model.value !== 'object' || vm.model.value === null) {// testing if we have null or undefined value or if the value is set to another type than Object.
vm.model.value = {};
}
var scopeOfExistence = $scope;
scopeOfExistence = $scope;
if (vm.umbVariantContentEditors && vm.umbVariantContentEditors.getScope) {
scopeOfExistence = vm.umbVariantContentEditors.getScope();
} else if(vm.umbElementEditorContent && vm.umbElementEditorContent.getScope) {
@@ -179,9 +182,18 @@
useLegacyIcon: false
};
resetSingleBlock = {
labelKey: "content_removeItem",
labelTokens: [],
icon: "icon-trash",
method: requestResetSingleBlock,
isDisabled: false,
useLegacyIcon: false
};
var propertyActions = [copyAllBlocksAction, deleteAllBlocksAction];
var propertyActionsForSingleBlockMode = [pasteSingleBlockAction];
var propertyActionsForSingleBlockMode = [pasteSingleBlockAction, resetSingleBlock];
if (vm.umbProperty) {
if (vm.singleBlockMode) {
@@ -844,6 +856,24 @@
});
}
function requestResetSingleBlock() {
localizationService.localizeMany(["content_nestedContentDeleteItem", "general_delete"]).then(function (data) {
overlayService.confirmDelete({
title: data[1],
content: data[0],
close: function () {
overlayService.close();
},
submit: function () {
deleteAllBlocks();
modelObject = blockEditorService.createModelObject(vm.model.value, vm.model.editor, vm.model.config.blocks, scopeOfExistence, $scope);
modelObject.load().then(onLoaded);
overlayService.close();
}
});
});
}
function openSettingsForBlock(block, blockIndex, parentForm) {
editBlock(block, true, blockIndex, parentForm);
}
@@ -111,7 +111,7 @@ function dateTimePickerController($scope, angularHelper, dateHelper, validationM
// $scope.hasDatetimePickerValue indicates that we had a value before the input was changed,
// but now the input is empty.
$scope.clearDate();
} else if ($scope.model.datetimePickerValue) {
} else if ($scope.model.datetimePickerInputValue) {
var momentDate = moment($scope.model.datetimePickerInputValue, $scope.model.config.format, true);
if (!momentDate || !momentDate.isValid()) {
momentDate = moment(new Date($scope.model.datetimePickerInputValue));
@@ -120,7 +120,7 @@ function dateTimePickerController($scope, angularHelper, dateHelper, validationM
setDate(momentDate);
}
setDatePickerVal();
flatPickr.setDate($scope.model.datetimePickerValue, false);
flatPickr.setDate($scope.model.datetimePickerInputValue, false);
}
}
@@ -196,7 +196,7 @@
<umb-avatar size="l" color="secondary" name="{{user.name}}" img-src="{{user.avatars[2]}}" img-srcset="{{user.avatars[3]}} 2x, {{user.avatars[4]}} 3x">
</umb-avatar>
</div>
<div class="umb-user-card__name">{{user.name}}</div>
<div title="{{user.name}}" class="umb-user-card__name">{{user.name}}</div>
</a>
<div class="umb-user-card__group">
<span ng-repeat="userGroup in user.userGroups">{{ userGroup.name }}<span ng-if="!$last">, </span></span>
@@ -209,9 +209,10 @@
{{ user.formattedLastLogin }}
</div>
<div ng-if="!user.formattedLastLogin">
<div>{{ user.name | umbWordLimit:1 }}
<localize key="user_noLogin">has not logged in yet</localize>
</div>
<div class="umb-user-name__last-login" title="{{ user.name | umbWordLimit:1 }}">
{{ user.name | umbWordLimit:1 }}
</div>
<localize key="user_noLogin">has not logged in yet</localize>
</div>
</div>
</div>
+2377 -2365
View File
File diff suppressed because it is too large Load Diff
+25 -25
View File
@@ -1,29 +1,29 @@
{
"name": "login",
"private": true,
"name": "login",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"watch": "tsc && vite build --watch",
"preview": "vite preview"
},
"engines": {
"node": ">=20.8",
"npm": ">=10.1"
},
"dependencies": {
"lit": "^3.1.2",
"msw": "^2.2.0",
"rxjs": "^7.8.1"
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"watch": "tsc && vite build --watch",
"preview": "vite preview"
},
"devDependencies": {
"@umbraco-ui/uui": "1.12.2",
"@umbraco-ui/uui-css": "1.12.1",
"typescript": "^5.3.3",
"vite": "^5.1.7"
},
"msw": {
"workerDirectory": "public"
}
"engines": {
"node": ">=20.8",
"npm": ">=10.1"
},
"dependencies": {
"lit": "^3.1.2",
"msw": "^2.2.0",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@umbraco-ui/uui": "1.13.0",
"@umbraco-ui/uui-css": "1.13.0",
"typescript": "^5.3.3",
"vite": "^5.1.7"
},
"msw": {
"workerDirectory": "public"
}
}
@@ -0,0 +1,38 @@
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
using Umbraco.Extensions;
namespace Umbraco.Cms.Web.Website.Cache.PartialViewCacheInvalidators;
/// <summary>
/// Implementation of <see cref="IMemberPartialViewCacheInvalidator"/> that only remove cached partial views
/// that were cached for the specified member(s).
/// </summary>
public class MemberPartialViewCacheInvalidator : IMemberPartialViewCacheInvalidator
{
private readonly AppCaches _appCaches;
/// <summary>
/// Initializes a new instance of the <see cref="MemberPartialViewCacheInvalidator"/> class.
/// </summary>
public MemberPartialViewCacheInvalidator(AppCaches appCaches) => _appCaches = appCaches;
/// <inheritdoc/>
/// <remarks>
/// Partial view cache keys follow the following format:
/// [] is optional or only added if the information is available
/// {} is a parameter
/// "Umbraco.Web.PartialViewCacheKey{partialViewName}-[{currentThreadCultureName}-][m{memberId}-][c{contextualKey}-]"
/// See <see cref="HtmlHelperRenderExtensions.CachedPartialAsync"/> for more information.
/// </remarks>
public void ClearPartialViewCacheItems(IEnumerable<int> memberIds)
{
foreach (var memberId in memberIds)
{
_appCaches.RuntimeCache.ClearByRegex($"{CoreCacheHelperExtensions.PartialViewCacheKey}.*-m{memberId}-*");
}
// since it is possible to add a cache item linked to members without a member logged in, we should always clear these items.
_appCaches.RuntimeCache.ClearByRegex($"{CoreCacheHelperExtensions.PartialViewCacheKey}.*-m-*");
}
}
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Routing;
@@ -13,6 +14,7 @@ using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Infrastructure.DependencyInjection;
using Umbraco.Cms.Web.Common.Middleware;
using Umbraco.Cms.Web.Common.Routing;
using Umbraco.Cms.Web.Website.Cache.PartialViewCacheInvalidators;
using Umbraco.Cms.Web.Website.Collections;
using Umbraco.Cms.Web.Website.Models;
using Umbraco.Cms.Web.Website.Routing;
@@ -74,6 +76,9 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IPublicAccessRequestHandler, PublicAccessRequestHandler>();
builder.Services.AddSingleton<BasicAuthenticationMiddleware>();
// Partial view cache invalidators
builder.Services.AddUnique<IMemberPartialViewCacheInvalidator, MemberPartialViewCacheInvalidator>();
builder
.AddDistributedCache()
.AddModelsBuilder();
@@ -104,42 +104,18 @@ public static class HtmlHelperRenderExtensions
ViewDataDictionary? viewData = null,
Func<object, ViewDataDictionary?, string>? contextualKeyBuilder = null)
{
var cacheKey = new StringBuilder(partialViewName);
// let's always cache by the current culture to allow variants to have different cache results
var cultureName = Thread.CurrentThread.CurrentUICulture.Name;
if (!string.IsNullOrEmpty(cultureName))
{
cacheKey.AppendFormat("{0}-", cultureName);
}
IUmbracoContextAccessor umbracoContextAccessor = GetRequiredService<IUmbracoContextAccessor>(htmlHelper);
umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext);
if (cacheByPage)
{
if (umbracoContext == null)
{
throw new InvalidOperationException(
"Cannot cache by page if the UmbracoContext has not been initialized, this parameter can only be used in the context of an Umbraco request");
}
cacheKey.AppendFormat("{0}-", umbracoContext.PublishedRequest?.PublishedContent?.Id ?? 0);
}
if (cacheByMember)
{
IMemberManager memberManager =
htmlHelper.ViewContext.HttpContext.RequestServices.GetRequiredService<IMemberManager>();
MemberIdentityUser? currentMember = await memberManager.GetCurrentMemberAsync();
cacheKey.AppendFormat("m{0}-", currentMember?.Id ?? "0");
}
if (contextualKeyBuilder != null)
{
var contextualKey = contextualKeyBuilder(model, viewData);
cacheKey.AppendFormat("c{0}-", contextualKey);
}
string cacheKey = await GenerateCacheKeyForCachedPartialViewAsync(
partialViewName,
cacheByPage,
umbracoContext,
cacheByMember,
cacheByMember ? GetRequiredService<IMemberManager>(htmlHelper) : null,
model,
viewData,
contextualKeyBuilder);
AppCaches appCaches = GetRequiredService<AppCaches>(htmlHelper);
IHostingEnvironment hostingEnvironment = GetRequiredService<IHostingEnvironment>(htmlHelper);
@@ -155,6 +131,58 @@ public static class HtmlHelperRenderExtensions
viewData);
}
// Internal for tests.
internal static async Task<string> GenerateCacheKeyForCachedPartialViewAsync(
string partialViewName,
bool cacheByPage,
IUmbracoContext? umbracoContext,
bool cacheByMember,
IMemberManager? memberManager,
object model,
ViewDataDictionary? viewData,
Func<object, ViewDataDictionary?, string>? contextualKeyBuilder)
{
var cacheKey = new StringBuilder(partialViewName + "-");
// let's always cache by the current culture to allow variants to have different cache results
var cultureName = Thread.CurrentThread.CurrentUICulture.Name;
if (!string.IsNullOrEmpty(cultureName))
{
cacheKey.AppendFormat("{0}-", cultureName);
}
if (cacheByPage)
{
if (umbracoContext == null)
{
throw new InvalidOperationException(
"Cannot cache by page if the UmbracoContext has not been initialized, this parameter can only be used in the context of an Umbraco request.");
}
cacheKey.AppendFormat("{0}-", umbracoContext.PublishedRequest?.PublishedContent?.Id ?? 0);
}
if (cacheByMember)
{
if (memberManager == null)
{
throw new InvalidOperationException(
"Cannot cache by member if the MemberManager is not available.");
}
MemberIdentityUser? currentMember = await memberManager.GetCurrentMemberAsync();
cacheKey.AppendFormat("m{0}-", currentMember?.Id ?? "0");
}
if (contextualKeyBuilder != null)
{
var contextualKey = contextualKeyBuilder(model, viewData);
cacheKey.AppendFormat("c{0}-", contextualKey);
}
return cacheKey.ToString();
}
// public static IHtmlContent EditorFor<T>(this IHtmlHelper htmlHelper, string templateName = "", string htmlFieldName = "", object additionalViewData = null)
// where T : new()
// {
@@ -1,4 +1,4 @@
using System.Reflection;
using System.Reflection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Routing;
@@ -126,8 +126,20 @@ internal class EagerMatcherPolicy : MatcherPolicy, IEndpointSelectorPolicy
return;
}
// If it's an attribute routed IVirtualPageController with a Host attribute we should ignore if the host doesn't match the current request.
// Maybe we would expect that it wouldn't be in the provided CandidateSet, but it will be included just based on the Route.
// See: https://github.com/umbraco/Umbraco-CMS/issues/16816
if (controllerTypeInfo is not null && controllerTypeInfo.IsType<IVirtualPageController>())
{
HostAttribute? hostAttribute = controllerTypeInfo.GetCustomAttribute<HostAttribute>();
if (hostAttribute is not null && hostAttribute.Hosts.InvariantContains(httpContext.Request.Host.Value) is false)
{
continue;
}
}
// If it's an UmbracoPageController we need to do some domain routing.
// We need to do this in oder to handle cultures for our Dictionary.
// We need to do this in order to handle cultures for our Dictionary.
// This is because UmbracoPublishedContentCultureProvider is ued to set the Thread.CurrentThread.CurrentUICulture
// The CultureProvider is run before the actual routing, this means that our UmbracoVirtualPageFilterAttribute is hit AFTER the culture is set.
// Meaning we have to route the domain part already now, this is not pretty, but it beats having to look for content we know doesn't exist.

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