Compare commits

...
66 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
102 changed files with 5143 additions and 3106 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.7.0" />
<PackageVersion Include="Examine.Core" Version="3.7.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));
}
}
}
@@ -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>
@@ -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!;
@@ -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
@@ -3627,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>
@@ -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;
}
+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);
}
@@ -820,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;
}
}
@@ -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);
}
}
@@ -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;
}
@@ -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,
@@ -681,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
@@ -692,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
@@ -706,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;
}
@@ -723,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)
@@ -762,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!;
}
@@ -777,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" });
@@ -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)
{
@@ -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();
@@ -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)
@@ -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);
}
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 ....
});
}
@@ -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>
@@ -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);
}
}
+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.
@@ -25,7 +25,7 @@ const config: PlaywrightTestConfig = {
// We don't want to run parallel, as tests might differ in state
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: process.env.CI ? 'line' : 'html',
reporter: process.env.CI ? [['line'], ['junit', {outputFile: 'results/results.xml'}]] : 'html',
outputDir : "./results",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

@@ -11,6 +11,7 @@ using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DistributedLocking;
@@ -43,6 +44,8 @@ public static class UmbracoBuilderExtensions
public static IUmbracoBuilder AddTestServices(this IUmbracoBuilder builder, TestHelper testHelper)
{
builder.Services.AddUnique(AppCaches.NoCache);
builder.Services.AddUnique(Mock.Of<IMemberPartialViewCacheInvalidator>());
builder.Services.AddUnique(Mock.Of<IUmbracoBootPermissionChecker>());
builder.Services.AddUnique(testHelper.MainDom);
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Api.Delivery.Services;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.DeliveryApi;
[TestFixture]
[UmbracoTest(
Database = UmbracoTestOptions.Database.NewSchemaPerFixture,
WithApplication = true)]
public class RequestRoutingServiceTests : UmbracoIntegrationTest
{
private IRequestRoutingService RequestRoutingService => GetRequiredService<IRequestRoutingService>();
protected override void CustomTestSetup(IUmbracoBuilder builder)
{
builder.Services.AddUnique<IRequestRoutingService, RequestRoutingService>();
var elementCache = new FastDictionaryAppCache();
var snapshotCache = new FastDictionaryAppCache();
var domainCacheMock = new Mock<IDomainCache>();
domainCacheMock.Setup(x => x.GetAll(It.IsAny<bool>()))
.Returns(
[
new Domain(1, "localhost/en", 1000, "en-us", false, 0),
new Domain(2, "localhost/jp", 1000, "ja-jp", false, 1),
]);
var publishedSnapshotMock = new Mock<IPublishedSnapshot>();
publishedSnapshotMock.SetupGet(p => p.ElementsCache).Returns(elementCache);
publishedSnapshotMock.SetupGet(p => p.SnapshotCache).Returns(snapshotCache);
publishedSnapshotMock.SetupGet(p => p.Domains).Returns(domainCacheMock.Object);
var publishedSnapshot = publishedSnapshotMock.Object;
var publishedSnapshotAccessor = new Mock<IPublishedSnapshotAccessor>();
publishedSnapshotAccessor.Setup(p => p.TryGetPublishedSnapshot(out publishedSnapshot)).Returns(true);
builder.Services.AddSingleton(provider => publishedSnapshotAccessor.Object);
}
[TestCase(null, "")]
[TestCase("", "")]
[TestCase("/", "/")]
[TestCase("/en/test/", "1000/test/")] // Verifies matching a domain.
[TestCase("/da/test/", "/da/test/")] // Verifies that with no matching domain, so route will be returned as is.
[TestCase("/jp/オフィス/", "1000/オフィス/")] // Verifies that with a URL segment containing special characters, the route remains decoded.
public void GetContentRoute_ReturnsExpectedRoute(string? requestedRoute, string expectedResult)
{
if (!string.IsNullOrEmpty(requestedRoute))
{
var httpContextAccessor = GetRequiredService<IHttpContextAccessor>();
httpContextAccessor.HttpContext = new DefaultHttpContext
{
Request =
{
Scheme = "https",
Host = new HostString("localhost"),
Path = requestedRoute,
},
};
}
var result = RequestRoutingService.GetContentRoute(requestedRoute);
Assert.AreEqual(expectedResult, result);
}
}
@@ -0,0 +1,120 @@
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Examine;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Examine;
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
[TestFixture]
public class DeliveryApiContentIndexHelperTests : UmbracoIntegrationTestWithContent
{
public override void CreateTestData()
{
base.CreateTestData();
// Save an extra, published content item of a different type to those created via the base class,
// that we'll use to test filtering out disallowed content types.
var template = TemplateBuilder.CreateTextPageTemplate("textPage2");
FileService.SaveTemplate(template);
var contentType = ContentTypeBuilder.CreateSimpleContentType("umbTextpage2", "Textpage2", defaultTemplateId: template.Id);
contentType.Key = Guid.NewGuid();
ContentTypeService.Save(contentType);
ContentType.AllowedContentTypes =
[
new ContentTypeSort(ContentType.Id, 0),
new ContentTypeSort(contentType.Id, 1),
];
ContentTypeService.Save(ContentType);
var subpage = ContentBuilder.CreateSimpleContent(contentType, "Alternate Text Page 4", Textpage.Id);
ContentService.Save(subpage);
// And then add some more of the first type, so the one we'll filter out in tests isn't in the last page.
for (int i = 0; i < 5; i++)
{
subpage = ContentBuilder.CreateSimpleContent(ContentType, $"Text Page {5 + i}", Textpage.Id);
ContentService.Save(subpage);
}
}
[Test]
public void Can_Enumerate_Descendants_For_Content_Index()
{
var sut = CreateDeliveryApiContentIndexHelper();
var expectedNumberOfContentItems = GetExpectedNumberOfContentItems();
var contentEnumerated = 0;
Action<IContent[]> actionToPerform = content =>
{
contentEnumerated += content.Length;
};
const int pageSize = 3;
sut.EnumerateApplicableDescendantsForContentIndex(
Cms.Core.Constants.System.Root,
actionToPerform,
pageSize);
Assert.AreEqual(expectedNumberOfContentItems, contentEnumerated);
}
[Test]
public void Can_Enumerate_Descendants_For_Content_Index_With_Disallowed_Content_Type()
{
var sut = CreateDeliveryApiContentIndexHelper(["umbTextPage2"]);
var expectedNumberOfContentItems = GetExpectedNumberOfContentItems();
var contentEnumerated = 0;
Action<IContent[]> actionToPerform = content =>
{
contentEnumerated += content.Length;
};
const int pageSize = 3;
sut.EnumerateApplicableDescendantsForContentIndex(
Cms.Core.Constants.System.Root,
actionToPerform,
pageSize);
Assert.AreEqual(expectedNumberOfContentItems - 1, contentEnumerated);
}
private DeliveryApiContentIndexHelper CreateDeliveryApiContentIndexHelper(string[]? disallowedContentTypeAliases = null)
{
return new DeliveryApiContentIndexHelper(
ContentService,
GetRequiredService<IUmbracoDatabaseFactory>(),
GetDeliveryApiSettings(disallowedContentTypeAliases ?? []));
}
private IOptionsMonitor<DeliveryApiSettings> GetDeliveryApiSettings(string[] disallowedContentTypeAliases)
{
var deliveryApiSettings = new DeliveryApiSettings
{
DisallowedContentTypeAliases = disallowedContentTypeAliases,
};
var optionsMonitorMock = new Mock<IOptionsMonitor<DeliveryApiSettings>>();
optionsMonitorMock.Setup(o => o.CurrentValue).Returns(deliveryApiSettings);
return optionsMonitorMock.Object;
}
private int GetExpectedNumberOfContentItems()
{
var result = ContentService.GetAllPublished().Count();
Assert.AreEqual(10, result);
return result;
}
}
@@ -1,7 +1,6 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.Linq;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
using Umbraco.Cms.Core.Cache;
@@ -1047,6 +1046,98 @@ public class TagRepositoryTest : UmbracoIntegrationTest
}
}
[Test]
public void Can_Create_Tag_Relations_With_Mixed_Casing_For_Tag()
{
var provider = ScopeProvider;
using (var scope = ScopeProvider.CreateScope())
{
(IContentType contentType, IContent content1, IContent content2) = CreateContentForCreateTagTests();
var repository = CreateRepository(provider);
// Note two tags are applied, but they differ only in case for the tag.
Tag[] tags1 = { new() { Text = "tag1", Group = "test" }, new() { Text = "Tag1", Group = "test" } };
repository.Assign(
content1.Id,
contentType.PropertyTypes.First().Id,
tags1,
false);
// Note the casing is different from the tag in tags1, but both should be considered equivalent.
Tag[] tags2 = { new() { Text = "TAG1", Group = "test" } };
repository.Assign(
content2.Id,
contentType.PropertyTypes.First().Id,
tags2,
false);
// Only one tag should have been saved.
var tagCount = scope.Database.ExecuteScalar<int>(
"SELECT COUNT(*) FROM cmsTags WHERE [group] = 'test'");
Assert.AreEqual(1, tagCount);
// Both content items should be found as tagged by the tag, even though one was assigned with the tag differing in case.
Assert.AreEqual(2, repository.GetTaggedEntitiesByTag(TaggableObjectTypes.Content, "tag1").Count());
}
}
[Test]
public void Can_Create_Tag_Relations_With_Mixed_Casing_For_Group()
{
var provider = ScopeProvider;
using (var scope = ScopeProvider.CreateScope())
{
(IContentType contentType, IContent content1, IContent content2) = CreateContentForCreateTagTests();
var repository = CreateRepository(provider);
// Note two tags are applied, but they differ only in case for the group.
Tag[] tags1 = { new() { Text = "tag1", Group = "group1" }, new() { Text = "tag1", Group = "Group1" } };
repository.Assign(
content1.Id,
contentType.PropertyTypes.First().Id,
tags1,
false);
// Note the casing is different from the group in tags1, but both should be considered equivalent.
Tag[] tags2 = { new() { Text = "tag1", Group = "GROUP1" } };
repository.Assign(
content2.Id,
contentType.PropertyTypes.First().Id,
tags2,
false);
// Only one tag/group should have been saved.
var tagCount = scope.Database.ExecuteScalar<int>(
"SELECT COUNT(*) FROM cmsTags WHERE [tag] = 'tag1'");
Assert.AreEqual(1, tagCount);
var groupCount = scope.Database.ExecuteScalar<int>(
"SELECT COUNT(*) FROM cmsTags WHERE [group] = 'group1'");
Assert.AreEqual(1, groupCount);
// Both content items should be found as tagged by the tag, even though one was assigned with the group differing in case.
Assert.AreEqual(2, repository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Content, "group1").Count());
}
}
private (IContentType ContentType, IContent Content1, IContent Content2) CreateContentForCreateTagTests()
{
var template = TemplateBuilder.CreateTextPageTemplate();
FileService.SaveTemplate(template);
var contentType = ContentTypeBuilder.CreateSimpleContentType("test", "Test", defaultTemplateId: template.Id);
ContentTypeRepository.Save(contentType);
var content1 = ContentBuilder.CreateSimpleContent(contentType);
var content2 = ContentBuilder.CreateSimpleContent(contentType);
DocumentRepository.Save(content1);
DocumentRepository.Save(content2);
return (contentType, content1, content2);
}
private TagRepository CreateRepository(IScopeProvider provider) =>
new((IScopeAccessor)provider, AppCaches.Disabled, LoggerFactory.CreateLogger<TagRepository>());
}
@@ -967,6 +967,73 @@ public class UserServiceTests : UmbracoIntegrationTest
}
}
[Test]
public void Can_Get_Next_Users_In_Batches()
{
var users = UserBuilder.CreateMulipleUsers(10).ToArray();
UserService.Save(users);
var userBatch1 = UserService.GetNextUsers(Constants.Security.SuperUserId, 3);
var userBatch2 = UserService.GetNextUsers(1, 6);
var userBatch3 = UserService.GetNextUsers(4, 5);
var userBatch4 = UserService.GetNextUsers(9, 5);
var allUsers = UserService.GetNextUsers(Constants.Security.SuperUserId, int.MaxValue);
Assert.AreEqual(3, userBatch1.Count());
Assert.AreEqual(Constants.Security.SuperUserId, userBatch1.First().Id);
Assert.AreEqual(2, userBatch1.Last().Id);
Assert.AreEqual(6, userBatch2.Count());
Assert.AreEqual(1, userBatch2.First().Id);
Assert.AreEqual(6, userBatch2.Last().Id);
Assert.AreEqual(5, userBatch3.Count());
Assert.AreEqual(4, userBatch3.First().Id);
Assert.AreEqual(8, userBatch3.Last().Id);
Assert.AreEqual(2, userBatch4.Count());
Assert.AreEqual(9, userBatch4.First().Id);
Assert.AreEqual(10, userBatch4.Last().Id);
Assert.AreEqual(11, allUsers.Count());
}
[Test]
public void Can_Get_Next_Approved_Users_In_Batches()
{
var users = UserBuilder.CreateMulipleUsers(10).ToArray();
for (int i = 0; i < users.Length; i++)
{
users[i].IsApproved = !(i == 0 || i == 6); // Setup all users as approved except for a couple.
}
UserService.Save(users);
var userBatch1 = UserService.GetNextApprovedUsers(Constants.Security.SuperUserId, 3);
var userBatch2 = UserService.GetNextApprovedUsers(1, 6);
var userBatch3 = UserService.GetNextApprovedUsers(4, 5);
var userBatch4 = UserService.GetNextApprovedUsers(9, 5);
var allApprovedUsers = UserService.GetNextApprovedUsers(Constants.Security.SuperUserId, int.MaxValue);
Assert.AreEqual(3, userBatch1.Count());
Assert.AreEqual(Constants.Security.SuperUserId, userBatch1.First().Id);
Assert.AreEqual(3, userBatch1.Last().Id);
Assert.AreEqual(6, userBatch2.Count());
Assert.AreEqual(2, userBatch2.First().Id);
Assert.AreEqual(8, userBatch2.Last().Id);
Assert.AreEqual(5, userBatch3.Count());
Assert.AreEqual(4, userBatch3.First().Id);
Assert.AreEqual(9, userBatch3.Last().Id);
Assert.AreEqual(2, userBatch4.Count());
Assert.AreEqual(9, userBatch4.First().Id);
Assert.AreEqual(10, userBatch4.Last().Id);
Assert.AreEqual(9, allApprovedUsers.Count());
}
private Content[] BuildContentItems(int numberToCreate)
{
var template = TemplateBuilder.CreateTextPageTemplate();
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Hosting;
@@ -22,6 +23,7 @@ using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Infrastructure.HostedServices;
using Umbraco.Cms.Infrastructure.PublishedCache;
using Umbraco.Cms.Infrastructure.PublishedCache.DataSource;
using Umbraco.Cms.Infrastructure.Serialization;
@@ -280,7 +282,9 @@ public class PublishedSnapshotServiceTestBase
PublishedModelFactory,
TestHelper.GetHostingEnvironment(),
Options.Create(nuCacheSettings),
new ContentDataSerializer(new DictionaryOfPropertyDataSerializer()));
new ContentDataSerializer(new DictionaryOfPropertyDataSerializer()),
Mock.Of<IBackgroundTaskQueue>(),
AppCaches.NoCache);
// invariant is the current default
VariationContextAccessor.VariationContext = new VariationContext();
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Http;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Api.Delivery.Services;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Delivery.Services;
[TestFixture]
public class RequestPreviewServiceTests
{
[TestCase(null, false)]
[TestCase("", false)]
[TestCase("false", false)]
[TestCase("true", true)]
[TestCase("True", true)]
public void IsPreview_Returns_Expected_Result(string? headerValue, bool expected)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["Preview"] = headerValue;
var httpContextAccessorMock = new Mock<IHttpContextAccessor>();
httpContextAccessorMock
.Setup(x => x.HttpContext)
.Returns(httpContext);
var sut = new RequestPreviewService(httpContextAccessorMock.Object);
var result = sut.IsPreview();
Assert.AreEqual(expected, result);
}
}
@@ -0,0 +1,52 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi;
[TestFixture]
public class ApiContentPathResolverTests
{
private const string TestPath = "/test/page";
[TestCase(TestPath, true)]
[TestCase("/", true)]
[TestCase("file.txt", false)]
[TestCase("test/file.txt", false)]
[TestCase("test/test2/file.txt", false)]
[TestCase("/file.txt", false)]
[TestCase("/test/file.txt", false)]
[TestCase("/test/test2/file.txt", false)]
public void Can_Verify_Resolveable_Paths(string path, bool expected)
{
var resolver = CreateResolver();
var result = resolver.IsResolvablePath(path);
Assert.AreEqual(expected, result);
}
[TestCase(TestPath)]
[TestCase("/")]
public void Resolves_Content_For_Path(string path)
{
var resolver = CreateResolver();
var result = resolver.ResolveContentPath(path);
Assert.IsNotNull(result);
}
private static ApiContentPathResolver CreateResolver()
{
var mockRequestRoutingService = new Mock<IRequestRoutingService>();
mockRequestRoutingService
.Setup(x => x.GetContentRoute(It.IsAny<string>()))
.Returns((string path) => path);
var mockApiPublishedContentCache = new Mock<IApiPublishedContentCache>();
mockApiPublishedContentCache
.Setup(x => x.GetByRoute(TestPath))
.Returns(new Mock<IPublishedContent>().Object);
mockApiPublishedContentCache
.Setup(x => x.GetByRoute("/"))
.Returns(new Mock<IPublishedContent>().Object);
return new ApiContentPathResolver(mockRequestRoutingService.Object, mockApiPublishedContentCache.Object);
}
}
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
@@ -357,16 +357,71 @@ public class RichTextParserTests : PropertyValueConverterTests
Assert.IsEmpty(blockLevelBlock.Elements);
}
private const string TestParagraph = "What follows from <strong>here</strong> <em>is</em> <a href=\"#\">just</a> a bunch of text.";
[Test]
public void ParseElement_CanHandleWhitespaceAroundInlineElemements()
{
var parser = CreateRichTextElementParser();
var element = parser.Parse("<p>What follows from <strong>here</strong> <em>is</em> <a href=\"#\">just</a> a bunch of text.</p>") as RichTextRootElement;
var element = parser.Parse($"<p>{TestParagraph}</p>") as RichTextRootElement;
Assert.IsNotNull(element);
var paragraphElement = element.Elements.Single() as RichTextGenericElement;
Assert.IsNotNull(paragraphElement);
AssertTestParagraph(paragraphElement);
}
[TestCase(1, "\n")]
[TestCase(2, "\n")]
[TestCase(1, "\r")]
[TestCase(2, "\r")]
[TestCase(1, "\r\n")]
[TestCase(2, "\r\n")]
public void ParseElement_RemovesNewLinesAroundHtmlStructuralElements(int numberOfNewLineCharacters, string newlineCharacter)
{
var parser = CreateRichTextElementParser();
var newLineSeparator = string.Concat(Enumerable.Repeat(newlineCharacter, numberOfNewLineCharacters));
var element = parser.Parse($"<table>{newLineSeparator}<tr>{newLineSeparator}<td>{TestParagraph}</td>{newLineSeparator}</tr>{newLineSeparator}</table>") as RichTextRootElement;
Assert.IsNotNull(element);
var tableElement = element.Elements.Single() as RichTextGenericElement;
Assert.IsNotNull(tableElement);
var rowElement = tableElement.Elements.Single() as RichTextGenericElement;
Assert.IsNotNull(rowElement);
var cellElement = rowElement.Elements.Single() as RichTextGenericElement;
Assert.IsNotNull(cellElement);
AssertTestParagraph(cellElement);
}
[TestCase(1, "\n")]
[TestCase(2, "\n")]
[TestCase(1, "\r")]
[TestCase(2, "\r")]
[TestCase(1, "\r\n")]
[TestCase(2, "\r\n")]
public void ParseElement_RemovesNewLinesAroundHtmlContentElements(int numberOfNewLineCharacters, string newlineCharacter)
{
var parser = CreateRichTextElementParser();
var newLineSeparator = string.Concat(Enumerable.Repeat(newlineCharacter, numberOfNewLineCharacters));
var element = parser.Parse($"<div><p>{TestParagraph}</p>{newLineSeparator}<p></p>{newLineSeparator}<p>&nbsp;</p>{newLineSeparator}<p>{TestParagraph}</p></div>") as RichTextRootElement;
Assert.IsNotNull(element);
var divElement = element.Elements.Single() as RichTextGenericElement;
Assert.IsNotNull(divElement);
var paragraphELements = divElement.Elements;
Assert.AreEqual(4, paragraphELements.Count());
AssertTestParagraph(paragraphELements.First() as RichTextGenericElement);
AssertTestParagraph(paragraphELements.Last() as RichTextGenericElement);
}
private static void AssertTestParagraph(RichTextGenericElement paragraphElement)
{
var childElements = paragraphElement.Elements.ToArray();
Assert.AreEqual(7, childElements.Length);
@@ -0,0 +1,41 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using NUnit.Framework;
using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Extensions;
[TestFixture]
public class IntExtensionsTests
{
[TestCase(20, "00000014-0000-0000-0000-000000000000")]
[TestCase(106, "0000006a-0000-0000-0000-000000000000")]
[TestCase(999999, "000f423f-0000-0000-0000-000000000000")]
[TestCase(555555555, "211d1ae3-0000-0000-0000-000000000000")]
public void ToGuid_Creates_Expected_Guid(int input, string expected)
{
var result = input.ToGuid();
Assert.AreEqual(expected, result.ToString());
}
[TestCase("00000014-0000-0000-0000-000000000000", 20)]
[TestCase("0000006a-0000-0000-0000-000000000000", 106)]
[TestCase("000f423f-0000-0000-0000-000000000000", 999999)]
[TestCase("211d1ae3-0000-0000-0000-000000000000", 555555555)]
[TestCase("0d93047e-558d-4311-8a9d-b89e6fca0337", null)]
public void TryParseFromGuid_Parses_Expected_Integer(string input, int? expected)
{
var result = IntExtensions.TryParseFromGuid(Guid.Parse(input), out int? intValue);
if (expected is null)
{
Assert.IsFalse(result);
Assert.IsFalse(intValue.HasValue);
}
else
{
Assert.IsTrue(result);
Assert.AreEqual(expected, intValue.Value);
}
}
}
@@ -0,0 +1,82 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors;
/// <summary>
/// Tests for <see cref="RichTextPropertyIndexValueFactory"/> to ensure it correctly creates index values from rich text properties.
/// </summary>
public class RichTextPropertyIndexValueFactoryTests
{
/// <summary>
/// Tests that the factory can create index values from a rich text property with valid content
/// </summary>
/// <param name="testContent"></param>
/// <param name="expected"></param>
[Test]
[TestCase("<p>Sample text</p>", "Sample text")]
[TestCase("<p>John Smith<br>Company ABC<br>London</p>", "John Smith Company ABC London")]
[TestCase("<p>John Smith<break>Company ABC<break>London</p>", "John SmithCompany ABCLondon")]
[TestCase("<p>John Smith<br>Company ABC<branything>London</p>", "John Smith Company ABCLondon")]
[TestCase("<p>Another sample text with <strong>bold</strong> content</p>", "Another sample text with bold content")]
[TestCase("<p>Text with <a href=\"https://example.com\">link</a></p>", "Text with link")]
[TestCase("<p>Text with <img src=\"image.jpg\" alt=\"image\" /></p>", "Text with")]
[TestCase("<p>Text with <span style=\"color: red;\">styled text</span></p>", "Text with styled text")]
[TestCase("<p>Text with <em>emphasized</em> content</p>", "Text with emphasized content")]
[TestCase("<p>Text with <u>underlined</u> content</p>", "Text with underlined content")]
[TestCase("<p>Text with <code>inline code</code></p>", "Text with inline code")]
[TestCase("<p>Text with <pre><code>code block</code></pre></p>", "Text with code block")]
[TestCase("<p>Text with <blockquote>quoted text</blockquote></p>", "Text with quoted text")]
[TestCase("<p>Text with <ul><li>list item 1</li><li>list item 2</li></ul></p>",
"Text with list item 1list item 2")]
[TestCase("<p>Text with <ol><li>ordered item 1</li><li>ordered item 2</li></ol></p>",
"Text with ordered item 1ordered item 2")]
[TestCase("<p>Text with <div class=\"class-name\">div content</div></p>", "Text with div content")]
[TestCase("<p>Text with <span class=\"class-name\">span content</span></p>", "Text with span content")]
[TestCase("<p>Text with <strong>bold</strong> and <em>italic</em> content</p>",
"Text with bold and italic content")]
[TestCase("<p>Text with <a href=\"https://example.com\" target=\"_blank\">external link</a></p>",
"Text with external link")]
[TestCase("<p>John Smith<br class=\"test\">Company ABC<br>London</p>", "John Smith Company ABC London")]
[TestCase("<p>John Smith<br \r\n />Company ABC<br>London</p>", "John Smith Company ABC London")]
public void Can_Create_Index_Values_From_RichText_Property(string testContent, string expected)
{
var propertyEditorCollection = new PropertyEditorCollection(new DataEditorCollection(() => null));
var jsonSerializer = Mock.Of<IJsonSerializer>();
var indexingSettings = Mock.Of<IOptionsMonitor<IndexingSettings>>();
Mock.Get(indexingSettings).Setup(x => x.CurrentValue).Returns(new IndexingSettings { });
var contentTypeService = Mock.Of<IContentTypeService>();
var logger = Mock.Of<ILogger<RichTextPropertyIndexValueFactory>>();
string alias = "richText";
var factory = new RichTextPropertyIndexValueFactory(
propertyEditorCollection,
jsonSerializer,
indexingSettings,
contentTypeService,
logger);
// create a mock property with the rich text value
var property = Mock.Of<IProperty>(p => p.Alias == alias
&& (string)p.GetValue(It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<bool>()) == testContent);
// get the index value for the property
var indexValue = factory
.GetIndexValues(property, null, null, true, [], new Dictionary<Guid, IContentType>())
.FirstOrDefault(kvp => kvp.Key == alias);
Assert.IsNotNull(indexValue);
// assert that index the value is created correctly (it might contain a trailing whitespace, but that's OK)
var expectedIndexValue = indexValue.Value.SingleOrDefault() as string;
Assert.IsNotNull(expectedIndexValue);
Assert.AreEqual(expected, expectedIndexValue.TrimEnd());
}
}
@@ -204,6 +204,64 @@ public class MemberUserStoreTests
_mockMemberService.Verify(x => x.ReplaceRoles(new[] { 123 }, new[] { "role1", "role2" }));
}
[Test]
public async Task GivenIUpdateAUsersLoginPropertiesOnly_ThenIShouldGetASuccessResultAsync()
{
// arrange
var sut = CreateSut();
var fakeUser = new MemberIdentityUser
{
Id = "123",
Name = "a",
Email = "a@b.com",
UserName = "c",
Comments = "e",
LastLoginDateUtc = DateTime.UtcNow,
SecurityStamp = "abc",
};
IMemberType fakeMemberType = new MemberType(new MockShortStringHelper(), 77);
var mockMember = Mock.Of<IMember>(m =>
m.Id == 123 &&
m.Name == "a" &&
m.Email == "a@b.com" &&
m.Username == "c" &&
m.Comments == "e" &&
m.ContentTypeAlias == fakeMemberType.Alias &&
m.HasIdentity == true &&
m.EmailConfirmedDate == DateTime.MinValue &&
m.FailedPasswordAttempts == 0 &&
m.LastLockoutDate == DateTime.MinValue &&
m.IsApproved == false &&
m.RawPasswordValue == "xyz" &&
m.SecurityStamp == "xyz");
_mockMemberService.Setup(x => x.UpdateLoginPropertiesAsync(mockMember));
_mockMemberService.Setup(x => x.GetById(123)).Returns(mockMember);
// act
var identityResult = await sut.UpdateAsync(fakeUser, CancellationToken.None);
// assert
Assert.IsTrue(identityResult.Succeeded);
Assert.IsTrue(!identityResult.Errors.Any());
Assert.AreEqual(fakeUser.Name, mockMember.Name);
Assert.AreEqual(fakeUser.Email, mockMember.Email);
Assert.AreEqual(fakeUser.UserName, mockMember.Username);
Assert.AreEqual(fakeUser.Comments, mockMember.Comments);
Assert.IsFalse(fakeUser.LastPasswordChangeDateUtc.HasValue);
Assert.AreEqual(fakeUser.LastLoginDateUtc.Value.ToLocalTime(), mockMember.LastLoginDate);
Assert.AreEqual(fakeUser.AccessFailedCount, mockMember.FailedPasswordAttempts);
Assert.AreEqual(fakeUser.IsLockedOut, mockMember.IsLockedOut);
Assert.AreEqual(fakeUser.IsApproved, mockMember.IsApproved);
Assert.AreEqual(fakeUser.SecurityStamp, mockMember.SecurityStamp);
_mockMemberService.Verify(x => x.Save(mockMember), Times.Never);
_mockMemberService.Verify(x => x.UpdateLoginPropertiesAsync(mockMember));
_mockMemberService.Verify(x => x.GetById(123));
}
[Test]
public async Task GivenIDeleteUser_AndTheUserIsNotPresent_ThenIShouldGetAFailedResultAsync()
{

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