Merge branch 'v14/dev' into v15/dev

# Conflicts:
#	src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs
#	src/Umbraco.Web.Common/Views/UmbracoViewPage.cs
This commit is contained in:
Sven Geusens
2025-02-18 11:09:52 +01:00
26 changed files with 419 additions and 71 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -20,6 +20,8 @@ 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-._@+\\";
@@ -64,7 +66,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

View File

@@ -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 }))
{

View File

@@ -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,
}

View File

@@ -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.

View File

@@ -217,6 +217,15 @@ public interface IMemberService : IMembershipMemberService, IContentServiceBase<
/// </returns>
IMember CreateMemberWithIdentity(string username, string email, string name, IMemberType memberType);
/// <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="member"><see cref="IMember" /> or <see cref="IUser" /> to Save</param>
/// <param name="publishNotificationSaveOptions"> Enum for deciding which notifications to publish.</param>
/// <param name="userId">Id of the User saving the Member</param>
Attempt<OperationResult?> Save(IMember member, PublishNotificationSaveOptions publishNotificationSaveOptions, int userId = Constants.Security.SuperUserId) => Save(member, userId);
/// <summary>
/// Saves a single <see cref="IMember" /> object
/// </summary>
@@ -268,6 +277,21 @@ public interface IMemberService : IMembershipMemberService, IContentServiceBase<
/// </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>

View File

@@ -408,16 +408,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>
@@ -765,6 +772,9 @@ namespace Umbraco.Cms.Core.Services
/// <inheritdoc />
public Attempt<OperationResult?> Save(IMember member, int userId = Constants.Security.SuperUserId)
=> Save(member, PublishNotificationSaveOptions.All, userId);
public Attempt<OperationResult?> Save(IMember member, PublishNotificationSaveOptions publishNotificationSaveOptions, int userId = Constants.Security.SuperUserId)
{
// trimming username and email to make sure we have no trailing space
member.Username = member.Username.Trim();
@@ -773,11 +783,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 OperationResult.Attempt.Cancel(evtMsgs);
savingNotification = new MemberSavingNotification(member, evtMsgs);
if (scope.Notifications.PublishCancelable(savingNotification))
{
scope.Complete();
return OperationResult.Attempt.Cancel(evtMsgs);
}
}
if (string.IsNullOrWhiteSpace(member.Name))
@@ -789,7 +803,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, userId, member.Id);

View File

@@ -87,6 +87,6 @@ public static class UserServiceExtensions
});
}
[Obsolete("Use IUserService.Get that takes a Guid instead. Scheduled for removal in V15.")]
[Obsolete("Use IUserService.GetAsync that takes a Guid instead. Scheduled for removal in V15.")]
public static IUser? GetByKey(this IUserService userService, Guid key) => userService.GetAsync(key).GetAwaiter().GetResult();
}

View File

@@ -24,6 +24,8 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB
private static readonly TEntity[] _emptyEntities = new TEntity[0]; // const
private readonly RepositoryCachePolicyOptions _options;
private const string NullRepresentationInCache = "*NULL*";
public DefaultRepositoryCachePolicy(IAppPolicyCache cache, IScopeAccessor scopeAccessor, RepositoryCachePolicyOptions options)
: base(cache, scopeAccessor) =>
_options = options ?? throw new ArgumentNullException(nameof(options));
@@ -116,6 +118,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 +130,36 @@ 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;
}
// Because TEntity can never be a string, we will never be in a position where the proxy value collides withs a real value.
// Therefore this point can only be reached if there is a proxy null value => becomes null when cast to TEntity above OR the item simply does not exist.
// If we've cached a "null" value, return null.
if (_options.CacheNullValues && Cache.GetCacheItem<string>(cacheKey) == NullRepresentationInCache)
{
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 +267,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, () => NullRepresentationInCache, TimeSpan.FromMinutes(5), true);
}
protected virtual void InsertEntities(TId[]? ids, TEntity[]? entities)
{
if (ids?.Length == 0 && entities?.Length == 0 && _options.GetAllCacheAllowZeroCount)

View File

@@ -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)

View File

@@ -143,14 +143,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);

View File

@@ -122,11 +122,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);
}
private IDictionaryItem ConvertFromDto(DictionaryDto dto, IDictionary<int, ILanguage> languagesById)
@@ -217,11 +216,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);
}
protected override IEnumerable<IDictionaryItem> PerformGetAll(params Guid[]? ids)
@@ -272,12 +270,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);
}
protected override IEnumerable<IDictionaryItem> PerformGetAll(params string[]? ids)

