Compare commits
132
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f5c21c631 | ||
|
|
4ad18dc963 | ||
|
|
881535af7d | ||
|
|
1e66fb6ab3 | ||
|
|
e1b9e5efad | ||
|
|
28570b43e8 | ||
|
|
a01382d756 | ||
|
|
1d6e7f15b9 | ||
|
|
97cc3ca581 | ||
|
|
0bcae3e87b | ||
|
|
ebd228c3d7 | ||
|
|
4b83a74bdb | ||
|
|
b348b84b63 | ||
|
|
7f4a8d5974 | ||
|
|
4d8ca457ec | ||
|
|
d677e948f1 | ||
|
|
d4e6af50bd | ||
|
|
c609cafa9b | ||
|
|
5556b0fe0c | ||
|
|
eee04f4703 | ||
|
|
127c6c4b53 | ||
|
|
7d6a1e54e6 | ||
|
|
8433b2b637 | ||
|
|
87b2153794 | ||
|
|
0fc5b2a95c | ||
|
|
6c2f93e85d | ||
|
|
4f1604fea1 | ||
|
|
059766291b | ||
|
|
e471c1fc8b | ||
|
|
0fb91ef859 | ||
|
|
c0a0a34cbb | ||
|
|
3d44a6fdf9 | ||
|
|
0eee307622 | ||
|
|
6a4aa04b47 | ||
|
|
643f073945 | ||
|
|
5ba40c65fc | ||
|
|
dcbbed4160 | ||
|
|
e94e165593 | ||
|
|
34709be6cc | ||
|
|
83107bb31a | ||
|
|
3caa43a5bf | ||
|
|
1efe860a89 | ||
|
|
d49965549c | ||
|
|
8d85259025 | ||
|
|
097d0456cc | ||
|
|
a62fa93c77 | ||
|
|
a22dcf05f2 | ||
|
|
afa6fa5ca2 | ||
|
|
c6effef622 | ||
|
|
ab31fbb0aa | ||
|
|
7d9a2c6e70 | ||
|
|
a486d5df33 | ||
|
|
3c8a621817 | ||
|
|
0e0aca55af | ||
|
|
0f02584143 | ||
|
|
a3db45609a | ||
|
|
d60a2a217c | ||
|
|
3e9ff6b5cb | ||
|
|
05a7d337de | ||
|
|
fdca086a47 | ||
|
|
42a81beeac | ||
|
|
9284b9e0b1 | ||
|
|
eb91f4fef4 | ||
|
|
68acc2aa51 | ||
|
|
5570583f70 | ||
|
|
eb979625d1 | ||
|
|
e1e5f527e4 | ||
|
|
f016dbd223 | ||
|
|
b2aa04ae1a | ||
|
|
e270adc50f | ||
|
|
fd5b8de533 | ||
|
|
e3d9b042c2 | ||
|
|
f4ae796b30 | ||
|
|
367d0386b6 | ||
|
|
5b54bed406 | ||
|
|
d3b60277d7 | ||
|
|
2ec6ff4ebc | ||
|
|
78a8d017a2 | ||
|
|
1a205d8727 | ||
|
|
396b5ea211 | ||
|
|
3cf9099f56 | ||
|
|
451b5f96e7 | ||
|
|
15ebae5025 | ||
|
|
32bfbadb8e | ||
|
|
5ed09ebefa | ||
|
|
eaea7a67ba | ||
|
|
de2114b8c5 | ||
|
|
64607ddbaa | ||
|
|
db1d999721 | ||
|
|
18047a7cfb | ||
|
|
def7ebd48c | ||
|
|
8c2b1ebdc5 | ||
|
|
9227517a50 | ||
|
|
5322d0f7b5 | ||
|
|
a282cc5691 | ||
|
|
048f8bcdf9 | ||
|
|
7bcbc748d4 | ||
|
|
f30e6cfe7f | ||
|
|
026e80e026 | ||
|
|
a3b77cff63 | ||
|
|
4ca68d6995 | ||
|
|
280cb7f2b1 | ||
|
|
095a73132c | ||
|
|
cfb0fc23ac | ||
|
|
6620aca9fe | ||
|
|
2422a02b23 | ||
|
|
b4a9dc0770 | ||
|
|
e7411244fd | ||
|
|
9f357173c3 | ||
|
|
7dc6e3ed5b | ||
|
|
5dfff212d0 | ||
|
|
17615f966b | ||
|
|
7a2d6b6c63 | ||
|
|
343867f6ee | ||
|
|
ee2d7bbb1b | ||
|
|
5d48bc7371 | ||
|
|
59a4649528 | ||
|
|
f54b6033ab | ||
|
|
313417cb91 | ||
|
|
7850078623 | ||
|
|
44bf3b77b3 | ||
|
|
8485458896 | ||
|
|
edc78a5a4c | ||
|
|
1a18d6c035 | ||
|
|
3e877182b5 | ||
|
|
0e4f883bc1 | ||
|
|
265cc62ab8 | ||
|
|
a2fd82a3f3 | ||
|
|
d15b5b221e | ||
|
|
60e3fa97a9 | ||
|
|
fd1bd51aff | ||
|
|
b12620af9c |
@@ -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>
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
<PackageVersion Include="Asp.Versioning.Mvc" Version="7.1.1" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="7.1.0" />
|
||||
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
|
||||
<PackageVersion Include="Examine" Version="3.5.0" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.5.0" />
|
||||
<PackageVersion Include="Examine" Version="3.7.1" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.7.1" />
|
||||
<PackageVersion Include="HtmlAgilityPack" Version="1.11.71" />
|
||||
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
|
||||
<PackageVersion Include="MailKit" Version="4.8.0" />
|
||||
@@ -73,7 +73,7 @@
|
||||
<PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Map" Version="1.0.2" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.6" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.7" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.1.3" />
|
||||
<PackageVersion Include="Smidge.InMemory" Version="4.4.0" />
|
||||
<PackageVersion Include="Smidge.Nuglify" Version="4.5.1" />
|
||||
|
||||
@@ -71,7 +71,7 @@ stages:
|
||||
- job: A
|
||||
displayName: Build Umbraco CMS
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
vmImage: 'windows-latest'
|
||||
steps:
|
||||
- checkout: self
|
||||
submodules: false
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -42,31 +42,42 @@ internal sealed class RequestRedirectService : RoutingServiceBase, IRequestRedir
|
||||
{
|
||||
requestedPath = requestedPath.EnsureStartsWith("/");
|
||||
|
||||
IPublishedContent? startItem = GetStartItem();
|
||||
|
||||
// must append the root content url segment if it is not hidden by config, because
|
||||
// the URL tracking is based on the actual URL, including the root content url segment
|
||||
if (_globalSettings.HideTopLevelNodeFromPath == false)
|
||||
if (_globalSettings.HideTopLevelNodeFromPath == false && startItem?.UrlSegment != null)
|
||||
{
|
||||
IPublishedContent? startItem = GetStartItem();
|
||||
if (startItem?.UrlSegment != null)
|
||||
{
|
||||
requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}";
|
||||
}
|
||||
requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}";
|
||||
}
|
||||
|
||||
var culture = _requestCultureService.GetRequestedCulture();
|
||||
|
||||
// append the configured domain content ID to the path if we have a domain bound request,
|
||||
// because URL tracking registers the tracked url like "{domain content ID}/{content path}"
|
||||
Uri contentRoute = GetDefaultRequestUri(requestedPath);
|
||||
DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute);
|
||||
if (domainAndUri != null)
|
||||
// important: redirect URLs are always tracked without trailing slashes
|
||||
requestedPath = requestedPath.TrimEnd("/");
|
||||
IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath, culture);
|
||||
|
||||
// if a redirect URL was not found, try by appending the start item ID because URL tracking might have tracked
|
||||
// a redirect with "{root content ID}/{content path}"
|
||||
if (redirectUrl is null && startItem is not null)
|
||||
{
|
||||
requestedPath = GetContentRoute(domainAndUri, contentRoute);
|
||||
culture ??= domainAndUri.Culture;
|
||||
redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl($"{startItem.Id}{requestedPath}", culture);
|
||||
}
|
||||
|
||||
// still no redirect URL found - try looking for a configured domain if we have a domain bound request,
|
||||
// because URL tracking might have tracked a redirect with "{domain content ID}/{content path}"
|
||||
if (redirectUrl is null)
|
||||
{
|
||||
Uri contentRoute = GetDefaultRequestUri(requestedPath);
|
||||
DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute);
|
||||
if (domainAndUri is not null)
|
||||
{
|
||||
requestedPath = GetContentRoute(domainAndUri, contentRoute);
|
||||
culture ??= domainAndUri.Culture;
|
||||
redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath, culture);
|
||||
}
|
||||
}
|
||||
|
||||
// important: redirect URLs are always tracked without trailing slashes
|
||||
IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath.TrimEnd("/"), culture);
|
||||
IPublishedContent? content = redirectUrl != null
|
||||
? _apiPublishedContentCache.GetById(redirectUrl.ContentKey)
|
||||
: null;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SixLabors.ImageSharp" VersionOverride="[2.1.9, 3)" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" VersionOverride="[2.1.10, 3)" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Web" VersionOverride="[2.0.2, 3)" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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+";
|
||||
}
|
||||
|
||||
+16
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public class RepositoryCachePolicyOptions
|
||||
public RepositoryCachePolicyOptions(Func<int> performCount)
|
||||
{
|
||||
PerformCount = performCount;
|
||||
CacheNullValues = false;
|
||||
GetAllCacheValidateCount = true;
|
||||
GetAllCacheAllowZeroCount = false;
|
||||
}
|
||||
@@ -21,6 +22,7 @@ public class RepositoryCachePolicyOptions
|
||||
public RepositoryCachePolicyOptions()
|
||||
{
|
||||
PerformCount = null;
|
||||
CacheNullValues = false;
|
||||
GetAllCacheValidateCount = false;
|
||||
GetAllCacheAllowZeroCount = false;
|
||||
}
|
||||
@@ -30,6 +32,11 @@ public class RepositoryCachePolicyOptions
|
||||
/// </summary>
|
||||
public Func<int>? PerformCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if the Get method will cache null results so that the db is not hit for repeated lookups
|
||||
/// </summary>
|
||||
public bool CacheNullValues { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True/false as to validate the total item count when all items are returned from cache, the default is true but this
|
||||
/// means that a db lookup will occur - though that lookup will probably be significantly less expensive than the
|
||||
|
||||
@@ -34,7 +34,7 @@ public class TypeFinder : ITypeFinder
|
||||
"ServiceStack.", "SqlCE4Umbraco,", "Superpower,", // used by Serilog
|
||||
"System.", "TidyNet,", "TidyNet.", "WebDriver,", "itextsharp,", "mscorlib,", "NUnit,", "NUnit.", "NUnit3.",
|
||||
"Selenium.", "ImageProcessor", "MiniProfiler.", "Owin,", "SQLite",
|
||||
"ReSharperTestRunner", "ReSharperTestRunner32", "ReSharperTestRunner64", // These are used by the Jetbrains Rider IDE and Visual Studio ReSharper Extension
|
||||
"ReSharperTestRunner", "ReSharperTestRunner32", "ReSharperTestRunner64", "ReSharperTestRunnerArm32", "ReSharperTestRunnerArm64", // These are used by the Jetbrains Rider IDE and Visual Studio ReSharper Extension
|
||||
};
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Type?> TypeNamesCache = new();
|
||||
|
||||
@@ -160,6 +160,7 @@ public class ContentSettings
|
||||
internal const bool StaticDisableUnpublishWhenReferenced = false;
|
||||
internal const bool StaticAllowEditInvariantFromNonDefault = false;
|
||||
internal const bool StaticShowDomainWarnings = true;
|
||||
internal const bool StaticShowUnroutableContentWarnings = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the content notification settings.
|
||||
@@ -285,4 +286,10 @@ public class ContentSettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticShowDomainWarnings)]
|
||||
public bool ShowDomainWarnings { get; set; } = StaticShowDomainWarnings;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to show unroutable content warnings.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticShowUnroutableContentWarnings)]
|
||||
public bool ShowUnroutableContentWarnings { get; set; } = StaticShowUnroutableContentWarnings;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public class ModelsBuilderSettings
|
||||
internal const string StaticModelsDirectory = "~/umbraco/models";
|
||||
internal const bool StaticAcceptUnsafeModelsDirectory = false;
|
||||
internal const int StaticDebugLevel = 0;
|
||||
internal const bool StaticIncludeVersionNumberInGeneratedModels = true;
|
||||
private bool _flagOutOfDateModels = true;
|
||||
|
||||
/// <summary>
|
||||
@@ -78,4 +79,16 @@ public class ModelsBuilderSettings
|
||||
/// <remarks>0 means minimal (safe on live site), anything else means more and more details (maybe not safe).</remarks>
|
||||
[DefaultValue(StaticDebugLevel)]
|
||||
public int DebugLevel { get; set; } = StaticDebugLevel;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the version number should be included in generated models.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By default this is written to the <see cref="System.CodeDom.Compiler.GeneratedCodeAttribute"/> output in
|
||||
/// generated code for each property of the model. This can be useful for debugging purposes but isn't essential,
|
||||
/// and it has the causes the generated code to change every time Umbraco is upgraded. In turn, this leads
|
||||
/// to unnecessary code file changes that need to be checked into source control. Default is <c>true</c>.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticIncludeVersionNumberInGeneratedModels)]
|
||||
public bool IncludeVersionNumberInGeneratedModels { get; set; } = StaticIncludeVersionNumberInGeneratedModels;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
@@ -19,12 +20,16 @@ public class SecuritySettings
|
||||
internal const bool StaticAllowEditInvariantFromNonDefault = false;
|
||||
internal const bool StaticAllowConcurrentLogins = false;
|
||||
internal const string StaticAuthCookieName = "UMB_UCONTEXT";
|
||||
internal const bool StaticUsernameIsEmail = true;
|
||||
internal const bool StaticMemberRequireUniqueEmail = true;
|
||||
|
||||
internal const string StaticAllowedUserNameCharacters =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+\\";
|
||||
|
||||
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.
|
||||
@@ -58,7 +63,14 @@ public class SecuritySettings
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the user's email address is to be considered as their username.
|
||||
/// </summary>
|
||||
public bool UsernameIsEmail { get; set; } = true;
|
||||
[DefaultValue(StaticUsernameIsEmail)]
|
||||
public bool UsernameIsEmail { get; set; } = StaticUsernameIsEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the member's email address must be unique.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticMemberRequireUniqueEmail)]
|
||||
public bool MemberRequireUniqueEmail { get; set; } = StaticMemberRequireUniqueEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of allowed characters for a username
|
||||
@@ -116,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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@using Umbraco.Extensions
|
||||
|
||||
@{
|
||||
var isLoggedIn = Context.User?.Identity?.IsAuthenticated ?? false;
|
||||
var isLoggedIn = Context.User.GetMemberIdentity()?.IsAuthenticated ?? false;
|
||||
var logoutModel = new PostRedirectModel();
|
||||
// You can modify this to redirect to a different URL instead of the current one
|
||||
logoutModel.RedirectUrl = null;
|
||||
@@ -15,7 +15,7 @@
|
||||
{
|
||||
<div class="login-status">
|
||||
|
||||
<p>Welcome back <strong>@Context?.User?.Identity?.Name</strong>!</p>
|
||||
<p>Welcome back <strong>@Context.User?.GetMemberIdentity()?.Name</strong>!</p>
|
||||
|
||||
@using (Html.BeginUmbracoForm<UmbLoginStatusController>("HandleLogout", new { RedirectUrl = logoutModel.RedirectUrl }))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ namespace Umbraco.Cms.Core.IO
|
||||
|
||||
// nothing prevents us to reach the file, security-wise, yet it is outside
|
||||
// this filesystem's root - throw
|
||||
throw new UnauthorizedAccessException($"File original: [{originalPath}] full: [{path}] is outside this filesystem's root.");
|
||||
throw new UnauthorizedAccessException($"Requested path {originalPath} is outside this filesystem's root.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
namespace Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
/// <summary>
|
||||
/// The action associated with saving a content item
|
||||
/// The action associated with saving a content item.
|
||||
/// </summary>
|
||||
public enum ContentSaveAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves the content item, no publish
|
||||
/// Saves the content item, no publish.
|
||||
/// </summary>
|
||||
Save = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new content item
|
||||
/// Creates a new content item.
|
||||
/// </summary>
|
||||
SaveNew = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Saves and publishes the content item
|
||||
/// Saves and publishes the content item.
|
||||
/// </summary>
|
||||
Publish = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Creates and publishes a new content item
|
||||
/// Creates and publishes a new content item.
|
||||
/// </summary>
|
||||
PublishNew = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Saves and sends publish notification
|
||||
/// Saves and sends publish notification.
|
||||
/// </summary>
|
||||
SendPublish = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Creates and sends publish notification
|
||||
/// Creates and sends publish notification.
|
||||
/// </summary>
|
||||
SendPublishNew = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Saves and schedules publishing
|
||||
/// Saves and schedules publishing.
|
||||
/// </summary>
|
||||
Schedule = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Creates and schedules publishing
|
||||
/// Creates and schedules publishing.
|
||||
/// </summary>
|
||||
ScheduleNew = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Saves and publishes the content item including all descendants that have a published version
|
||||
/// Saves and publishes the content item including all descendants that have a published version.
|
||||
/// </summary>
|
||||
PublishWithDescendants = 8,
|
||||
|
||||
/// <summary>
|
||||
/// Creates and publishes the content item including all descendants that have a published version
|
||||
/// Creates and publishes the new content item including all descendants that have a published version.
|
||||
/// </summary>
|
||||
PublishWithDescendantsNew = 9,
|
||||
|
||||
/// <summary>
|
||||
/// Saves and publishes the content item including all descendants regardless of whether they have a published version
|
||||
/// or not
|
||||
/// or not.
|
||||
/// </summary>
|
||||
PublishWithDescendantsForce = 10,
|
||||
|
||||
/// <summary>
|
||||
/// Creates and publishes the content item including all descendants regardless of whether they have a published
|
||||
/// version or not
|
||||
/// Creates and publishes the new content item including all descendants regardless of whether they have a published
|
||||
/// version or not.
|
||||
/// </summary>
|
||||
PublishWithDescendantsForceNew = 11,
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ public class PropertyTypeBasic
|
||||
|
||||
[Required]
|
||||
[RegularExpression(@"^([a-zA-Z]\w.*)$", ErrorMessage = "Invalid alias")]
|
||||
[MaxLength(255, ErrorMessage = "Alias is too long")]
|
||||
[DataMember(Name = "alias")]
|
||||
public string Alias { get; set; } = null!;
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Umbraco.Cms.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the options available with publishing a content branch for force publishing.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum PublishBranchFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// The default behavior is to publish only the published content that has changed.
|
||||
/// </summary>
|
||||
Default = 0,
|
||||
|
||||
/// <summary>
|
||||
/// For publishing a branch, publish all changed content, including content that is not published.
|
||||
/// </summary>
|
||||
IncludeUnpublished = 1,
|
||||
|
||||
/// <summary>
|
||||
/// For publishing a branch, force republishing of all published content, including content that has not changed.
|
||||
/// </summary>
|
||||
ForceRepublish = 2,
|
||||
|
||||
/// <summary>
|
||||
/// For publishing a branch, publish all content, including content that is not published and content that has not changed.
|
||||
/// </summary>
|
||||
All = IncludeUnpublished | ForceRepublish,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Umbraco.Cms.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies options for publishing notifcations when saving.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum PublishNotificationSaveOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Do not publish any notifications.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Only publish the saving notification.
|
||||
/// </summary>
|
||||
Saving = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Only publish the saved notification.
|
||||
/// </summary>
|
||||
Saved = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Publish all the notifications.
|
||||
/// </summary>
|
||||
All = Saving | Saved,
|
||||
}
|
||||
@@ -3,23 +3,29 @@ using Umbraco.Cms.Core.Security;
|
||||
namespace Umbraco.Cms.Core.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for external logins with Guid as key, so it can be shared for members and users
|
||||
/// Repository for external logins with Guid as key, so it can be shared for members and users.
|
||||
/// </summary>
|
||||
public interface IExternalLoginWithKeyRepository : IReadWriteQueryRepository<int, IIdentityUserLogin>,
|
||||
IQueryRepository<IIdentityUserToken>
|
||||
{
|
||||
/// <summary>
|
||||
/// Replaces all external login providers for the user/member key
|
||||
/// Replaces all external login providers for the user/member key.
|
||||
/// </summary>
|
||||
void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces all external login provider tokens for the providers specified for the user/member key
|
||||
/// Replaces all external login provider tokens for the providers specified for the user/member key.
|
||||
/// </summary>
|
||||
void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all external logins for the specified the user/member key
|
||||
/// Deletes all external logins for the specified the user/member key.
|
||||
/// </summary>
|
||||
void DeleteUserLogins(Guid userOrMemberKey);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes external logins that aren't associated with the current collection of providers.
|
||||
/// </summary>
|
||||
/// <param name="currentLoginProviders">The names of the currently configured providers.</param>
|
||||
void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,11 @@ public interface IMemberRepository : IContentRepository<int, IMember>
|
||||
/// <param name="query"></param>
|
||||
/// <returns></returns>
|
||||
int GetCountByQuery(IQuery<IMember>? query);
|
||||
|
||||
/// <summary>
|
||||
/// Saves only the properties related to login for the member, using an optimized, non-locking update.
|
||||
/// </summary>
|
||||
/// <param name="member">The member to update.</param>
|
||||
/// <returns>Used to avoid the full save of the member object after a login operation.</returns>
|
||||
Task UpdateLoginPropertiesAsync(IMember member) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Linq.Expressions;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
|
||||
@@ -109,5 +109,26 @@ public interface IUserRepository : IReadWriteQueryRepository<int, IUser>
|
||||
|
||||
void ClearLoginSession(Guid sessionId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a page of users, ordered by Id and starting from the provided Id.
|
||||
/// </summary>
|
||||
/// <param name="id">The user Id to start retrieving users from.</param>
|
||||
/// <param name="count">The number of users to return.</param>
|
||||
/// <returns>A page of <see cref="IUser"/> instances.</returns>
|
||||
[Obsolete("No longer used in Umbraco. Scheduled for removal in Umbraco 18.")]
|
||||
IEnumerable<IUser> GetNextUsers(int id, int count);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a page of approved users, ordered by Id and starting from the provided Id.
|
||||
/// </summary>
|
||||
/// <param name="id">The user Id to start retrieving users from.</param>
|
||||
/// <param name="count">The number of users to return.</param>
|
||||
/// <returns>A page of <see cref="IUser"/> instances.</returns>
|
||||
IEnumerable<IUser> GetNextApprovedUsers(int id, int count) => Enumerable.Empty<IUser>();
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates sessions for users that aren't associated with the current collection of providers.
|
||||
/// </summary>
|
||||
/// <param name="currentProviderKeys">The keys for the currently configured providers.</param>
|
||||
void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentProviderKeys) { }
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ public interface IPublishedSnapshotService : IDisposable
|
||||
/// </remarks>
|
||||
IPublishedSnapshot CreatePublishedSnapshot(string? previewToken);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if the database cache is in the process of being rebuilt.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
bool IsRebuilding() => false;
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds internal database caches (but does not reload).
|
||||
/// </summary>
|
||||
@@ -61,6 +67,38 @@ public interface IPublishedSnapshotService : IDisposable
|
||||
IReadOnlyCollection<int>? mediaTypeIds = null,
|
||||
IReadOnlyCollection<int>? memberTypeIds = null);
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds internal database caches (but does not reload).
|
||||
/// </summary>
|
||||
/// <param name="contentTypeIds">
|
||||
/// If not null will process content for the matching content types, if empty will process all
|
||||
/// content
|
||||
/// </param>
|
||||
/// <param name="mediaTypeIds">
|
||||
/// If not null will process content for the matching media types, if empty will process all
|
||||
/// media
|
||||
/// </param>
|
||||
/// <param name="memberTypeIds">
|
||||
/// If not null will process content for the matching members types, if empty will process all
|
||||
/// members
|
||||
/// </param>
|
||||
/// <param name="useBackgroundThread">Flag indicating whether to use a background thread for the operation and immediately return to the caller.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Forces the snapshot service to rebuild its internal database caches. For instance, some caches
|
||||
/// may rely on a database table to store pre-serialized version of documents.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This does *not* reload the caches. Caches need to be reloaded, for instance via
|
||||
/// <see cref="DistributedCache" /> RefreshAllPublishedSnapshot method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
void Rebuild(
|
||||
bool useBackgroundThread,
|
||||
IReadOnlyCollection<int>? contentTypeIds = null,
|
||||
IReadOnlyCollection<int>? mediaTypeIds = null,
|
||||
IReadOnlyCollection<int>? memberTypeIds = null) => Rebuild(contentTypeIds, mediaTypeIds, memberTypeIds);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds all internal database caches (but does not reload).
|
||||
@@ -77,6 +115,22 @@ public interface IPublishedSnapshotService : IDisposable
|
||||
/// </remarks>
|
||||
void RebuildAll() => Rebuild(Array.Empty<int>(), Array.Empty<int>(), Array.Empty<int>());
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds all internal database caches (but does not reload).
|
||||
/// </summary>
|
||||
/// <param name="useBackgroundThread">Flag indicating whether to use a background thread for the operation and immediately return to the caller.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Forces the snapshot service to rebuild its internal database caches. For instance, some caches
|
||||
/// may rely on a database table to store pre-serialized version of documents.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This does *not* reload the caches. Caches need to be reloaded, for instance via
|
||||
/// <see cref="DistributedCache" /> RefreshAllPublishedSnapshot method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
void RebuildAll(bool useBackgroundThread) => Rebuild(useBackgroundThread, Array.Empty<int>(), Array.Empty<int>(), Array.Empty<int>());
|
||||
|
||||
/* An IPublishedCachesService implementation can rely on transaction-level events to update
|
||||
* its internal, database-level data, as these events are purely internal. However, it cannot
|
||||
* rely on cache refreshers CacheUpdated events to update itself, as these events are external
|
||||
|
||||
@@ -33,7 +33,7 @@ public interface ITagQuery
|
||||
/// <summary>
|
||||
/// Gets all document tags.
|
||||
/// </summary>
|
||||
/// /// <remarks>
|
||||
/// <remarks>
|
||||
/// If no culture is specified, it retrieves tags with an invariant culture.
|
||||
/// If a culture is specified, it only retrieves tags for that culture.
|
||||
/// Use "*" to retrieve tags for all cultures.
|
||||
|
||||
@@ -1963,17 +1963,14 @@ public class ContentService : RepositoryService, IContentService
|
||||
}
|
||||
|
||||
// utility 'ShouldPublish' func used by SaveAndPublishBranch
|
||||
private HashSet<string>? SaveAndPublishBranch_ShouldPublish(ref HashSet<string>? cultures, string c, bool published, bool edited, bool isRoot, bool force)
|
||||
private HashSet<string>? SaveAndPublishBranch_ShouldPublish(ref HashSet<string>? cultures, string c, bool published, bool edited, bool isRoot, PublishBranchFilter publishBranchFilter)
|
||||
{
|
||||
// if published, republish
|
||||
if (published)
|
||||
{
|
||||
if (cultures == null)
|
||||
{
|
||||
cultures = new HashSet<string>(); // empty means 'already published'
|
||||
}
|
||||
cultures ??= []; // empty means 'already published'
|
||||
|
||||
if (edited)
|
||||
if (edited || publishBranchFilter.HasFlag(PublishBranchFilter.ForceRepublish))
|
||||
{
|
||||
cultures.Add(c); // <culture> means 'republish this culture'
|
||||
}
|
||||
@@ -1982,15 +1979,12 @@ public class ContentService : RepositoryService, IContentService
|
||||
}
|
||||
|
||||
// if not published, publish if force/root else do nothing
|
||||
if (!force && !isRoot)
|
||||
if (!publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) && !isRoot)
|
||||
{
|
||||
return cultures; // null means 'nothing to do'
|
||||
}
|
||||
|
||||
if (cultures == null)
|
||||
{
|
||||
cultures = new HashSet<string>();
|
||||
}
|
||||
cultures ??= [];
|
||||
|
||||
cultures.Add(c); // <culture> means 'publish this culture'
|
||||
return cultures;
|
||||
@@ -1998,6 +1992,10 @@ public class ContentService : RepositoryService, IContentService
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, bool force, string culture = "*", int userId = Constants.Security.SuperUserId)
|
||||
=> SaveAndPublishBranch(content, force ? PublishBranchFilter.IncludeUnpublished : PublishBranchFilter.Default, culture, userId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string culture = "*", int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
// note: EditedValue and PublishedValue are objects here, so it is important to .Equals()
|
||||
// and not to == them, else we would be comparing references, and that is a bad thing
|
||||
@@ -2016,13 +2014,13 @@ public class ContentService : RepositoryService, IContentService
|
||||
// invariant content type
|
||||
if (!c.ContentType.VariesByCulture())
|
||||
{
|
||||
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, force);
|
||||
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, publishBranchFilter);
|
||||
}
|
||||
|
||||
// variant content type, specific culture
|
||||
if (culture != "*")
|
||||
{
|
||||
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, culture, c.IsCulturePublished(culture), c.IsCultureEdited(culture), isRoot, force);
|
||||
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, culture, c.IsCulturePublished(culture), c.IsCultureEdited(culture), isRoot, publishBranchFilter);
|
||||
}
|
||||
|
||||
// variant content type, all cultures
|
||||
@@ -2032,23 +2030,27 @@ public class ContentService : RepositoryService, IContentService
|
||||
// others will have to 'republish this culture'
|
||||
foreach (var x in c.AvailableCultures)
|
||||
{
|
||||
SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, force);
|
||||
SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, publishBranchFilter);
|
||||
}
|
||||
|
||||
return culturesToPublish;
|
||||
}
|
||||
|
||||
// if not published, publish if force/root else do nothing
|
||||
return force || isRoot
|
||||
// if not published, publish if forcing unpublished/root else do nothing
|
||||
return publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) || isRoot
|
||||
? new HashSet<string> { "*" } // "*" means 'publish all'
|
||||
: null; // null means 'nothing to do'
|
||||
}
|
||||
|
||||
return SaveAndPublishBranch(content, force, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId);
|
||||
return SaveAndPublishBranch(content, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, bool force, string[] cultures, int userId = Constants.Security.SuperUserId)
|
||||
=> SaveAndPublishBranch(content, force ? PublishBranchFilter.IncludeUnpublished : PublishBranchFilter.Default, cultures, userId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
// note: EditedValue and PublishedValue are objects here, so it is important to .Equals()
|
||||
// and not to == them, else we would be comparing references, and that is a bad thing
|
||||
@@ -2064,7 +2066,7 @@ public class ContentService : RepositoryService, IContentService
|
||||
// invariant content type
|
||||
if (!c.ContentType.VariesByCulture())
|
||||
{
|
||||
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, force);
|
||||
return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, publishBranchFilter);
|
||||
}
|
||||
|
||||
// variant content type, specific cultures
|
||||
@@ -2074,24 +2076,23 @@ public class ContentService : RepositoryService, IContentService
|
||||
// others will have to 'republish this culture'
|
||||
foreach (var x in cultures)
|
||||
{
|
||||
SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, force);
|
||||
SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, publishBranchFilter);
|
||||
}
|
||||
|
||||
return culturesToPublish;
|
||||
}
|
||||
|
||||
// if not published, publish if force/root else do nothing
|
||||
return force || isRoot
|
||||
// if not published, publish if forcing unpublished/root else do nothing
|
||||
return publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) || isRoot
|
||||
? new HashSet<string>(cultures) // means 'publish specified cultures'
|
||||
: null; // null means 'nothing to do'
|
||||
}
|
||||
|
||||
return SaveAndPublishBranch(content, force, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId);
|
||||
return SaveAndPublishBranch(content, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId);
|
||||
}
|
||||
|
||||
internal IEnumerable<PublishResult> SaveAndPublishBranch(
|
||||
IContent document,
|
||||
bool force,
|
||||
Func<IContent, HashSet<string>?> shouldPublish,
|
||||
Func<IContent, HashSet<string>, IReadOnlyCollection<ILanguage>, bool> publishCultures,
|
||||
int userId = Constants.Security.SuperUserId)
|
||||
@@ -3626,6 +3627,7 @@ public class ContentService : RepositoryService, IContentService
|
||||
|
||||
private static readonly string?[] ArrayOfOneNullString = { null };
|
||||
|
||||
/// <inheritdoc />
|
||||
public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
if (blueprint == null)
|
||||
|
||||
@@ -108,7 +108,7 @@ public class DomainService : RepositoryService, IDomainService
|
||||
EventMessages eventMessages = EventMessagesFactory.Get();
|
||||
|
||||
IDomain[] domains = items.ToArray();
|
||||
if (domains.Length == 0)
|
||||
if (domains.Length == 0 || AreDomainsAlreadySorted(domains))
|
||||
{
|
||||
return OperationResult.Attempt.NoOperation(eventMessages);
|
||||
}
|
||||
@@ -144,4 +144,18 @@ public class DomainService : RepositoryService, IDomainService
|
||||
|
||||
return OperationResult.Attempt.Succeed(eventMessages);
|
||||
}
|
||||
|
||||
private static bool AreDomainsAlreadySorted(IDomain[] domains)
|
||||
{
|
||||
// Check if the domains are already sorted by comparing the current sort order with what we'll set to be the new sort order.
|
||||
for (int i = 0; i < domains.Length; i++)
|
||||
{
|
||||
if (domains[i].SortOrder != i)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,4 +80,14 @@ public class ExternalLoginService : RepositoryService, IExternalLoginWithKeyServ
|
||||
scope.Complete();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders)
|
||||
{
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
|
||||
{
|
||||
_externalLoginRepository.DeleteUserLoginsForRemovedProviders(currentLoginProviders);
|
||||
scope.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ public interface IContentService : IContentServiceBase<IContent>
|
||||
/// <summary>
|
||||
/// Creates a new content item from a blueprint.
|
||||
/// </summary>
|
||||
/// <remarks>Warning: If you intend to save the resulting <c>IContent</c> as a content node, you must trigger a
|
||||
/// <see cref="Notifications.ContentScaffoldedNotification"/> notification to ensure that the block ids are regenerated.
|
||||
/// Failing to do so could lead to caching issues.</remarks>
|
||||
IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
@@ -431,6 +434,7 @@ public interface IContentService : IContentServiceBase<IContent>
|
||||
/// published. The root of the branch is always published, regardless of <paramref name="force" />.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead.")]
|
||||
IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, bool force, string culture = "*", int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
@@ -447,8 +451,47 @@ public interface IContentService : IContentServiceBase<IContent>
|
||||
/// published. The root of the branch is always published, regardless of <paramref name="force" />.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead.")]
|
||||
IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, bool force, string[] cultures, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Saves and publishes a document branch.
|
||||
/// </summary>
|
||||
/// <param name="content">The root document.</param>
|
||||
/// <param name="publishBranchFilter">A value indicating options for force publishing unpublished or re-publishing unchanged content.</param>
|
||||
/// <param name="culture">A culture, or "*" for all cultures.</param>
|
||||
/// <param name="userId">The identifier of the user performing the operation.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Unless specified, all cultures are re-published. Otherwise, one culture can be specified. To act on more
|
||||
/// than one culture, see the other overloads of this method.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The root of the branch is always published, regardless of <paramref name="publishBranchFilter" />.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string culture = "*", int userId = Constants.Security.SuperUserId)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> SaveAndPublishBranch(content, publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished), culture, userId);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
/// <summary>
|
||||
/// Saves and publishes a document branch.
|
||||
/// </summary>
|
||||
/// <param name="content">The root document.</param>
|
||||
/// <param name="publishBranchFilter">A value indicating options for force publishing unpublished or re-publishing unchanged content.</param>
|
||||
/// <param name="cultures">The cultures to publish.</param>
|
||||
/// <param name="userId">The identifier of the user performing the operation.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The root of the branch is always published, regardless of <paramref name="publishBranchFilter" />.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
IEnumerable<PublishResult> SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> SaveAndPublishBranch(content, publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished), cultures, userId);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
///// <summary>
|
||||
///// Saves and publishes a document branch.
|
||||
///// </summary>
|
||||
|
||||
@@ -5,47 +5,53 @@ namespace Umbraco.Cms.Core.Services;
|
||||
public interface IExternalLoginWithKeyService : IService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns all user logins assigned
|
||||
/// Returns all user logins assigned.
|
||||
/// </summary>
|
||||
IEnumerable<IIdentityUserLogin> GetExternalLogins(Guid userOrMemberKey);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all user login tokens assigned
|
||||
/// Returns all user login tokens assigned.
|
||||
/// </summary>
|
||||
IEnumerable<IIdentityUserToken> GetExternalLoginTokens(Guid userOrMemberKey);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all logins matching the login info - generally there should only be one but in some cases
|
||||
/// there might be more than one depending on if an administrator has been editing/removing members
|
||||
/// there might be more than one depending on if an administrator has been editing/removing members.
|
||||
/// </summary>
|
||||
IEnumerable<IIdentityUserLogin> Find(string loginProvider, string providerKey);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the external logins associated with the user
|
||||
/// Saves the external logins associated with the user.
|
||||
/// </summary>
|
||||
/// <param name="userOrMemberKey">
|
||||
/// The user or member key associated with the logins
|
||||
/// The user or member key associated with the logins.
|
||||
/// </param>
|
||||
/// <param name="logins"></param>
|
||||
/// <remarks>
|
||||
/// This will replace all external login provider information for the user
|
||||
/// This will replace all external login provider information for the user.
|
||||
/// </remarks>
|
||||
void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the external login tokens associated with the user
|
||||
/// Saves the external login tokens associated with the user.
|
||||
/// </summary>
|
||||
/// <param name="userOrMemberKey">
|
||||
/// The user or member key associated with the logins
|
||||
/// The user or member key associated with the logins.
|
||||
/// </param>
|
||||
/// <param name="tokens"></param>
|
||||
/// <remarks>
|
||||
/// This will replace all external login tokens for the user
|
||||
/// This will replace all external login tokens for the user.
|
||||
/// </remarks>
|
||||
void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all user logins - normally used when a member is deleted
|
||||
/// Deletes all user logins - normally used when a member is deleted.
|
||||
/// </summary>
|
||||
void DeleteUserLogins(Guid userOrMemberKey);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes external logins that aren't associated with the current collection of providers.
|
||||
/// </summary>
|
||||
/// <param name="currentLoginProviders">The names of the currently configured providers.</param>
|
||||
void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
|
||||
}
|
||||
|
||||
@@ -210,6 +210,21 @@ public interface IMemberService : IMembershipMemberService
|
||||
/// </returns>
|
||||
IMember? GetById(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Get an list of <see cref="IMember"/> for all members with the specified email.
|
||||
/// </summary>
|
||||
//// <param name="email">Email to use for retrieval</param>
|
||||
/// <returns>
|
||||
/// <see cref="IEnumerable{IMember}" />
|
||||
/// </returns>
|
||||
IEnumerable<IMember> GetMembersByEmail(string email)
|
||||
=>
|
||||
// TODO (V16): Remove this default implementation.
|
||||
// The following is very inefficient, but will return the correct data, so probably better than throwing a NotImplementedException
|
||||
// in the default implentation here, for, presumably rare, cases where a custom IMemberService implementation has been registered and
|
||||
// does not override this method.
|
||||
GetAllMembers().Where(x => x.Email.Equals(email));
|
||||
|
||||
/// <summary>
|
||||
/// Gets all Members for the specified MemberType alias
|
||||
/// </summary>
|
||||
@@ -328,4 +343,11 @@ public interface IMemberService : IMembershipMemberService
|
||||
/// <see cref="IEnumerable{IMember}" />
|
||||
/// </returns>
|
||||
IEnumerable<IMember>? GetMembersByPropertyValue(string propertyTypeAlias, DateTime value, ValuePropertyMatchType matchType = ValuePropertyMatchType.Exact);
|
||||
|
||||
/// <summary>
|
||||
/// Saves only the properties related to login for the member, using an optimized, non-locking update.
|
||||
/// </summary>
|
||||
/// <param name="member">The member to update.</param>
|
||||
/// <returns>Used to avoid the full save of the member object after a login operation.</returns>
|
||||
Task UpdateLoginPropertiesAsync(IMember member) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -135,6 +135,14 @@ public interface IMembershipMemberService<T> : IService
|
||||
/// <param name="entity"><see cref="IMember" /> or <see cref="IUser" /> to Save</param>
|
||||
void Save(T entity);
|
||||
|
||||
/// <summary>
|
||||
/// Saves an <see cref="IMembershipUser" />
|
||||
/// </summary>
|
||||
/// <remarks>An <see cref="IMembershipUser" /> can be of type <see cref="IMember" /> or <see cref="IUser" /></remarks>
|
||||
/// <param name="entity"><see cref="IMember" /> or <see cref="IUser" /> to Save</param>
|
||||
/// <param name="publishNotificationSaveOptions"> Enum for deciding which notifications to publish.</param>
|
||||
void Save(T entity, PublishNotificationSaveOptions publishNotificationSaveOptions) => Save(entity);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a list of <see cref="IMembershipUser" /> objects
|
||||
/// </summary>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -389,16 +389,23 @@ namespace Umbraco.Cms.Core.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get an <see cref="IMember"/> by email
|
||||
/// Get an <see cref="IMember"/> by email. If RequireUniqueEmailForMembers is set to false, then the first member found with the specified email will be returned.
|
||||
/// </summary>
|
||||
/// <param name="email">Email to use for retrieval</param>
|
||||
/// <returns><see cref="IMember"/></returns>
|
||||
public IMember? GetByEmail(string email)
|
||||
public IMember? GetByEmail(string email) => GetMembersByEmail(email).FirstOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Get an list of <see cref="IMember"/> for all members with the specified email.
|
||||
/// </summary>
|
||||
/// <param name="email">Email to use for retrieval</param>
|
||||
/// <returns><see cref="IEnumerable{IMember}"/></returns>
|
||||
public IEnumerable<IMember> GetMembersByEmail(string email)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
scope.ReadLock(Constants.Locks.MemberTree);
|
||||
IQuery<IMember> query = Query<IMember>().Where(x => x.Email.Equals(email));
|
||||
return _memberRepository.Get(query)?.FirstOrDefault();
|
||||
return _memberRepository.Get(query);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -736,7 +743,9 @@ namespace Umbraco.Cms.Core.Services
|
||||
public void SetLastLogin(string username, DateTime date) => throw new NotImplementedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save(IMember member)
|
||||
public void Save(IMember member) => Save(member, PublishNotificationSaveOptions.All);
|
||||
|
||||
public void Save(IMember member, PublishNotificationSaveOptions publishNotificationSaveOptions)
|
||||
{
|
||||
// trimming username and email to make sure we have no trailing space
|
||||
member.Username = member.Username.Trim();
|
||||
@@ -745,11 +754,15 @@ namespace Umbraco.Cms.Core.Services
|
||||
EventMessages evtMsgs = EventMessagesFactory.Get();
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
var savingNotification = new MemberSavingNotification(member, evtMsgs);
|
||||
if (scope.Notifications.PublishCancelable(savingNotification))
|
||||
MemberSavingNotification? savingNotification = null;
|
||||
if (publishNotificationSaveOptions.HasFlag(PublishNotificationSaveOptions.Saving))
|
||||
{
|
||||
scope.Complete();
|
||||
return;
|
||||
savingNotification = new MemberSavingNotification(member, evtMsgs);
|
||||
if (scope.Notifications.PublishCancelable(savingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(member.Name))
|
||||
@@ -761,7 +774,13 @@ namespace Umbraco.Cms.Core.Services
|
||||
|
||||
_memberRepository.Save(member);
|
||||
|
||||
scope.Notifications.Publish(new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification));
|
||||
if (publishNotificationSaveOptions.HasFlag(PublishNotificationSaveOptions.Saved))
|
||||
{
|
||||
scope.Notifications.Publish(
|
||||
savingNotification is null
|
||||
? new MemberSavedNotification(member, evtMsgs)
|
||||
: new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification));
|
||||
}
|
||||
|
||||
Audit(AuditType.Save, 0, member.Id);
|
||||
|
||||
@@ -801,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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
namespace Umbraco.Cms.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Makes a code block timed (take at least a certain amount of time). This class cannot be inherited.
|
||||
/// </summary>
|
||||
public sealed class TimedScope : IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly TimeSpan _duration;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CancellationTokenSource _cancellationTokenSource;
|
||||
private readonly long _startingTimestamp;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the elapsed time.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The elapsed time.
|
||||
/// </value>
|
||||
public TimeSpan Elapsed
|
||||
=> _timeProvider.GetElapsedTime(_startingTimestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remaining time.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The remaining time.
|
||||
/// </value>
|
||||
public TimeSpan Remaining
|
||||
=> TryGetRemaining(out TimeSpan remaining) ? remaining : TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
|
||||
public TimedScope(long millisecondsDuration)
|
||||
: this(TimeSpan.FromMilliseconds(millisecondsDuration))
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public TimedScope(long millisecondsDuration, CancellationToken cancellationToken)
|
||||
: this(TimeSpan.FromMilliseconds(millisecondsDuration), cancellationToken)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
|
||||
/// <param name="timeProvider">The time provider.</param>
|
||||
public TimedScope(long millisecondsDuration, TimeProvider timeProvider)
|
||||
: this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
|
||||
/// <param name="timeProvider">The time provider.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public TimedScope(long millisecondsDuration, TimeProvider timeProvider, CancellationToken cancellationToken)
|
||||
: this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider, cancellationToken)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope"/> class.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration the scope should at least take.</param>
|
||||
public TimedScope(TimeSpan duration)
|
||||
: this(duration, TimeProvider.System)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration the scope should at least take.</param>
|
||||
/// <param name="timeProvider">The time provider.</param>
|
||||
public TimedScope(TimeSpan duration, TimeProvider timeProvider)
|
||||
: this(duration, timeProvider, new CancellationTokenSource())
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration the scope should at least take.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public TimedScope(TimeSpan duration, CancellationToken cancellationToken)
|
||||
: this(duration, TimeProvider.System, cancellationToken)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration the scope should at least take.</param>
|
||||
/// <param name="timeProvider">The time provider.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationToken cancellationToken)
|
||||
: this(duration, timeProvider, CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
|
||||
{ }
|
||||
|
||||
private TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationTokenSource cancellationTokenSource)
|
||||
{
|
||||
_duration = duration;
|
||||
_timeProvider = timeProvider;
|
||||
_cancellationTokenSource = cancellationTokenSource;
|
||||
_startingTimestamp = timeProvider.GetTimestamp();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the timed scope.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
=> _cancellationTokenSource.Cancel();
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the timed scope asynchronously.
|
||||
/// </summary>
|
||||
public async Task CancelAsync()
|
||||
=> await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This will block using <see cref="Thread.Sleep(TimeSpan)" /> until the remaining time has elapsed, if not cancelled.
|
||||
/// </remarks>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_cancellationTokenSource.IsCancellationRequested is false &&
|
||||
TryGetRemaining(out TimeSpan remaining))
|
||||
{
|
||||
Thread.Sleep(remaining);
|
||||
}
|
||||
|
||||
_cancellationTokenSource.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources asynchronously.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous dispose operation.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This will delay using <see cref="Task.Delay(TimeSpan, TimeProvider, CancellationToken)" /> until the remaining time has elapsed, if not cancelled.
|
||||
/// </remarks>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cancellationTokenSource.IsCancellationRequested is false &&
|
||||
TryGetRemaining(out TimeSpan remaining))
|
||||
{
|
||||
await Task.Delay(remaining, _timeProvider, _cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_cancellationTokenSource.Dispose();
|
||||
}
|
||||
|
||||
private bool TryGetRemaining(out TimeSpan remaining)
|
||||
{
|
||||
remaining = _duration.Subtract(Elapsed);
|
||||
|
||||
return remaining > TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,12 @@ public class WebhookFiring : IRecurringBackgroundJob
|
||||
|
||||
public async Task RunJobAsync()
|
||||
{
|
||||
if (_webhookSettings.Enabled is false)
|
||||
{
|
||||
_logger.LogInformation("WebhookFiring task will not run as it has been globally disabled via configuration");
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable<WebhookRequest> requests;
|
||||
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
|
||||
{
|
||||
|
||||
@@ -116,6 +116,7 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB
|
||||
{
|
||||
// whatever happens, clear the cache
|
||||
var cacheKey = GetEntityCacheKey(entity.Id);
|
||||
|
||||
Cache.Clear(cacheKey);
|
||||
|
||||
// if there's a GetAllCacheAllowZeroCount cache, ensure it is cleared
|
||||
@@ -127,20 +128,34 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB
|
||||
public override TEntity? Get(TId? id, Func<TId?, TEntity?> performGet, Func<TId[]?, IEnumerable<TEntity>?> performGetAll)
|
||||
{
|
||||
var cacheKey = GetEntityCacheKey(id);
|
||||
|
||||
TEntity? fromCache = Cache.GetCacheItem<TEntity>(cacheKey);
|
||||
|
||||
// if found in cache then return else fetch and cache
|
||||
if (fromCache != null)
|
||||
// If found in cache then return immediately.
|
||||
if (fromCache is not null)
|
||||
{
|
||||
return fromCache;
|
||||
}
|
||||
|
||||
// If we've cached a "null" value, return null.
|
||||
if (_options.CacheNullValues && Cache.GetCacheItem<string>(cacheKey) == Constants.Cache.NullRepresentationInCache)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Otherwise go to the database to retrieve.
|
||||
TEntity? entity = performGet(id);
|
||||
|
||||
if (entity != null && entity.HasIdentity)
|
||||
{
|
||||
// If we've found an identified entity, cache it for subsequent retrieval.
|
||||
InsertEntity(cacheKey, entity);
|
||||
}
|
||||
else if (entity is null && _options.CacheNullValues)
|
||||
{
|
||||
// If we've not found an entity, and we're caching null values, cache a "null" value.
|
||||
InsertNull(cacheKey);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
@@ -248,6 +263,15 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB
|
||||
protected virtual void InsertEntity(string cacheKey, TEntity entity)
|
||||
=> Cache.Insert(cacheKey, () => entity, TimeSpan.FromMinutes(5), true);
|
||||
|
||||
protected virtual void InsertNull(string cacheKey)
|
||||
{
|
||||
// We can't actually cache a null value, as in doing so wouldn't be able to distinguish between
|
||||
// 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, () => Constants.Cache.NullRepresentationInCache, TimeSpan.FromMinutes(5), true);
|
||||
}
|
||||
|
||||
protected virtual void InsertEntities(TId[]? ids, TEntity[]? entities)
|
||||
{
|
||||
if (ids?.Length == 0 && entities?.Length == 0 && _options.GetAllCacheAllowZeroCount)
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
using HtmlAgilityPack;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using HtmlAgilityPack;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.DeliveryApi;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Routing;
|
||||
using Umbraco.Cms.Infrastructure.Extensions;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -101,9 +98,9 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich
|
||||
// - non-#comment nodes
|
||||
// - non-#text nodes
|
||||
// - non-empty #text nodes
|
||||
// - empty #text between inline elements (see #17037)
|
||||
// - empty #text between inline elements (see #17037) but not #text with only newlines (see #19388)
|
||||
HtmlNode[] childNodes = element.ChildNodes
|
||||
.Where(c => c.Name != CommentNodeName && (c.Name != TextNodeName || c.NextSibling is not null || string.IsNullOrWhiteSpace(c.InnerText) is false))
|
||||
.Where(c => c.Name != CommentNodeName && (c.Name != TextNodeName || IsNonEmptyElement(c)))
|
||||
.ToArray();
|
||||
|
||||
var tag = TagName(element);
|
||||
@@ -124,6 +121,9 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich
|
||||
return createElement(tag, attributes, childElements);
|
||||
}
|
||||
|
||||
private static bool IsNonEmptyElement(HtmlNode htmlNode) =>
|
||||
string.IsNullOrWhiteSpace(htmlNode.InnerText) is false || htmlNode.InnerText.Any(c => c != '\n' && c != '\r');
|
||||
|
||||
private string TagName(HtmlNode htmlNode) => htmlNode.Name;
|
||||
|
||||
private void ReplaceLocalLinks(IPublishedSnapshot publishedSnapshot, Dictionary<string, object> attributes)
|
||||
|
||||
@@ -240,6 +240,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<IBlockEditorElementTypeCache, BlockEditorElementTypeCache>();
|
||||
|
||||
builder.Services.AddSingleton<IRichTextRequiredValidator, RichTextRequiredValidator>();
|
||||
builder.Services.AddSingleton<IRichTextRegexValidator, RichTextRegexValidator>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
@@ -28,21 +28,28 @@ internal sealed class DeliveryApiContentIndexHelper : IDeliveryApiContentIndexHe
|
||||
public void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action<IContent[]> actionToPerform)
|
||||
{
|
||||
const int pageSize = 10000;
|
||||
var pageIndex = 0;
|
||||
EnumerateApplicableDescendantsForContentIndex(rootContentId, actionToPerform, pageSize);
|
||||
}
|
||||
|
||||
internal void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action<IContent[]> actionToPerform, int pageSize)
|
||||
{
|
||||
var itemIndex = 0;
|
||||
long total;
|
||||
|
||||
IQuery<IContent> query = _umbracoDatabaseFactory.SqlContext.Query<IContent>().Where(content => content.Trashed == false);
|
||||
|
||||
IContent[] descendants;
|
||||
IQuery<IContent> query = _umbracoDatabaseFactory.SqlContext.Query<IContent>().Where(content => content.Trashed == false);
|
||||
do
|
||||
{
|
||||
descendants = _contentService
|
||||
.GetPagedDescendants(rootContentId, pageIndex, pageSize, out _, query, Ordering.By("Path"))
|
||||
.GetPagedDescendants(rootContentId, itemIndex / pageSize, pageSize, out total, query, Ordering.By("Path"))
|
||||
.Where(descendant => _deliveryApiSettings.IsAllowedContentType(descendant.ContentType.Alias))
|
||||
.ToArray();
|
||||
|
||||
actionToPerform(descendants.ToArray());
|
||||
actionToPerform(descendants);
|
||||
|
||||
pageIndex++;
|
||||
itemIndex += pageSize;
|
||||
}
|
||||
while (descendants.Length == pageSize);
|
||||
while (descendants.Length > 0 && itemIndex < total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ public abstract class MigrationExpressionBase : IMigrationExpression
|
||||
}
|
||||
else
|
||||
{
|
||||
if (stmtBuilder.Length > 0)
|
||||
{
|
||||
stmtBuilder.Append(Environment.NewLine);
|
||||
}
|
||||
|
||||
stmtBuilder.Append(line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,6 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
|
||||
/// <para>Each migration in the plan, may or may not run in a scope depending on the type of plan.</para>
|
||||
/// <para>A plan can complete partially, the changes of each completed migration will be saved.</para>
|
||||
/// </remarks>
|
||||
[Obsolete("This will return an ExecutedMigrationPlan in V13")]
|
||||
public ExecutedMigrationPlan ExecutePlan(MigrationPlan plan, string fromState)
|
||||
{
|
||||
plan.Validate();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using NPoco;
|
||||
using NPoco;
|
||||
using Umbraco.Cms.Infrastructure.Persistence;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
|
||||
using ColumnInfo = Umbraco.Cms.Infrastructure.Persistence.SqlSyntax.ColumnInfo;
|
||||
@@ -153,16 +153,26 @@ SELECT obj_Constraint.NAME AS 'constraintName'
|
||||
");
|
||||
var currentConstraintName = Database.ExecuteScalar<string>(constraintNameQuery);
|
||||
|
||||
|
||||
// only rename the constraint if necessary
|
||||
// Only rename the constraint if necessary.
|
||||
if (currentConstraintName == expectedConstraintName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Sql<ISqlContext> renameConstraintQuery = Database.SqlContext.Sql(
|
||||
$"EXEC sp_rename N'{currentConstraintName}', N'{expectedConstraintName}', N'OBJECT'");
|
||||
Database.Execute(renameConstraintQuery);
|
||||
if (currentConstraintName is null)
|
||||
{
|
||||
// Constraint does not exist, so we need to create it.
|
||||
Sql<ISqlContext> createConstraintStatement = Database.SqlContext.Sql(@$"
|
||||
ALTER TABLE umbracoContentVersion ADD CONSTRAINT [DF_umbracoContentVersion_versionDate] DEFAULT (getdate()) FOR [versionDate]");
|
||||
Database.Execute(createConstraintStatement);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Constraint exists, and differs from the expected name, so we need to rename it.
|
||||
Sql<ISqlContext> renameConstraintQuery = Database.SqlContext.Sql(
|
||||
$"EXEC sp_rename N'{currentConstraintName}', N'{expectedConstraintName}', N'OBJECT'");
|
||||
Database.Execute(renameConstraintQuery);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateExternalLoginIndexes(IEnumerable<Tuple<string, string, string, bool>> indexes)
|
||||
|
||||
@@ -51,7 +51,15 @@ public class TextBuilder : Builder
|
||||
/// Outputs an "auto-generated" header to a string builder.
|
||||
/// </summary>
|
||||
/// <param name="sb">The string builder.</param>
|
||||
public static void WriteHeader(StringBuilder sb) => TextHeaderWriter.WriteHeader(sb);
|
||||
[Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 17.")]
|
||||
public static void WriteHeader(StringBuilder sb) => WriteHeader(sb, true);
|
||||
|
||||
/// <summary>
|
||||
/// Outputs an "auto-generated" header to a string builder.
|
||||
/// </summary>
|
||||
/// <param name="sb">The string builder.</param>
|
||||
/// <param name="includeVersion">Flag indicating whether the tool version number should be included in the output.</param>
|
||||
public static void WriteHeader(StringBuilder sb, bool includeVersion) => TextHeaderWriter.WriteHeader(sb, includeVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Outputs a generated model to a string builder.
|
||||
@@ -60,7 +68,7 @@ public class TextBuilder : Builder
|
||||
/// <param name="typeModel">The model to generate.</param>
|
||||
public void Generate(StringBuilder sb, TypeModel typeModel)
|
||||
{
|
||||
WriteHeader(sb);
|
||||
WriteHeader(sb, Config.IncludeVersionNumberInGeneratedModels);
|
||||
|
||||
foreach (var t in TypesUsing)
|
||||
{
|
||||
@@ -83,7 +91,7 @@ public class TextBuilder : Builder
|
||||
/// <param name="typeModels">The models to generate.</param>
|
||||
public void Generate(StringBuilder sb, IEnumerable<TypeModel> typeModels)
|
||||
{
|
||||
WriteHeader(sb);
|
||||
WriteHeader(sb, Config.IncludeVersionNumberInGeneratedModels);
|
||||
|
||||
foreach (var t in TypesUsing)
|
||||
{
|
||||
@@ -143,14 +151,17 @@ public class TextBuilder : Builder
|
||||
//
|
||||
// note that the blog post above clearly states that "Nor should it be applied at the type level if the type being generated is a partial class."
|
||||
// and since our models are partial classes, we have to apply the attribute against the individual members, not the class itself.
|
||||
private static void WriteGeneratedCodeAttribute(StringBuilder sb, string tabs) => sb.AppendFormat(
|
||||
private void WriteGeneratedCodeAttribute(StringBuilder sb, string tabs) => sb.AppendFormat(
|
||||
"{0}[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"Umbraco.ModelsBuilder.Embedded\", \"{1}\")]\n",
|
||||
tabs, ApiVersion.Current.Version);
|
||||
tabs,
|
||||
Config.IncludeVersionNumberInGeneratedModels ? ApiVersion.Current.Version : null);
|
||||
|
||||
// writes an attribute that specifies that an output may be null.
|
||||
// (useful for consuming projects with nullable reference types enabled)
|
||||
private static void WriteMaybeNullAttribute(StringBuilder sb, string tabs, bool isReturn = false) =>
|
||||
sb.AppendFormat("{0}[{1}global::System.Diagnostics.CodeAnalysis.MaybeNull]\n", tabs,
|
||||
sb.AppendFormat(
|
||||
"{0}[{1}global::System.Diagnostics.CodeAnalysis.MaybeNull]\n",
|
||||
tabs,
|
||||
isReturn ? "return: " : string.Empty);
|
||||
|
||||
private static string MixinStaticGetterName(string clrName) => string.Format("Get{0}", clrName);
|
||||
|
||||
@@ -8,13 +8,30 @@ internal static class TextHeaderWriter
|
||||
/// Outputs an "auto-generated" header to a string builder.
|
||||
/// </summary>
|
||||
/// <param name="sb">The string builder.</param>
|
||||
public static void WriteHeader(StringBuilder sb)
|
||||
[Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 17.")]
|
||||
public static void WriteHeader(StringBuilder sb) => WriteHeader(sb, true);
|
||||
|
||||
/// <summary>
|
||||
/// Outputs an "auto-generated" header to a string builder.
|
||||
/// </summary>
|
||||
/// <param name="sb">The string builder.</param>
|
||||
/// <param name="includeVersion">Flag indicating whether the tool version number should be included in the output.</param>
|
||||
public static void WriteHeader(StringBuilder sb, bool includeVersion)
|
||||
{
|
||||
sb.Append("//------------------------------------------------------------------------------\n");
|
||||
sb.Append("// <auto-generated>\n");
|
||||
sb.Append("// This code was generated by a tool.\n");
|
||||
sb.Append("//\n");
|
||||
sb.AppendFormat("// Umbraco.ModelsBuilder.Embedded v{0}\n", ApiVersion.Current.Version);
|
||||
|
||||
if (includeVersion)
|
||||
{
|
||||
sb.AppendFormat("// Umbraco.ModelsBuilder.Embedded v{0}\n", ApiVersion.Current.Version);
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("// Umbraco.ModelsBuilder.Embedded\n");
|
||||
}
|
||||
|
||||
sb.Append("//\n");
|
||||
sb.Append("// Changes to this file will be lost if the code is regenerated.\n");
|
||||
sb.Append("// </auto-generated>\n");
|
||||
|
||||
+8
-9
@@ -102,11 +102,10 @@ internal class DictionaryRepository : EntityRepositoryBase<int, IDictionaryItem>
|
||||
var options = new RepositoryCachePolicyOptions
|
||||
{
|
||||
// allow zero to be cached
|
||||
GetAllCacheAllowZeroCount = true,
|
||||
GetAllCacheAllowZeroCount = true
|
||||
};
|
||||
|
||||
return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, int>(GlobalIsolatedCache, ScopeAccessor,
|
||||
options);
|
||||
return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, int>(GlobalIsolatedCache, ScopeAccessor, options);
|
||||
}
|
||||
|
||||
protected IDictionaryItem ConvertFromDto(DictionaryDto dto)
|
||||
@@ -190,11 +189,10 @@ internal class DictionaryRepository : EntityRepositoryBase<int, IDictionaryItem>
|
||||
var options = new RepositoryCachePolicyOptions
|
||||
{
|
||||
// allow zero to be cached
|
||||
GetAllCacheAllowZeroCount = true,
|
||||
GetAllCacheAllowZeroCount = true
|
||||
};
|
||||
|
||||
return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, Guid>(GlobalIsolatedCache, ScopeAccessor,
|
||||
options);
|
||||
return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, Guid>(GlobalIsolatedCache, ScopeAccessor, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,12 +226,13 @@ internal class DictionaryRepository : EntityRepositoryBase<int, IDictionaryItem>
|
||||
{
|
||||
var options = new RepositoryCachePolicyOptions
|
||||
{
|
||||
// allow null to be cached
|
||||
CacheNullValues = true,
|
||||
// allow zero to be cached
|
||||
GetAllCacheAllowZeroCount = true,
|
||||
GetAllCacheAllowZeroCount = true
|
||||
};
|
||||
|
||||
return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, string>(GlobalIsolatedCache, ScopeAccessor,
|
||||
options);
|
||||
return new SingleItemsOnlyRepositoryCachePolicy<IDictionaryItem, string>(GlobalIsolatedCache, ScopeAccessor, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-7
@@ -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;
|
||||
}
|
||||
@@ -387,7 +394,9 @@ WHERE r.tagId IS NULL";
|
||||
}).ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetTagsForEntityType(TaggableObjectTypes objectType, string? group = null,
|
||||
public IEnumerable<ITag> GetTagsForEntityType(
|
||||
TaggableObjectTypes objectType,
|
||||
string? group = null,
|
||||
string? culture = null)
|
||||
{
|
||||
Sql<ISqlContext> sql = GetTagsSql(culture, true);
|
||||
@@ -401,6 +410,9 @@ WHERE r.tagId IS NULL";
|
||||
.Where<NodeDto>(dto => dto.NodeObjectType == nodeObjectType);
|
||||
}
|
||||
|
||||
sql = sql
|
||||
.Where<NodeDto>(dto => !dto.Trashed);
|
||||
|
||||
if (group.IsNullOrWhiteSpace() == false)
|
||||
{
|
||||
sql = sql
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ using HtmlAgilityPack;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Exceptions;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Media;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Routing;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
@@ -38,6 +40,9 @@ public sealed class RichTextEditorPastedImages
|
||||
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
|
||||
private readonly string _tempFolderAbsolutePath;
|
||||
private readonly IImageUrlGenerator _imageUrlGenerator;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly AppCaches _appCaches;
|
||||
private readonly ContentSettings _contentSettings;
|
||||
private readonly Dictionary<string, GuidUdi> _uploadedImages = new();
|
||||
|
||||
@@ -67,6 +72,7 @@ public sealed class RichTextEditorPastedImages
|
||||
{
|
||||
}
|
||||
|
||||
[Obsolete("Use the non-obsolete constructor. Scheduled for removal in v14")]
|
||||
public RichTextEditorPastedImages(
|
||||
IUmbracoContextAccessor umbracoContextAccessor,
|
||||
ILogger<RichTextEditorPastedImages> logger,
|
||||
@@ -79,6 +85,39 @@ public sealed class RichTextEditorPastedImages
|
||||
IPublishedUrlProvider publishedUrlProvider,
|
||||
IImageUrlGenerator imageUrlGenerator,
|
||||
IOptions<ContentSettings> contentSettings)
|
||||
: this(
|
||||
umbracoContextAccessor,
|
||||
logger,
|
||||
hostingEnvironment,
|
||||
mediaService,
|
||||
contentTypeBaseServiceProvider,
|
||||
mediaFileManager,
|
||||
mediaUrlGenerators,
|
||||
shortStringHelper,
|
||||
publishedUrlProvider,
|
||||
imageUrlGenerator,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IEntityService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IUserService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<AppCaches>(),
|
||||
contentSettings)
|
||||
{
|
||||
}
|
||||
|
||||
public RichTextEditorPastedImages(
|
||||
IUmbracoContextAccessor umbracoContextAccessor,
|
||||
ILogger<RichTextEditorPastedImages> logger,
|
||||
IHostingEnvironment hostingEnvironment,
|
||||
IMediaService mediaService,
|
||||
IContentTypeBaseServiceProvider contentTypeBaseServiceProvider,
|
||||
MediaFileManager mediaFileManager,
|
||||
MediaUrlGeneratorCollection mediaUrlGenerators,
|
||||
IShortStringHelper shortStringHelper,
|
||||
IPublishedUrlProvider publishedUrlProvider,
|
||||
IImageUrlGenerator imageUrlGenerator,
|
||||
IEntityService entityService,
|
||||
IUserService userService,
|
||||
AppCaches appCaches,
|
||||
IOptions<ContentSettings> contentSettings)
|
||||
{
|
||||
_umbracoContextAccessor =
|
||||
umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor));
|
||||
@@ -92,6 +131,9 @@ public sealed class RichTextEditorPastedImages
|
||||
_shortStringHelper = shortStringHelper;
|
||||
_publishedUrlProvider = publishedUrlProvider;
|
||||
_imageUrlGenerator = imageUrlGenerator;
|
||||
_entityService = entityService;
|
||||
_userService = userService;
|
||||
_appCaches = appCaches;
|
||||
_contentSettings = contentSettings.Value;
|
||||
|
||||
_tempFolderAbsolutePath = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempImageUploads);
|
||||
@@ -270,7 +312,7 @@ public sealed class RichTextEditorPastedImages
|
||||
: Constants.Conventions.MediaTypes.Image;
|
||||
|
||||
IMedia mediaFile = mediaParentFolder == Guid.Empty
|
||||
? _mediaService.CreateMedia(mediaItemName, Constants.System.Root, mediaType, userId)
|
||||
? _mediaService.CreateMedia(mediaItemName, GetDefaultMediaRoot(userId), mediaType, userId)
|
||||
: _mediaService.CreateMedia(mediaItemName, mediaParentFolder, mediaType, userId);
|
||||
|
||||
var fileInfo = new FileInfo(absoluteTempImagePath);
|
||||
@@ -354,4 +396,11 @@ public sealed class RichTextEditorPastedImages
|
||||
}
|
||||
|
||||
private bool IsValidPath(string imagePath) => imagePath.StartsWith(_tempFolderAbsolutePath);
|
||||
|
||||
private int GetDefaultMediaRoot(int userId)
|
||||
{
|
||||
IUser user = _userService.GetUserById(userId) ?? throw new ArgumentException("User could not be found");
|
||||
var userStartNodes = user.CalculateMediaStartNodeIds(_entityService, _appCaches);
|
||||
return userStartNodes?.FirstOrDefault() ?? Constants.System.Root;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <br> 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;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ internal class FileSystemMainDomLock : IMainDomLock
|
||||
private readonly string _lockFilePath;
|
||||
private readonly ILogger<FileSystemMainDomLock> _logger;
|
||||
private readonly string _releaseSignalFilePath;
|
||||
private bool _disposed;
|
||||
private Task? _listenForReleaseSignalFileTask;
|
||||
|
||||
private FileStream? _lockFileStream;
|
||||
@@ -88,16 +89,14 @@ internal class FileSystemMainDomLock : IMainDomLock
|
||||
ListeningLoop,
|
||||
_cancellationTokenSource.Token,
|
||||
TaskCreationOptions.LongRunning,
|
||||
TaskScheduler.Default);
|
||||
TaskScheduler.Default)
|
||||
.Unwrap(); // Because ListeningLoop is an async method, we need to use Unwrap to return the inner task.
|
||||
|
||||
return _listenForReleaseSignalFileTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_lockFileStream?.Close();
|
||||
_lockFileStream = null;
|
||||
}
|
||||
/// <summary>Releases the resources used by this <see cref="FileSystemMainDomLock" />.</summary>
|
||||
public void Dispose() => Dispose(true);
|
||||
|
||||
public void CreateLockReleaseSignalFile() =>
|
||||
File.Open(_releaseSignalFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite,
|
||||
@@ -107,7 +106,27 @@ internal class FileSystemMainDomLock : IMainDomLock
|
||||
public void DeleteLockReleaseSignalFile() =>
|
||||
File.Delete(_releaseSignalFilePath);
|
||||
|
||||
private void ListeningLoop()
|
||||
/// <summary>Releases the resources used by this <see cref="FileSystemMainDomLock" />.</summary>
|
||||
/// <param name="disposing">true to release both managed resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !_disposed)
|
||||
{
|
||||
_logger.LogInformation($"{nameof(FileSystemMainDomLock)} Disposing...");
|
||||
_cancellationTokenSource.Cancel();
|
||||
_cancellationTokenSource.Dispose();
|
||||
ReleaseLock();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleaseLock()
|
||||
{
|
||||
_lockFileStream?.Close();
|
||||
_lockFileStream = null;
|
||||
}
|
||||
|
||||
private async Task ListeningLoop()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
@@ -126,12 +145,12 @@ internal class FileSystemMainDomLock : IMainDomLock
|
||||
{
|
||||
_logger.LogDebug("Found lock release signal file, releasing lock on {lockFilePath}", _lockFilePath);
|
||||
}
|
||||
_lockFileStream?.Close();
|
||||
_lockFileStream = null;
|
||||
|
||||
ReleaseLock();
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(_globalSettings.CurrentValue.MainDomReleaseSignalPollingInterval);
|
||||
await Task.Delay(_globalSettings.CurrentValue.MainDomReleaseSignalPollingInterval, _cancellationTokenSource.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +281,20 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ThrowIfDisposed();
|
||||
|
||||
IUser? user = _userService.GetUserById(UserIdToInt(userId));
|
||||
// In the external login flow - see BackOfficeController.ExternalSignInAsync - we can have a situation where an
|
||||
// error has occured but the user is signed in. For that reason, at the end of the process, if errors are
|
||||
// recorded, the user is signed out.
|
||||
// Before signing out, we request the user in order to update the security stamp - see UmbracoSignInManager.SignOutAsync.
|
||||
// But we can have a situation where the signed in principal has the ID from the external provider, which may not be something
|
||||
// we can parse to an integer.
|
||||
// If that's the case, return null rather than throwing an exception. Without an Umbraco user, we can't update the security stamp,
|
||||
// so no need to fail here.
|
||||
if (!TryUserIdToInt(userId, out var userIdAsInt))
|
||||
{
|
||||
return Task.FromResult((BackOfficeIdentityUser?)null)!;
|
||||
}
|
||||
|
||||
IUser? user = _userService.GetUserById(userIdAsInt);
|
||||
if (user == null)
|
||||
{
|
||||
return Task.FromResult((BackOfficeIdentityUser?)null)!;
|
||||
|
||||
@@ -110,7 +110,7 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
UpdateMemberProperties(memberEntity, user, out bool _);
|
||||
|
||||
// create the member
|
||||
_memberService.Save(memberEntity);
|
||||
_memberService.Save(memberEntity, PublishNotificationSaveOptions.Saving);
|
||||
|
||||
// We need to add roles now that the member has an Id. It do not work implicit in UpdateMemberProperties
|
||||
_memberService.AssignRoles(
|
||||
@@ -161,7 +161,7 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<IdentityResult> UpdateAsync(
|
||||
public override async Task<IdentityResult> UpdateAsync(
|
||||
MemberIdentityUser user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -189,9 +189,21 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
var isLoginsPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.Logins));
|
||||
var isTokensPropertyDirty = user.IsPropertyDirty(nameof(MemberIdentityUser.LoginTokens));
|
||||
|
||||
if (UpdateMemberProperties(found, user, out var updateRoles))
|
||||
IReadOnlyList<string> propertiesUpdated = UpdateMemberProperties(found, user, out var updateRoles);
|
||||
|
||||
if (propertiesUpdated.Count > 0)
|
||||
{
|
||||
_memberService.Save(found);
|
||||
// As part of logging in members we update the last login date, and, if concurrent logins are disabled, the security stamp.
|
||||
// If and only if we are updating these properties, we can avoid the overhead of a full save of the member with the associated
|
||||
// locking, property updates, tag handling etc., and make a more efficient update.
|
||||
if (UpdatingOnlyLoginProperties(propertiesUpdated))
|
||||
{
|
||||
await _memberService.UpdateLoginPropertiesAsync(found);
|
||||
}
|
||||
else
|
||||
{
|
||||
_memberService.Save(found);
|
||||
}
|
||||
|
||||
if (updateRoles)
|
||||
{
|
||||
@@ -222,15 +234,21 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
}
|
||||
|
||||
scope.Complete();
|
||||
return Task.FromResult(IdentityResult.Success);
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(
|
||||
IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = ex.Message }));
|
||||
return IdentityResult.Failed(new IdentityError { Code = GenericIdentityErrorCode, Description = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static bool UpdatingOnlyLoginProperties(IReadOnlyList<string> propertiesUpdated)
|
||||
{
|
||||
string[] loginPropertyUpdates = [nameof(MemberIdentityUser.LastLoginDateUtc), nameof(MemberIdentityUser.SecurityStamp)];
|
||||
return (propertiesUpdated.Count == 2 && propertiesUpdated.ContainsAll(loginPropertyUpdates)) ||
|
||||
(propertiesUpdated.Count == 1 && propertiesUpdated.ContainsAny(loginPropertyUpdates));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<IdentityResult> DeleteAsync(
|
||||
MemberIdentityUser user,
|
||||
@@ -321,9 +339,20 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
throw new ArgumentNullException(nameof(userId));
|
||||
}
|
||||
|
||||
IMember? user = Guid.TryParse(userId, out Guid key)
|
||||
? _memberService.GetByKey(key)
|
||||
: _memberService.GetById(UserIdToInt(userId));
|
||||
// With external member providers we can get a ID here that's not a GUID or integer.
|
||||
// We can't retrieve the member, but if that's the case we shouldn't throw an exception,
|
||||
// just return null in the same way as when the member isn't found.
|
||||
// See: https://github.com/umbraco/Umbraco-CMS/issues/14713
|
||||
IMember? user = null;
|
||||
if (Guid.TryParse(userId, out Guid key))
|
||||
{
|
||||
user = _memberService.GetByKey(key);
|
||||
}
|
||||
else if (TryUserIdToInt(userId, out int id))
|
||||
{
|
||||
user = _memberService.GetById(id);
|
||||
}
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return Task.FromResult((MemberIdentityUser)null!)!;
|
||||
@@ -670,9 +699,9 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
return user;
|
||||
}
|
||||
|
||||
private bool UpdateMemberProperties(IMember member, MemberIdentityUser identityUser, out bool updateRoles)
|
||||
private IReadOnlyList<string> UpdateMemberProperties(IMember member, MemberIdentityUser identityUser, out bool updateRoles)
|
||||
{
|
||||
var anythingChanged = false;
|
||||
var updatedProperties = new List<string>();
|
||||
updateRoles = false;
|
||||
|
||||
// don't assign anything if nothing has changed as this will trigger the track changes of the model
|
||||
@@ -681,7 +710,7 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
|| (identityUser.LastLoginDateUtc.HasValue &&
|
||||
member.LastLoginDate?.ToUniversalTime() != identityUser.LastLoginDateUtc.Value))
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.LastLoginDateUtc));
|
||||
|
||||
// if the LastLoginDate is being set to MinValue, don't convert it ToLocalTime
|
||||
DateTime dt = identityUser.LastLoginDateUtc == DateTime.MinValue
|
||||
@@ -695,14 +724,14 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
|| (identityUser.LastPasswordChangeDateUtc.HasValue && member.LastPasswordChangeDate?.ToUniversalTime() !=
|
||||
identityUser.LastPasswordChangeDateUtc.Value))
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.LastPasswordChangeDateUtc));
|
||||
member.LastPasswordChangeDate = identityUser.LastPasswordChangeDateUtc?.ToLocalTime() ?? DateTime.Now;
|
||||
}
|
||||
|
||||
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Comments))
|
||||
&& member.Comments != identityUser.Comments && identityUser.Comments.IsNullOrWhiteSpace() == false)
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.Comments));
|
||||
member.Comments = identityUser.Comments;
|
||||
}
|
||||
|
||||
@@ -712,34 +741,34 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
|| ((member.EmailConfirmedDate.HasValue == false || member.EmailConfirmedDate.Value == default) &&
|
||||
identityUser.EmailConfirmed))
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.EmailConfirmed));
|
||||
member.EmailConfirmedDate = identityUser.EmailConfirmed ? DateTime.Now : null;
|
||||
}
|
||||
|
||||
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Name))
|
||||
&& member.Name != identityUser.Name && identityUser.Name.IsNullOrWhiteSpace() == false)
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.Name));
|
||||
member.Name = identityUser.Name ?? string.Empty;
|
||||
}
|
||||
|
||||
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Email))
|
||||
&& member.Email != identityUser.Email && identityUser.Email.IsNullOrWhiteSpace() == false)
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.Email));
|
||||
member.Email = identityUser.Email!;
|
||||
}
|
||||
|
||||
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.AccessFailedCount))
|
||||
&& member.FailedPasswordAttempts != identityUser.AccessFailedCount)
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.AccessFailedCount));
|
||||
member.FailedPasswordAttempts = identityUser.AccessFailedCount;
|
||||
}
|
||||
|
||||
if (member.IsLockedOut != identityUser.IsLockedOut)
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.IsLockedOut));
|
||||
member.IsLockedOut = identityUser.IsLockedOut;
|
||||
|
||||
if (member.IsLockedOut)
|
||||
@@ -751,14 +780,14 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
|
||||
if (member.IsApproved != identityUser.IsApproved)
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.IsApproved));
|
||||
member.IsApproved = identityUser.IsApproved;
|
||||
}
|
||||
|
||||
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.UserName))
|
||||
&& member.Username != identityUser.UserName && identityUser.UserName.IsNullOrWhiteSpace() == false)
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.UserName));
|
||||
member.Username = identityUser.UserName!;
|
||||
}
|
||||
|
||||
@@ -766,33 +795,33 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
|
||||
&& member.RawPasswordValue != identityUser.PasswordHash &&
|
||||
identityUser.PasswordHash.IsNullOrWhiteSpace() == false)
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.PasswordHash));
|
||||
member.RawPasswordValue = identityUser.PasswordHash;
|
||||
member.PasswordConfiguration = identityUser.PasswordConfig;
|
||||
}
|
||||
|
||||
if (member.PasswordConfiguration != identityUser.PasswordConfig)
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.PasswordConfig));
|
||||
member.PasswordConfiguration = identityUser.PasswordConfig;
|
||||
}
|
||||
|
||||
if (member.SecurityStamp != identityUser.SecurityStamp)
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.SecurityStamp));
|
||||
member.SecurityStamp = identityUser.SecurityStamp;
|
||||
}
|
||||
|
||||
if (identityUser.IsPropertyDirty(nameof(MemberIdentityUser.Roles)))
|
||||
{
|
||||
anythingChanged = true;
|
||||
updatedProperties.Add(nameof(MemberIdentityUser.Roles));
|
||||
updateRoles = true;
|
||||
}
|
||||
|
||||
// reset all changes
|
||||
identityUser.ResetDirtyProperties(false);
|
||||
|
||||
return anythingChanged;
|
||||
return updatedProperties.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -33,18 +33,29 @@ public abstract class UmbracoUserStore<TUser, TRole>
|
||||
|
||||
protected static int UserIdToInt(string? userId)
|
||||
{
|
||||
if (int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
|
||||
if (TryUserIdToInt(userId, out int result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture");
|
||||
}
|
||||
|
||||
protected static bool TryUserIdToInt(string? userId, out int result)
|
||||
{
|
||||
if (int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out result))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Guid.TryParse(userId, out Guid key))
|
||||
{
|
||||
// Reverse the IntExtensions.ToGuid
|
||||
return BitConverter.ToInt32(key.ToByteArray(), 0);
|
||||
result = BitConverter.ToInt32(key.ToByteArray(), 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture");
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static string UserIdToString(int userId) => string.Intern(userId.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ public class
|
||||
{
|
||||
private readonly ContentPermissions _contentPermissions;
|
||||
|
||||
protected override UmbracoObjectTypes KeyParsingFilterType => UmbracoObjectTypes.Document;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ContentPermissionsQueryStringHandler" /> class.
|
||||
/// </summary>
|
||||
@@ -48,7 +50,11 @@ public class
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
var argument = routeVal.ToString();
|
||||
// Handle case where the incoming querystring could contain more than one value (e.g. ?id=1000&id=1001).
|
||||
// It's the first one that'll be processed by the protected method so we should verify that.
|
||||
var argument = routeVal.Count == 1
|
||||
? routeVal.ToString()
|
||||
: routeVal.FirstOrDefault()?.ToString() ?? string.Empty;
|
||||
|
||||
if (!TryParseNodeId(argument, out nodeId))
|
||||
{
|
||||
|
||||
@@ -18,6 +18,8 @@ public class MediaPermissionsQueryStringHandler : PermissionsQueryStringHandler<
|
||||
{
|
||||
private readonly MediaPermissions _mediaPermissions;
|
||||
|
||||
protected override UmbracoObjectTypes KeyParsingFilterType => UmbracoObjectTypes.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MediaPermissionsQueryStringHandler" /> class.
|
||||
/// </summary>
|
||||
@@ -44,7 +46,11 @@ public class MediaPermissionsQueryStringHandler : PermissionsQueryStringHandler<
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
var argument = routeVal.ToString();
|
||||
// Handle case where the incoming querystring could contain more than one value (e.g. ?id=1000&id=1001).
|
||||
// It's the first one that'll be processed by the protected method so we should verify that.
|
||||
var argument = routeVal.Count == 1
|
||||
? routeVal.ToString()
|
||||
: routeVal.FirstOrDefault()?.ToString() ?? string.Empty;
|
||||
|
||||
if (!TryParseNodeId(argument, out var nodeId))
|
||||
{
|
||||
|
||||
@@ -49,12 +49,18 @@ public abstract class PermissionsQueryStringHandler<T> : MustSatisfyRequirementA
|
||||
/// </summary>
|
||||
protected IEntityService EntityService { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Defaults to Unknown so all types are allowed, since Keys are unique across all node types this works,
|
||||
/// but it if you are certain you are looking for a specific type this should be overwritten for DB query performance.
|
||||
/// </summary>
|
||||
protected virtual UmbracoObjectTypes KeyParsingFilterType => UmbracoObjectTypes.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a node ID from a string representation found in a querystring value.
|
||||
/// </summary>
|
||||
/// <param name="argument">Querystring value.</param>
|
||||
/// <param name="nodeId">Output parsed Id.</param>
|
||||
/// <returns>True of node ID could be parased, false it not.</returns>
|
||||
/// <returns>True of node ID could be parsed, false it not.</returns>
|
||||
protected bool TryParseNodeId(string argument, out int nodeId)
|
||||
{
|
||||
// If the argument is an int, it will parse and can be assigned to nodeId.
|
||||
@@ -75,7 +81,7 @@ public abstract class PermissionsQueryStringHandler<T> : MustSatisfyRequirementA
|
||||
|
||||
if (Guid.TryParse(argument, out Guid key))
|
||||
{
|
||||
nodeId = EntityService.GetId(key, UmbracoObjectTypes.Document).Result;
|
||||
nodeId = EntityService.GetId(key, KeyParsingFilterType).Result;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -256,6 +256,7 @@ public class ContentController : ContentControllerBase
|
||||
/// Permission check is done for letter 'R' which is for <see cref="ActionRights" /> which the user must have access to
|
||||
/// update
|
||||
/// </remarks>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<IEnumerable<AssignedUserGroupPermissions?>?>> PostSaveUserGroupPermissions(
|
||||
UserGroupPermissionsSave saveModel)
|
||||
{
|
||||
@@ -902,6 +903,7 @@ public class ContentController : ContentControllerBase
|
||||
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)]
|
||||
[FileUploadCleanupFilter]
|
||||
[ContentSaveValidation(skipUserAccessValidation:true)] // skip user access validation because we "only" require Settings access to create new blueprints from scratch
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ContentItemDisplay<ContentVariantDisplay>?>?> PostSaveBlueprint(
|
||||
[ModelBinder(typeof(BlueprintItemBinder))] ContentItemSave contentItem)
|
||||
{
|
||||
@@ -939,6 +941,7 @@ public class ContentController : ContentControllerBase
|
||||
[FileUploadCleanupFilter]
|
||||
[ContentSaveValidation]
|
||||
[OutgoingEditorModelEvent]
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ContentItemDisplay<ContentVariantScheduleDisplay>?>> PostSave(
|
||||
[ModelBinder(typeof(ContentItemBinder))] ContentItemSave contentItem)
|
||||
{
|
||||
@@ -999,6 +1002,7 @@ public class ContentController : ContentControllerBase
|
||||
// if there's only one variant and the model state is not valid we cannot publish so change it to save
|
||||
if (variantCount == 1)
|
||||
{
|
||||
|
||||
switch (contentItem.Action)
|
||||
{
|
||||
case ContentSaveAction.Publish:
|
||||
@@ -1144,25 +1148,6 @@ public class ContentController : ContentControllerBase
|
||||
break;
|
||||
case ContentSaveAction.PublishWithDescendants:
|
||||
case ContentSaveAction.PublishWithDescendantsNew:
|
||||
{
|
||||
if (!await ValidatePublishBranchPermissionsAsync(contentItem))
|
||||
{
|
||||
globalNotifications.AddErrorNotification(
|
||||
_localizedTextService.Localize(null, "publish"),
|
||||
_localizedTextService.Localize("publish", "invalidPublishBranchPermissions"));
|
||||
wasCancelled = false;
|
||||
break;
|
||||
}
|
||||
|
||||
var publishStatus = PublishBranchInternal(contentItem, false, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList();
|
||||
var addedDomainWarnings = AddDomainWarnings(publishStatus, successfulCultures, globalNotifications, defaultCulture);
|
||||
AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures);
|
||||
if (addedDomainWarnings is false)
|
||||
{
|
||||
AddPublishRoutableErrorNotifications(publishStatus, globalNotifications, successfulCultures);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ContentSaveAction.PublishWithDescendantsForce:
|
||||
case ContentSaveAction.PublishWithDescendantsForceNew:
|
||||
{
|
||||
@@ -1175,8 +1160,13 @@ public class ContentController : ContentControllerBase
|
||||
break;
|
||||
}
|
||||
|
||||
var publishStatus = PublishBranchInternal(contentItem, true, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList();
|
||||
var publishStatus = PublishBranchInternal(contentItem, BuildPublishBranchFilter(contentItem.Action), cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList();
|
||||
var addedDomainWarnings = AddDomainWarnings(publishStatus, successfulCultures, globalNotifications, defaultCulture);
|
||||
AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures);
|
||||
if (addedDomainWarnings is false)
|
||||
{
|
||||
AddPublishRoutableErrorNotifications(publishStatus, globalNotifications, successfulCultures);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -1228,6 +1218,20 @@ public class ContentController : ContentControllerBase
|
||||
return display;
|
||||
}
|
||||
|
||||
private static PublishBranchFilter BuildPublishBranchFilter(ContentSaveAction contentSaveAction)
|
||||
{
|
||||
var includeUnpublished = contentSaveAction == ContentSaveAction.PublishWithDescendantsForce
|
||||
|| contentSaveAction == ContentSaveAction.PublishWithDescendantsForceNew;
|
||||
|
||||
PublishBranchFilter publishBranchFilter = PublishBranchFilter.Default;
|
||||
if (includeUnpublished)
|
||||
{
|
||||
publishBranchFilter |= PublishBranchFilter.IncludeUnpublished;
|
||||
}
|
||||
|
||||
return publishBranchFilter;
|
||||
}
|
||||
|
||||
private void AddPublishStatusNotifications(
|
||||
IReadOnlyCollection<PublishResult> publishStatus,
|
||||
SimpleNotificationModel globalNotifications,
|
||||
@@ -1248,6 +1252,11 @@ public class ContentController : ContentControllerBase
|
||||
SimpleNotificationModel globalNotifications,
|
||||
string[]? successfulCultures)
|
||||
{
|
||||
if (_contentSettings.ShowUnroutableContentWarnings is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IContent? content = publishStatus.FirstOrDefault()?.Content;
|
||||
if (content is null)
|
||||
{
|
||||
@@ -1668,12 +1677,12 @@ public class ContentController : ContentControllerBase
|
||||
return authorizationResult.Succeeded;
|
||||
}
|
||||
|
||||
private IEnumerable<PublishResult> PublishBranchInternal(ContentItemSave contentItem, bool force, string? cultureForInvariantErrors, out bool wasCancelled, out string[]? successfulCultures)
|
||||
private IEnumerable<PublishResult> PublishBranchInternal(ContentItemSave contentItem, PublishBranchFilter publishBranchFilter, string? cultureForInvariantErrors, out bool wasCancelled, out string[]? successfulCultures)
|
||||
{
|
||||
if (!contentItem.PersistedContent?.ContentType.VariesByCulture() ?? false)
|
||||
{
|
||||
//its invariant, proceed normally
|
||||
IEnumerable<PublishResult> publishStatus = _contentService.SaveAndPublishBranch(contentItem.PersistedContent!, force, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1);
|
||||
IEnumerable<PublishResult> publishStatus = _contentService.SaveAndPublishBranch(contentItem.PersistedContent!, publishBranchFilter, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1);
|
||||
// TODO: Deal with multiple cancellations
|
||||
wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent);
|
||||
successfulCultures = null; //must be null! this implies invariant
|
||||
@@ -1709,7 +1718,7 @@ public class ContentController : ContentControllerBase
|
||||
{
|
||||
//proceed to publish if all validation still succeeds
|
||||
IEnumerable<PublishResult> publishStatus = _contentService.SaveAndPublishBranch(
|
||||
contentItem.PersistedContent!, force, culturesToPublish.WhereNotNull().ToArray(), _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1);
|
||||
contentItem.PersistedContent!, publishBranchFilter, culturesToPublish.WhereNotNull().ToArray(), _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1);
|
||||
// TODO: Deal with multiple cancellations
|
||||
wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent);
|
||||
successfulCultures = contentItem.Variants.Where(x => x.Publish).Select(x => x.Culture).WhereNotNull()
|
||||
@@ -2089,6 +2098,7 @@ public class ContentController : ContentControllerBase
|
||||
/// does not have Publish access to this node.
|
||||
/// </remarks>
|
||||
[Authorize(Policy = AuthorizationPolicies.ContentPermissionPublishById)]
|
||||
[HttpPost]
|
||||
public IActionResult PostPublishById(int id)
|
||||
{
|
||||
IContent? foundContent = GetObjectFromRequest(() => _contentService.GetById(id));
|
||||
@@ -2120,6 +2130,7 @@ public class ContentController : ContentControllerBase
|
||||
/// does not have Publish access to this node.
|
||||
/// </remarks>
|
||||
[Authorize(Policy = AuthorizationPolicies.ContentPermissionPublishById)]
|
||||
[HttpPost]
|
||||
public IActionResult PostPublishByIdAndCulture(PublishContent model)
|
||||
{
|
||||
var languageCount = _allLangs.Value.Count();
|
||||
@@ -2243,6 +2254,7 @@ public class ContentController : ContentControllerBase
|
||||
/// </summary>
|
||||
/// <param name="sorted"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> PostSort(ContentSortOrder sorted)
|
||||
{
|
||||
if (sorted == null)
|
||||
@@ -2294,6 +2306,7 @@ public class ContentController : ContentControllerBase
|
||||
/// </summary>
|
||||
/// <param name="move"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult?> PostMove(MoveOrCopy move)
|
||||
{
|
||||
// Authorize...
|
||||
@@ -2333,6 +2346,7 @@ public class ContentController : ContentControllerBase
|
||||
/// </summary>
|
||||
/// <param name="copy"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<IContent>?> PostCopy(MoveOrCopy copy)
|
||||
{
|
||||
// Authorize...
|
||||
@@ -2372,6 +2386,7 @@ public class ContentController : ContentControllerBase
|
||||
/// <param name="model">The content and variants to unpublish</param>
|
||||
/// <returns></returns>
|
||||
[OutgoingEditorModelEvent]
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ContentItemDisplayWithSchedule?>> PostUnpublish(UnpublishContent model)
|
||||
{
|
||||
IContent? foundContent = _contentService.GetById(model.Id);
|
||||
@@ -3096,6 +3111,7 @@ public class ContentController : ContentControllerBase
|
||||
return notifications;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public IActionResult PostNotificationOptions(
|
||||
int contentId,
|
||||
[FromQuery(Name = "notifyOptions[]")] string[] notifyOptions)
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NPoco;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
@@ -37,6 +34,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
|
||||
[PluginController(Constants.Web.Mvc.BackOfficeApiArea)]
|
||||
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes)]
|
||||
[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))]
|
||||
[ParameterSwapControllerActionSelector(nameof(GetReferences), "id", typeof(int), typeof(Guid))]
|
||||
public class DataTypeController : BackOfficeNotificationsController
|
||||
{
|
||||
private readonly PropertyEditorCollection _propertyEditors;
|
||||
@@ -51,6 +49,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
private readonly IConfigurationEditorJsonSerializer _serializer;
|
||||
private readonly IDataTypeUsageService _dataTypeUsageService;
|
||||
private readonly IIdKeyMap _idKeyMap;
|
||||
|
||||
[Obsolete("Use constructor that takes IDataTypeUsageService, scheduled for removal in V12")]
|
||||
public DataTypeController(
|
||||
@@ -77,10 +76,42 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
|
||||
localizedTextService,
|
||||
backOfficeSecurityAccessor,
|
||||
serializer,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IDataTypeUsageService>())
|
||||
StaticServiceProvider.Instance.GetRequiredService<IDataTypeUsageService>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IIdKeyMap>())
|
||||
{
|
||||
}
|
||||
|
||||
[Obsolete("Use constructor that takes IDataTypeUsageService, scheduled for removal in V17")]
|
||||
public DataTypeController(
|
||||
PropertyEditorCollection propertyEditors,
|
||||
IDataTypeService dataTypeService,
|
||||
IOptionsSnapshot<ContentSettings> contentSettings,
|
||||
IUmbracoMapper umbracoMapper,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IContentTypeService contentTypeService,
|
||||
IMediaTypeService mediaTypeService,
|
||||
IMemberTypeService memberTypeService,
|
||||
ILocalizedTextService localizedTextService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IConfigurationEditorJsonSerializer serializer,
|
||||
IDataTypeUsageService dataTypeUsageService)
|
||||
: this(
|
||||
propertyEditors,
|
||||
dataTypeService,
|
||||
contentSettings,
|
||||
umbracoMapper,
|
||||
propertyEditorCollection,
|
||||
contentTypeService,
|
||||
mediaTypeService,
|
||||
memberTypeService,
|
||||
localizedTextService,
|
||||
backOfficeSecurityAccessor,
|
||||
serializer,
|
||||
dataTypeUsageService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IIdKeyMap>())
|
||||
{
|
||||
}
|
||||
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public DataTypeController(
|
||||
PropertyEditorCollection propertyEditors,
|
||||
@@ -94,7 +125,8 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
|
||||
ILocalizedTextService localizedTextService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IConfigurationEditorJsonSerializer serializer,
|
||||
IDataTypeUsageService dataTypeUsageService)
|
||||
IDataTypeUsageService dataTypeUsageService,
|
||||
IIdKeyMap entityService)
|
||||
{
|
||||
_propertyEditors = propertyEditors ?? throw new ArgumentNullException(nameof(propertyEditors));
|
||||
_dataTypeService = dataTypeService ?? throw new ArgumentNullException(nameof(dataTypeService));
|
||||
@@ -108,6 +140,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor ?? throw new ArgumentNullException(nameof(backOfficeSecurityAccessor));
|
||||
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
|
||||
_dataTypeUsageService = dataTypeUsageService ?? throw new ArgumentNullException(nameof(dataTypeUsageService));
|
||||
_idKeyMap = entityService ?? throw new ArgumentNullException(nameof(entityService));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -421,10 +454,10 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the references (usages) for the data type
|
||||
/// Returns the references (usages) for the data type.
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="id">Data type's integer Id.</param>
|
||||
[HttpGet]
|
||||
public DataTypeReferences GetReferences(int id)
|
||||
{
|
||||
var result = new DataTypeReferences();
|
||||
@@ -462,6 +495,19 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the references (usages) for the data type.
|
||||
/// </summary>
|
||||
/// <param name="id">Data type's key.</param>
|
||||
[HttpGet]
|
||||
public DataTypeReferences GetReferences(Guid id)
|
||||
{
|
||||
Attempt<int> dataType = _idKeyMap.GetIdForKey(id, UmbracoObjectTypes.DataType);
|
||||
return dataType.Success
|
||||
? GetReferences(dataType.Result)
|
||||
: new DataTypeReferences();
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public ActionResult<DataTypeHasValuesDisplay> HasValues(int id)
|
||||
{
|
||||
|
||||
@@ -895,6 +895,7 @@ public class EntityController : UmbracoAuthorizedJsonController
|
||||
// Filtering out child nodes after getting a paged result is an active choice here, even though the pagination might get off.
|
||||
// This has been the case with this functionality in Umbraco for a long time.
|
||||
.Where(entity => ignoreUserStartNodes ||
|
||||
(objectType == UmbracoObjectTypes.Document || objectType == UmbracoObjectTypes.Media) is false ||
|
||||
(ContentPermissions.IsInBranchOfStartNode(entity.Path, startNodeIds, startNodePaths, out var hasPathAccess) &&
|
||||
hasPathAccess))
|
||||
.Select(source =>
|
||||
|
||||
@@ -87,7 +87,7 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetMacroResultAsHtmlForEditor(string macroAlias, int pageId,
|
||||
[FromQuery] IDictionary<string, object> macroParams) =>
|
||||
await GetMacroResultAsHtml(macroAlias, pageId, macroParams);
|
||||
await GetMacroResultAsHtml(macroAlias, pageId.ToString(), macroParams);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a rendered macro as HTML for rendering in the rich text editor.
|
||||
@@ -98,11 +98,24 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
|
||||
/// <param name="model"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
[NonAction]
|
||||
[Obsolete("This endpoint is no longer used.")]
|
||||
public async Task<IActionResult> GetMacroResultAsHtmlForEditor(MacroParameterModel model) =>
|
||||
await GetMacroResultAsHtml(model.MacroAlias, model.PageId.ToString(), model.MacroParams);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a rendered macro as HTML for rendering in the rich text editor.
|
||||
/// Using HTTP POST instead of GET allows for more parameters to be passed as it's not dependent on URL-length
|
||||
/// limitations like GET.
|
||||
/// The method using GET is kept to maintain backwards compatibility
|
||||
/// </summary>
|
||||
/// <param name="model"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> GetMacroResultAsHtmlForEditor(MacroParameterModel2 model) =>
|
||||
await GetMacroResultAsHtml(model.MacroAlias, model.PageId, model.MacroParams);
|
||||
|
||||
private async Task<IActionResult> GetMacroResultAsHtml(string? macroAlias, int pageId,
|
||||
IDictionary<string, object>? macroParams)
|
||||
private async Task<IActionResult> GetMacroResultAsHtml(string? macroAlias, string pageId, IDictionary<string, object>? macroParams)
|
||||
{
|
||||
IMacro? m = macroAlias is null ? null : _macroService.GetByAlias(macroAlias);
|
||||
if (m == null)
|
||||
@@ -111,11 +124,11 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
|
||||
}
|
||||
|
||||
IUmbracoContext umbracoContext = _umbracoContextAccessor.GetRequiredUmbracoContext();
|
||||
IPublishedContent? publishedContent = umbracoContext.Content?.GetById(true, pageId);
|
||||
IPublishedContent? publishedContent = GetPagePublishedContent(pageId, umbracoContext);
|
||||
|
||||
//if it isn't supposed to be rendered in the editor then return an empty string
|
||||
//currently we cannot render a macro if the page doesn't yet exist
|
||||
if (pageId == -1 || publishedContent == null || m.DontRender)
|
||||
if (publishedContent == null || m.DontRender)
|
||||
{
|
||||
//need to create a specific content result formatted as HTML since this controller has been configured
|
||||
//with only json formatters.
|
||||
@@ -149,6 +162,21 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
|
||||
}
|
||||
}
|
||||
|
||||
private static IPublishedContent? GetPagePublishedContent(string pageId, IUmbracoContext umbracoContext)
|
||||
{
|
||||
if (int.TryParse(pageId, NumberStyles.Integer, CultureInfo.InvariantCulture, out int pageIdAsInt))
|
||||
{
|
||||
return umbracoContext.Content?.GetById(true, pageIdAsInt);
|
||||
}
|
||||
|
||||
if (Guid.TryParse(pageId, out Guid pageIdAsGuid))
|
||||
{
|
||||
return umbracoContext.Content?.GetById(true, pageIdAsGuid);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public IActionResult CreatePartialViewMacroWithFile(CreatePartialViewMacroWithFileModel model)
|
||||
{
|
||||
@@ -180,6 +208,7 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[Obsolete("This model is no longer used and has been replaced with MacroParameterModel2 that changes the type of the PageId property.")]
|
||||
public class MacroParameterModel
|
||||
{
|
||||
public string? MacroAlias { get; set; }
|
||||
@@ -187,6 +216,13 @@ public class MacroRenderingController : UmbracoAuthorizedJsonController
|
||||
public IDictionary<string, object>? MacroParams { get; set; }
|
||||
}
|
||||
|
||||
public class MacroParameterModel2
|
||||
{
|
||||
public string? MacroAlias { get; set; }
|
||||
public string PageId { get; set; } = string.Empty;
|
||||
public IDictionary<string, object>? MacroParams { get; set; }
|
||||
}
|
||||
|
||||
public class CreatePartialViewMacroWithFileModel
|
||||
{
|
||||
public string? Filename { get; set; }
|
||||
|
||||
@@ -386,6 +386,7 @@ public class MediaController : ContentControllerBase
|
||||
/// </summary>
|
||||
/// <param name="move"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> PostMove(MoveOrCopy move)
|
||||
{
|
||||
// Authorize...
|
||||
@@ -436,6 +437,7 @@ public class MediaController : ContentControllerBase
|
||||
[FileUploadCleanupFilter]
|
||||
[MediaItemSaveValidation]
|
||||
[OutgoingEditorModelEvent]
|
||||
[HttpPost]
|
||||
public ActionResult<MediaItemDisplay?>? PostSave(
|
||||
[ModelBinder(typeof(MediaItemBinder))] MediaItemSave contentItem)
|
||||
{
|
||||
@@ -551,6 +553,7 @@ public class MediaController : ContentControllerBase
|
||||
/// </summary>
|
||||
/// <param name="sorted"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> PostSort(ContentSortOrder sorted)
|
||||
{
|
||||
if (sorted == null)
|
||||
@@ -595,6 +598,7 @@ public class MediaController : ContentControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<MediaItemDisplay?>> PostAddFolder(PostedFolder folder)
|
||||
{
|
||||
ActionResult<int?>? parentIdResult = await GetParentIdAsIntAsync(folder.ParentId, true);
|
||||
@@ -628,6 +632,7 @@ public class MediaController : ContentControllerBase
|
||||
/// <remarks>
|
||||
/// We cannot validate this request with attributes (nicely) due to the nature of the multi-part for data.
|
||||
/// </remarks>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> PostAddFile([FromForm] string path, [FromForm] string currentFolder,
|
||||
[FromForm] string contentTypeAlias, List<IFormFile> file)
|
||||
{
|
||||
@@ -789,8 +794,7 @@ public class MediaController : ContentControllerBase
|
||||
continue;
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
await formFile.CopyToAsync(stream);
|
||||
await using var stream = formFile.OpenReadStream();
|
||||
if (_fileStreamSecurityValidator != null && _fileStreamSecurityValidator.IsConsideredSafe(stream) == false)
|
||||
{
|
||||
tempFiles.Notifications.Add(new BackOfficeNotification(
|
||||
|
||||
@@ -8,8 +8,11 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.ContentApps;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Dictionary;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
@@ -26,7 +29,6 @@ using Umbraco.Cms.Web.BackOffice.Filters;
|
||||
using Umbraco.Cms.Web.BackOffice.ModelBinders;
|
||||
using Umbraco.Cms.Web.Common.Attributes;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Cms.Web.Common.DependencyInjection;
|
||||
using Umbraco.Cms.Web.Common.Filters;
|
||||
using Umbraco.Cms.Web.Common.Security;
|
||||
using Umbraco.Extensions;
|
||||
@@ -55,6 +57,7 @@ public class MemberController : ContentControllerBase
|
||||
private readonly ITwoFactorLoginService _twoFactorLoginService;
|
||||
private readonly IShortStringHelper _shortStringHelper;
|
||||
private readonly IUmbracoMapper _umbracoMapper;
|
||||
private readonly SecuritySettings _securitySettings;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MemberController" /> class.
|
||||
@@ -75,6 +78,7 @@ public class MemberController : ContentControllerBase
|
||||
/// <param name="passwordChanger">The password changer</param>
|
||||
/// <param name="scopeProvider">The core scope provider</param>
|
||||
/// <param name="twoFactorLoginService">The two factor login service</param>
|
||||
/// <param name="securitySettings">The security settings</param>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public MemberController(
|
||||
ICultureDictionary cultureDictionary,
|
||||
@@ -92,7 +96,8 @@ public class MemberController : ContentControllerBase
|
||||
IJsonSerializer jsonSerializer,
|
||||
IPasswordChanger<MemberIdentityUser> passwordChanger,
|
||||
ICoreScopeProvider scopeProvider,
|
||||
ITwoFactorLoginService twoFactorLoginService)
|
||||
ITwoFactorLoginService twoFactorLoginService,
|
||||
IOptions<SecuritySettings> securitySettings)
|
||||
: base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, jsonSerializer)
|
||||
{
|
||||
_propertyEditors = propertyEditors;
|
||||
@@ -108,9 +113,49 @@ public class MemberController : ContentControllerBase
|
||||
_passwordChanger = passwordChanger;
|
||||
_scopeProvider = scopeProvider;
|
||||
_twoFactorLoginService = twoFactorLoginService;
|
||||
_securitySettings = securitySettings.Value;
|
||||
}
|
||||
|
||||
[Obsolete("Use constructor that also takes an ITwoFactorLoginService. Scheduled for removal in V13")]
|
||||
[Obsolete("Please use the constructor that takes all paramters. Scheduled for removal in V14")]
|
||||
public MemberController(
|
||||
ICultureDictionary cultureDictionary,
|
||||
ILoggerFactory loggerFactory,
|
||||
IShortStringHelper shortStringHelper,
|
||||
IEventMessagesFactory eventMessages,
|
||||
ILocalizedTextService localizedTextService,
|
||||
PropertyEditorCollection propertyEditors,
|
||||
IUmbracoMapper umbracoMapper,
|
||||
IMemberService memberService,
|
||||
IMemberTypeService memberTypeService,
|
||||
IMemberManager memberManager,
|
||||
IDataTypeService dataTypeService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IPasswordChanger<MemberIdentityUser> passwordChanger,
|
||||
ICoreScopeProvider scopeProvider,
|
||||
ITwoFactorLoginService twoFactorLoginService)
|
||||
: this(
|
||||
cultureDictionary,
|
||||
loggerFactory,
|
||||
shortStringHelper,
|
||||
eventMessages,
|
||||
localizedTextService,
|
||||
propertyEditors,
|
||||
umbracoMapper,
|
||||
memberService,
|
||||
memberTypeService,
|
||||
memberManager,
|
||||
dataTypeService,
|
||||
backOfficeSecurityAccessor,
|
||||
jsonSerializer,
|
||||
passwordChanger,
|
||||
scopeProvider,
|
||||
twoFactorLoginService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IOptions<SecuritySettings>>())
|
||||
{
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor that takes all paramters. Scheduled for removal in V14")]
|
||||
public MemberController(
|
||||
ICultureDictionary cultureDictionary,
|
||||
ILoggerFactory loggerFactory,
|
||||
@@ -461,7 +506,7 @@ public class MemberController : ContentControllerBase
|
||||
}
|
||||
|
||||
// now re-look up the member, which will now exist
|
||||
IMember? member = _memberService.GetByEmail(contentItem.Email);
|
||||
IMember? member = _memberService.GetByUsername(contentItem.Username);
|
||||
|
||||
if (member is null)
|
||||
{
|
||||
@@ -678,6 +723,17 @@ public class MemberController : ContentControllerBase
|
||||
return false;
|
||||
}
|
||||
|
||||
// User names can only contain the configured allowed characters. This is validated by ASP.NET Identity on create
|
||||
// as the setting is applied to the IdentityOptions, but we need to check ourselves for updates.
|
||||
var allowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters;
|
||||
if (contentItem.Username.Any(c => allowedUserNameCharacters.Contains(c) == false))
|
||||
{
|
||||
ModelState.AddPropertyError(
|
||||
new ValidationResult("Username contains invalid characters"),
|
||||
$"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (contentItem.Password != null && !contentItem.Password.NewPassword.IsNullOrWhiteSpace())
|
||||
{
|
||||
IdentityResult validPassword = await _memberManager.ValidatePasswordAsync(contentItem.Password.NewPassword);
|
||||
@@ -699,13 +755,16 @@ public class MemberController : ContentControllerBase
|
||||
return false;
|
||||
}
|
||||
|
||||
IMember? byEmail = _memberService.GetByEmail(contentItem.Email);
|
||||
if (byEmail != null && byEmail.Key != contentItem.Key)
|
||||
if (_securitySettings.MemberRequireUniqueEmail)
|
||||
{
|
||||
ModelState.AddPropertyError(
|
||||
new ValidationResult("Email address is already in use", new[] { "value" }),
|
||||
$"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email");
|
||||
return false;
|
||||
IMember? byEmail = _memberService.GetByEmail(contentItem.Email);
|
||||
if (byEmail != null && byEmail.Key != contentItem.Key)
|
||||
{
|
||||
ModelState.AddPropertyError(
|
||||
new ValidationResult("Email address is already in use", new[] { "value" }),
|
||||
$"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Editors;
|
||||
using Umbraco.Cms.Core.Features;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
@@ -28,7 +31,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers;
|
||||
|
||||
[DisableBrowserCache]
|
||||
[Area(Constants.Web.Mvc.BackOfficeArea)]
|
||||
public class PreviewController : Controller
|
||||
public partial class PreviewController : Controller
|
||||
{
|
||||
private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor;
|
||||
private readonly ICookieManager _cookieManager;
|
||||
@@ -40,7 +43,9 @@ public class PreviewController : Controller
|
||||
private readonly IRuntimeMinifier _runtimeMinifier;
|
||||
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
|
||||
private readonly ICompositeViewEngine _viewEngines;
|
||||
private readonly WebRoutingSettings _webRoutingSettings;
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor.")]
|
||||
public PreviewController(
|
||||
UmbracoFeatures features,
|
||||
IOptionsSnapshot<GlobalSettings> globalSettings,
|
||||
@@ -52,9 +57,38 @@ public class PreviewController : Controller
|
||||
IRuntimeMinifier runtimeMinifier,
|
||||
ICompositeViewEngine viewEngines,
|
||||
IUmbracoContextAccessor umbracoContextAccessor)
|
||||
: this(
|
||||
features,
|
||||
globalSettings,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IOptionsSnapshot<WebRoutingSettings>>(),
|
||||
publishedSnapshotService,
|
||||
backofficeSecurityAccessor,
|
||||
localizationService,
|
||||
hostingEnvironment,
|
||||
cookieManager,
|
||||
runtimeMinifier,
|
||||
viewEngines,
|
||||
umbracoContextAccessor)
|
||||
{
|
||||
}
|
||||
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public PreviewController(
|
||||
UmbracoFeatures features,
|
||||
IOptionsSnapshot<GlobalSettings> globalSettings,
|
||||
IOptionsSnapshot<WebRoutingSettings> webRoutingSettings,
|
||||
IPublishedSnapshotService publishedSnapshotService,
|
||||
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
|
||||
ILocalizationService localizationService,
|
||||
IHostingEnvironment hostingEnvironment,
|
||||
ICookieManager cookieManager,
|
||||
IRuntimeMinifier runtimeMinifier,
|
||||
ICompositeViewEngine viewEngines,
|
||||
IUmbracoContextAccessor umbracoContextAccessor)
|
||||
{
|
||||
_features = features;
|
||||
_globalSettings = globalSettings.Value;
|
||||
_webRoutingSettings = webRoutingSettings.Value;
|
||||
_publishedSnapshotService = publishedSnapshotService;
|
||||
_backofficeSecurityAccessor = backofficeSecurityAccessor;
|
||||
_localizationService = localizationService;
|
||||
@@ -181,6 +215,41 @@ public class PreviewController : Controller
|
||||
// Expire Client-side cookie that determines whether the user has accepted to be in Preview Mode when visiting the website.
|
||||
_cookieManager.ExpireCookie(Constants.Web.AcceptPreviewCookieName);
|
||||
|
||||
// are we attempting a redirect to the default route (by ID with optional culture)?
|
||||
Match match = DefaultPreviewRedirectRegex().Match(redir ?? string.Empty);
|
||||
if (match.Success && int.TryParse(match.Groups["id"].Value, out int id))
|
||||
{
|
||||
// first try to resolve the published URL
|
||||
if (_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext) &&
|
||||
umbracoContext.Content is not null)
|
||||
{
|
||||
IPublishedContent? publishedContent = umbracoContext.Content.GetById(id);
|
||||
if (publishedContent is null)
|
||||
{
|
||||
// content is not published, redirect to root
|
||||
return Redirect("/");
|
||||
}
|
||||
|
||||
var culture = publishedContent.ContentType.VariesByCulture()
|
||||
&& match.Groups.TryGetValue("culture", out Group? group)
|
||||
? group.Value
|
||||
: null;
|
||||
|
||||
var publishedUrl = publishedContent.Url(culture);
|
||||
if (WebPath.IsWellFormedWebPath(publishedUrl, UriKind.RelativeOrAbsolute))
|
||||
{
|
||||
return Redirect(publishedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// could not resolve the published URL - are we allowed to route content by ID?
|
||||
if (_webRoutingSettings.DisableFindContentByIdPath)
|
||||
{
|
||||
// no we are not - redirect to root instead
|
||||
return Redirect("/");
|
||||
}
|
||||
}
|
||||
|
||||
if (WebPath.IsWellFormedWebPath(redir, UriKind.Relative)
|
||||
&& Uri.TryCreate(redir, UriKind.Relative, out Uri? url))
|
||||
{
|
||||
@@ -189,4 +258,7 @@ public class PreviewController : Controller
|
||||
|
||||
return Redirect("/");
|
||||
}
|
||||
|
||||
[GeneratedRegex("^\\/(?<id>\\d*)(\\?culture=(?<culture>[\\w-]*))?$")]
|
||||
private static partial Regex DefaultPreviewRedirectRegex();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -322,7 +322,7 @@ public class UsersController : BackOfficeNotificationsController
|
||||
/// <returns></returns>
|
||||
[OutgoingEditorModelEvent]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)]
|
||||
public ActionResult<IEnumerable<UserDisplay?>> GetByIds([FromJsonPath] int[] ids)
|
||||
public ActionResult<IEnumerable<UserDisplay?>> GetByIds([FromQuery] int[] ids)
|
||||
{
|
||||
if (ids == null)
|
||||
{
|
||||
@@ -664,10 +664,11 @@ public class UsersController : BackOfficeNotificationsController
|
||||
var emailSubject = _localizedTextService.Localize("user", "inviteEmailCopySubject",
|
||||
// Ensure the culture of the found user is used for the email!
|
||||
UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings));
|
||||
var name = userDisplay is null ? string.Empty : System.Web.HttpUtility.HtmlEncode(userDisplay.Name);
|
||||
var emailBody = _localizedTextService.Localize("user", "inviteEmailCopyFormat",
|
||||
// Ensure the culture of the found user is used for the email!
|
||||
UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings),
|
||||
new[] { userDisplay?.Name, from, WebUtility.HtmlEncode(message)!.ReplaceLineEndings("<br/>"), inviteUri.ToString(), senderEmail });
|
||||
new[] { name, from, WebUtility.HtmlEncode(message)!.ReplaceLineEndings("<br/>"), inviteUri.ToString(), senderEmail });
|
||||
|
||||
// This needs to be in the correct mailto format including the name, else
|
||||
// the name cannot be captured in the email sending notification.
|
||||
@@ -714,6 +715,15 @@ public class UsersController : BackOfficeNotificationsController
|
||||
|
||||
var hasErrors = false;
|
||||
|
||||
// User names can only contain the configured allowed characters. This is validated by ASP.NET Identity on create
|
||||
// as the setting is applied to the BackOfficeIdentityOptions, but we need to check ourselves for updates.
|
||||
var allowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters;
|
||||
if (userSave.Username.Any(c => allowedUserNameCharacters.Contains(c) == false))
|
||||
{
|
||||
ModelState.AddModelError("Username", "Username contains invalid characters");
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
// we need to check if there's any Deny Local login providers present, if so we need to ensure that the user's email address cannot be changed
|
||||
var hasDenyLocalLogin = _externalLogins.HasDenyLocalLogin();
|
||||
if (hasDenyLocalLogin)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -202,6 +205,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
{
|
||||
policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme);
|
||||
policy.Requirements.Add(new AdminUsersRequirement());
|
||||
policy.Requirements.Add(new AdminUsersRequirement("ids"));
|
||||
policy.Requirements.Add(new AdminUsersRequirement("userIds"));
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@ using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
@@ -16,27 +20,29 @@ namespace Umbraco.Cms.Web.BackOffice.Filters;
|
||||
/// <summary>
|
||||
/// Validator for <see cref="ContentItemSave" />
|
||||
/// </summary>
|
||||
internal class
|
||||
MemberSaveModelValidator : ContentModelValidator<IMember, MemberSave, IContentProperties<ContentPropertyBasic>>
|
||||
internal class MemberSaveModelValidator : ContentModelValidator<IMember, MemberSave, IContentProperties<ContentPropertyBasic>>
|
||||
{
|
||||
private readonly IBackOfficeSecurity? _backofficeSecurity;
|
||||
private readonly IMemberService _memberService;
|
||||
private readonly IMemberTypeService _memberTypeService;
|
||||
private readonly IShortStringHelper _shortStringHelper;
|
||||
private readonly SecuritySettings _securitySettings;
|
||||
|
||||
public MemberSaveModelValidator(
|
||||
ILogger<MemberSaveModelValidator> logger,
|
||||
IBackOfficeSecurity? backofficeSecurity,
|
||||
IMemberTypeService memberTypeService,
|
||||
IMemberService memberService,
|
||||
IShortStringHelper shortStringHelper,
|
||||
IPropertyValidationService propertyValidationService)
|
||||
: base(logger, propertyValidationService)
|
||||
ILogger<MemberSaveModelValidator> logger,
|
||||
IBackOfficeSecurity? backofficeSecurity,
|
||||
IMemberTypeService memberTypeService,
|
||||
IMemberService memberService,
|
||||
IShortStringHelper shortStringHelper,
|
||||
IPropertyValidationService propertyValidationService,
|
||||
SecuritySettings securitySettings)
|
||||
: base(logger, propertyValidationService)
|
||||
{
|
||||
_backofficeSecurity = backofficeSecurity;
|
||||
_memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService));
|
||||
_memberService = memberService ?? throw new ArgumentNullException(nameof(memberService));
|
||||
_shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper));
|
||||
_securitySettings = securitySettings;
|
||||
}
|
||||
|
||||
public override bool ValidatePropertiesData(
|
||||
@@ -64,8 +70,7 @@ internal class
|
||||
$"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email");
|
||||
}
|
||||
|
||||
var validEmail = ValidateUniqueEmail(model);
|
||||
if (validEmail == false)
|
||||
if (_securitySettings.MemberRequireUniqueEmail && ValidateUniqueEmail(model) is false)
|
||||
{
|
||||
modelState.AddPropertyError(
|
||||
new ValidationResult("Email address is already in use", new[] { "value" }),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
@@ -25,6 +27,7 @@ internal sealed class MemberSaveValidationAttribute : TypeFilterAttribute
|
||||
private readonly IMemberTypeService _memberTypeService;
|
||||
private readonly IPropertyValidationService _propertyValidationService;
|
||||
private readonly IShortStringHelper _shortStringHelper;
|
||||
private readonly SecuritySettings _securitySettings;
|
||||
|
||||
public MemberSaveValidationFilter(
|
||||
ILoggerFactory loggerFactory,
|
||||
@@ -32,16 +35,16 @@ internal sealed class MemberSaveValidationAttribute : TypeFilterAttribute
|
||||
IMemberTypeService memberTypeService,
|
||||
IMemberService memberService,
|
||||
IShortStringHelper shortStringHelper,
|
||||
IPropertyValidationService propertyValidationService)
|
||||
IPropertyValidationService propertyValidationService,
|
||||
IOptions<SecuritySettings> securitySettings)
|
||||
{
|
||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
_backofficeSecurityAccessor = backofficeSecurityAccessor ??
|
||||
throw new ArgumentNullException(nameof(backofficeSecurityAccessor));
|
||||
_memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService));
|
||||
_memberService = memberService ?? throw new ArgumentNullException(nameof(memberService));
|
||||
_shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper));
|
||||
_propertyValidationService = propertyValidationService ??
|
||||
throw new ArgumentNullException(nameof(propertyValidationService));
|
||||
_loggerFactory = loggerFactory;
|
||||
_backofficeSecurityAccessor = backofficeSecurityAccessor;
|
||||
_memberTypeService = memberTypeService;
|
||||
_memberService = memberService;
|
||||
_shortStringHelper = shortStringHelper;
|
||||
_propertyValidationService = propertyValidationService;
|
||||
_securitySettings = securitySettings.Value;
|
||||
}
|
||||
|
||||
public void OnActionExecuting(ActionExecutingContext context)
|
||||
@@ -53,7 +56,8 @@ internal sealed class MemberSaveValidationAttribute : TypeFilterAttribute
|
||||
_memberTypeService,
|
||||
_memberService,
|
||||
_shortStringHelper,
|
||||
_propertyValidationService);
|
||||
_propertyValidationService,
|
||||
_securitySettings);
|
||||
//now do each validation step
|
||||
if (contentItemValidator.ValidateExistingContent(model, context))
|
||||
{
|
||||
|
||||
+40
@@ -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() { }
|
||||
}
|
||||
|
||||
@@ -317,13 +317,7 @@ public class ContentTreeController : ContentTreeControllerBase, ISearchableTreeW
|
||||
|
||||
if (_emailSender.CanSendRequiredEmail())
|
||||
{
|
||||
menu.Items.Add(new MenuItem("notify", LocalizedTextService)
|
||||
{
|
||||
Icon = "icon-megaphone",
|
||||
SeparatorBefore = true,
|
||||
OpensDialog = true,
|
||||
UseLegacyIcon = false
|
||||
});
|
||||
AddActionNode<ActionNotify>(item, menu, hasSeparator: true, opensDialog: true, useLegacyIcon: false);
|
||||
}
|
||||
|
||||
if ((item is DocumentEntitySlim documentEntity && documentEntity.IsContainer) == false)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -62,9 +62,22 @@ public static class HttpContextExtensions
|
||||
// Update the HttpContext's user with the authenticated user's principal to ensure
|
||||
// that subsequent requests within the same context will recognize the user
|
||||
// as authenticated.
|
||||
if (result.Succeeded)
|
||||
if (result is { Succeeded: true, Principal.Identity: not null })
|
||||
{
|
||||
httpContext.User = result.Principal;
|
||||
// We need to get existing identities that are not the backoffice kind and flow them to the new identity
|
||||
// Otherwise we can't log in as both a member and a backoffice user
|
||||
// For instance if you've enabled basic auth.
|
||||
ClaimsPrincipal? authenticatedPrincipal = result.Principal;
|
||||
|
||||
// Make sure to copy into a list before attempting to update the authenticated principal, so we don't attempt to modify
|
||||
// the collection while iterating it.
|
||||
// See: https://github.com/umbraco/Umbraco-CMS/issues/18509
|
||||
var existingIdentities = httpContext.User.Identities
|
||||
.Where(x => x.IsAuthenticated && x.AuthenticationType != authenticatedPrincipal.Identity.AuthenticationType)
|
||||
.ToList();
|
||||
authenticatedPrincipal.AddIdentities(existingIdentities);
|
||||
|
||||
httpContext.User = authenticatedPrincipal;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
public static class MemberClaimsPrincipalExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to get specifically the member identity from the ClaimsPrincipal
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The identity returned is the one with default authentication type.
|
||||
/// </remarks>
|
||||
/// <param name="principal">The principal to find the identity in.</param>
|
||||
/// <returns>The default authenticated authentication type identity.</returns>
|
||||
public static ClaimsIdentity? GetMemberIdentity(this ClaimsPrincipal principal)
|
||||
=> principal.Identities.FirstOrDefault(x => x.AuthenticationType == IdentityConstants.ApplicationScheme);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user