View File

@@ -389,7 +389,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);
@@ -403,6 +405,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

View File

@@ -5,6 +5,7 @@ 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.DependencyInjection;
using Umbraco.Cms.Core.Exceptions;
@@ -33,6 +34,8 @@ public sealed class RichTextEditorPastedImages
private readonly IScopeProvider _scopeProvider;
private readonly IMediaImportService _mediaImportService;
private readonly IImageUrlGenerator _imageUrlGenerator;
private readonly IEntityService _entityService;
private readonly AppCaches _appCaches;
private readonly IUserService _userService;
[Obsolete("Please use the non-obsolete constructor. Will be removed in V16.")]
@@ -84,6 +87,30 @@ public sealed class RichTextEditorPastedImages
{
}
// highest overload to be picked by DI, pointing to newest ctor
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
public RichTextEditorPastedImages(
IUmbracoContextAccessor umbracoContextAccessor,
ILogger<RichTextEditorPastedImages> logger,
IHostingEnvironment hostingEnvironment,
IMediaService mediaService,
IContentTypeBaseServiceProvider contentTypeBaseServiceProvider,
MediaFileManager mediaFileManager,
MediaUrlGeneratorCollection mediaUrlGenerators,
IShortStringHelper shortStringHelper,
IPublishedUrlProvider publishedUrlProvider,
ITemporaryFileService temporaryFileService,
IScopeProvider scopeProvider,
IMediaImportService mediaImportService,
IImageUrlGenerator imageUrlGenerator,
IOptions<ContentSettings> contentSettings,
IEntityService entityService,
AppCaches appCaches)
: this(umbracoContextAccessor, publishedUrlProvider, temporaryFileService, scopeProvider, mediaImportService, imageUrlGenerator, entityService, appCaches)
{
}
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
public RichTextEditorPastedImages(
IUmbracoContextAccessor umbracoContextAccessor,
IPublishedUrlProvider publishedUrlProvider,
@@ -91,6 +118,27 @@ public sealed class RichTextEditorPastedImages
IScopeProvider scopeProvider,
IMediaImportService mediaImportService,
IImageUrlGenerator imageUrlGenerator)
: this(
umbracoContextAccessor,
publishedUrlProvider,
temporaryFileService,
scopeProvider,
mediaImportService,
imageUrlGenerator,
StaticServiceProvider.Instance.GetRequiredService<IEntityService>(),
StaticServiceProvider.Instance.GetRequiredService<AppCaches>())
{
}
public RichTextEditorPastedImages(
IUmbracoContextAccessor umbracoContextAccessor,
IPublishedUrlProvider publishedUrlProvider,
ITemporaryFileService temporaryFileService,
IScopeProvider scopeProvider,
IMediaImportService mediaImportService,
IImageUrlGenerator imageUrlGenerator,
IEntityService entityService,
AppCaches appCaches)
{
_umbracoContextAccessor =
umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor));
@@ -99,6 +147,8 @@ public sealed class RichTextEditorPastedImages
_scopeProvider = scopeProvider;
_mediaImportService = mediaImportService;
_imageUrlGenerator = imageUrlGenerator;
_entityService = entityService;
_appCaches = appCaches;
// this obviously is not correct. however, we only use IUserService in an obsolete method,
// so this is better than having even more obsolete constructors for V16
@@ -161,7 +211,7 @@ public sealed class RichTextEditorPastedImages
if (uploadedImages.ContainsKey(temporaryFileKey) == false)
{
using Stream fileStream = temporaryFile.OpenReadStream();
Guid? parentFolderKey = mediaParentFolder == Guid.Empty ? Constants.System.RootKey : mediaParentFolder;
Guid? parentFolderKey = mediaParentFolder == Guid.Empty ? await GetDefaultMediaRoot(userKey) : mediaParentFolder;
IMedia mediaFile = await _mediaImportService.ImportAsync(temporaryFile.FileName, fileStream, parentFolderKey, MediaTypeAlias(temporaryFile.FileName), userKey);
udi = mediaFile.GetUdi();
}
@@ -214,6 +264,20 @@ public sealed class RichTextEditorPastedImages
return htmlDoc.DocumentNode.OuterHtml;
}
private async Task<Guid?> GetDefaultMediaRoot(Guid userKey)
{
IUser user = await _userService.GetAsync(userKey) ?? throw new ArgumentException("User could not be found");
var userStartNodes = user.CalculateMediaStartNodeIds(_entityService, _appCaches);
var firstNodeId = userStartNodes?.FirstOrDefault();
if (firstNodeId is null)
{
return Constants.System.RootKey;
}
Attempt<Guid> firstNodeKeyAttempt = _entityService.GetKey(firstNodeId.Value, UmbracoObjectTypes.Media);
return firstNodeKeyAttempt.Success ? firstNodeKeyAttempt.Result : Constants.System.RootKey;
}
private string MediaTypeAlias(string fileName)
=> fileName.InvariantEndsWith(".svg")
? Constants.Conventions.MediaTypes.VectorGraphicsAlias

View File

@@ -101,7 +101,7 @@ public class MemberUserStore : UmbracoUserStore<MemberIdentityUser, UmbracoIdent
UpdateMemberProperties(memberEntity, user, out bool _);
// create the member
Attempt<OperationResult?> saveAttempt = _memberService.Save(memberEntity);
Attempt<OperationResult?> saveAttempt = _memberService.Save(memberEntity, PublishNotificationSaveOptions.Saving);
if (saveAttempt.Success is false)
{
scope.Complete();

View File

@@ -35,18 +35,29 @@ public abstract class UmbracoUserStore<TUser, TRole>
[Obsolete("Use TryConvertIdentityIdToInt instead. Scheduled for removal in V15.")]
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 abstract Task<int> ResolveEntityIdFromIdentityId(string? identityId);

View File

@@ -62,9 +62,16 @@ 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;
IEnumerable<ClaimsIdentity> existingIdentities = httpContext.User.Identities.Where(x => x.IsAuthenticated && x.AuthenticationType != authenticatedPrincipal.Identity.AuthenticationType);
authenticatedPrincipal.AddIdentities(existingIdentities);
httpContext.User = authenticatedPrincipal;
}
return result;

View File

@@ -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);
}

View File

@@ -24,7 +24,7 @@ public sealed class ConfigureMemberIdentityOptions : IConfigureOptions<IdentityO
options.SignIn.RequireConfirmedEmail = false; // not implemented
options.SignIn.RequireConfirmedPhoneNumber = false; // not implemented
options.User.RequireUniqueEmail = true;
options.User.RequireUniqueEmail = _securitySettings.MemberRequireUniqueEmail;
// Support validation of member names using Down-Level Logon Name format
options.User.AllowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters;

View File

@@ -1,4 +1,5 @@
using System.Globalization;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
@@ -113,8 +114,11 @@ public class MemberManager : UmbracoUserManager<MemberIdentityUser, MemberPasswo
/// <inheritdoc />
public virtual bool IsLoggedIn()
{
HttpContext? httpContext = _httpContextAccessor.HttpContext;
return httpContext?.User.Identity?.IsAuthenticated ?? false;
// We have to try and specifically find the member identity, it's entirely possible for there to be both backoffice and member.
ClaimsIdentity? memberIdentity = _httpContextAccessor.HttpContext?.User.GetMemberIdentity();
return memberIdentity is not null &&
memberIdentity.IsAuthenticated;
}
/// <inheritdoc />
@@ -170,23 +174,27 @@ public class MemberManager : UmbracoUserManager<MemberIdentityUser, MemberPasswo
/// <inheritdoc />
public virtual async Task<MemberIdentityUser?> GetCurrentMemberAsync()
{
if (_currentMember == null)
if (_currentMember is not null)
{
if (!IsLoggedIn())
{
return null;
}
_currentMember = await GetUserAsync(_httpContextAccessor.HttpContext?.User!);
return _currentMember;
}
if (IsLoggedIn() is false)
{
return null;
}
// Create a principal the represents the member security context.
var memberPrincipal = new ClaimsPrincipal(_httpContextAccessor.HttpContext?.User.GetMemberIdentity()!);
_currentMember = await GetUserAsync(memberPrincipal);
return _currentMember;
}
public virtual IPublishedContent? AsPublishedMember(MemberIdentityUser user) => _store.GetPublishedMember(user);
/// <summary>
/// This will check if the member has access to this path
/// This will check if the member has access to this path.
/// </summary>
/// <param name="path"></param>
/// <returns></returns>

View File

@@ -638,6 +638,89 @@ public class TagRepositoryTest : UmbracoIntegrationTest
}
}
[Test]
public void Can_Get_Tags_For_Entity_Type_Excluding_Trashed_Entity()
{
var provider = ScopeProvider;
using (ScopeProvider.CreateScope())
{
var template = TemplateBuilder.CreateTextPageTemplate();
FileService.SaveTemplate(template);
var contentType = ContentTypeBuilder.CreateSimpleContentType("test", "Test", defaultTemplateId: template.Id);
ContentTypeRepository.Save(contentType);
var content1 = ContentBuilder.CreateSimpleContent(contentType);
content1.PublishCulture(CultureImpact.Invariant);
content1.PublishedState = PublishedState.Publishing;
DocumentRepository.Save(content1);
var content2 = ContentBuilder.CreateSimpleContent(contentType);
content2.PublishCulture(CultureImpact.Invariant);
content2.PublishedState = PublishedState.Publishing;
content2.Trashed = true;
DocumentRepository.Save(content2);
var mediaType = MediaTypeBuilder.CreateImageMediaType("image2");
MediaTypeRepository.Save(mediaType);
var media1 = MediaBuilder.CreateMediaImage(mediaType, -1);
MediaRepository.Save(media1);
var media2 = MediaBuilder.CreateMediaImage(mediaType, -1);
media2.Trashed = true;
MediaRepository.Save(media2);
var repository = CreateRepository(provider);
Tag[] tags =
{
new Tag {Text = "tag1", Group = "test"},
new Tag {Text = "tag2", Group = "test1"},
new Tag {Text = "tag3", Group = "test"}
};
Tag[] tags2 =
{
new Tag {Text = "tag4", Group = "test"},
new Tag {Text = "tag5", Group = "test1"},
new Tag {Text = "tag6", Group = "test"}
};
repository.Assign(
content1.Id,
contentType.PropertyTypes.First().Id,
tags,
false);
repository.Assign(
content2.Id,
contentType.PropertyTypes.First().Id,
tags2,
false);
repository.Assign(
media1.Id,
contentType.PropertyTypes.First().Id,
tags,
false);
repository.Assign(
media2.Id,
contentType.PropertyTypes.First().Id,
tags2,
false);
var result1 = repository.GetTagsForEntityType(TaggableObjectTypes.Content).ToArray();
var result2 = repository.GetTagsForEntityType(TaggableObjectTypes.Media).ToArray();
var result3 = repository.GetTagsForEntityType(TaggableObjectTypes.All).ToArray();
const string ExpectedTags = "tag1,tag2,tag3";
Assert.AreEqual(ExpectedTags, string.Join(",", result1.Select(x => x.Text)));
Assert.AreEqual(ExpectedTags, string.Join(",", result2.Select(x => x.Text)));
Assert.AreEqual(ExpectedTags, string.Join(",", result3.Select(x => x.Text)));
}
}
[Test]
public void Can_Get_Tags_For_Entity_Type()
{

View File

@@ -268,7 +268,7 @@ public class MemberManagerTests
.Setup(x => x.CreateMember(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns(fakeMember);
_mockMemberService
.Setup(x => x.Save(fakeMember, Constants.Security.SuperUserId))
.Setup(x => x.Save(fakeMember, It.IsAny<PublishNotificationSaveOptions>(), Constants.Security.SuperUserId))
.Returns(Attempt.Succeed<OperationResult?>(null));
}

View File

@@ -124,7 +124,7 @@ public class MemberUserStoreTests
.Setup(x => x.CreateMember(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns(mockMember);
_mockMemberService
.Setup(x => x.Save(mockMember, Constants.Security.SuperUserId))
.Setup(x => x.Save(mockMember, PublishNotificationSaveOptions.Saving, Constants.Security.SuperUserId))
.Returns(Attempt.Succeed<OperationResult?>(null));
// act
var identityResult = await sut.CreateAsync(fakeUser, CancellationToken.None);
@@ -134,7 +134,7 @@ public class MemberUserStoreTests
Assert.IsTrue(!identityResult.Errors.Any());
_mockMemberService.Verify(x =>
x.CreateMember(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()));
_mockMemberService.Verify(x => x.Save(mockMember, Constants.Security.SuperUserId));
_mockMemberService.Verify(x => x.Save(mockMember, PublishNotificationSaveOptions.Saving, Constants.Security.SuperUserId));
}
[Test]