Merge remote-tracking branch 'origin/v14/dev' into v15/dev

# Conflicts:
#	Directory.Packages.props
#	src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
#	tests/Directory.Packages.props
#	version.json
This commit is contained in:
Bjarke Berg
2024-08-27 13:28:36 +02:00
147 changed files with 5921 additions and 566 deletions

View File

@@ -44,14 +44,14 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
<PackageVersion Include="Examine" Version="3.2.1" />
<PackageVersion Include="Examine.Core" Version="3.2.1" />
<PackageVersion Include="HtmlAgilityPack" Version="1.11.61" />
<PackageVersion Include="JsonPatch.Net" Version="3.1.0" />
<PackageVersion Include="Examine" Version="3.3.0" />
<PackageVersion Include="Examine.Core" Version="3.3.0" />
<PackageVersion Include="HtmlAgilityPack" Version="1.11.62" />
<PackageVersion Include="JsonPatch.Net" Version="3.1.1" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
<PackageVersion Include="MailKit" Version="4.6.0" />
<PackageVersion Include="MailKit" Version="4.7.1.1" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="2.5.168" />
<PackageVersion Include="MessagePack" Version="2.5.172" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.3.8" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.3.8" />
<PackageVersion Include="ncrontab" Version="3.3.3" />
@@ -61,20 +61,20 @@
<PackageVersion Include="OpenIddict.AspNetCore" Version="5.7.0" />
<PackageVersion Include="OpenIddict.EntityFrameworkCore" Version="5.7.0" />
<PackageVersion Include="Serilog" Version="3.1.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageVersion Include="Serilog.Enrichers.Process" Version="2.0.2" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<PackageVersion Include="Serilog.Expressions" Version="4.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="2.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact.Reader" Version="3.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.1" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.2" />
<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.4" />
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.1.2" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.1.3" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.7.0" />
</ItemGroup>
<!-- Transitive pinned versions (only required because our direct dependencies have vulnerable versions of transitive dependencies) -->
<ItemGroup>

View File

@@ -1,6 +1,7 @@
{
"sdk": {
"version": "9.0.100-preview.5.24307.3",
"rollForward": "latestFeature"
"rollForward": "latestFeature",
"allowPrerelease": true
}
}

View File

@@ -10,14 +10,15 @@ public class ProcessRequestContextHandler
: IOpenIddictServerHandler<OpenIddictServerEvents.ProcessRequestContext>, IOpenIddictValidationHandler<OpenIddictValidationEvents.ProcessRequestContext>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly string _backOfficePathSegment;
private readonly string[] _pathsToHandle;
public ProcessRequestContextHandler(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
_backOfficePathSegment = Constants.System.DefaultUmbracoPath.TrimStart(Constants.CharArrays.Tilde)
var backOfficePathSegment = Constants.System.DefaultUmbracoPath.TrimStart(Constants.CharArrays.Tilde)
.EnsureStartsWith('/')
.EnsureEndsWith('/');
_pathsToHandle = [backOfficePathSegment, "/.well-known/openid-configuration"];
}
public ValueTask HandleAsync(OpenIddictServerEvents.ProcessRequestContext context)
@@ -48,6 +49,14 @@ public class ProcessRequestContextHandler
return false;
}
return requestPath.StartsWith(_backOfficePathSegment) is false;
foreach (var path in _pathsToHandle)
{
if (requestPath.StartsWith(path))
{
return false;
}
}
return true;
}
}

View File

@@ -218,11 +218,12 @@ public class MemberController : DeliveryApiControllerBase
claim.SetDestinations(OpenIddictConstants.Destinations.AccessToken);
}
if (request.GetScopes().Contains(OpenIddictConstants.Scopes.OfflineAccess))
{
// "offline_access" scope is required to use refresh tokens
memberPrincipal.SetScopes(OpenIddictConstants.Scopes.OfflineAccess);
}
// "openid" and "offline_access" are the only scopes allowed for members; explicitly ensure we only add those
// NOTE: the "offline_access" scope is required to use refresh tokens
IEnumerable<string> allowedScopes = request
.GetScopes()
.Intersect(new[] { OpenIddictConstants.Scopes.OpenId, OpenIddictConstants.Scopes.OfflineAccess });
memberPrincipal.SetScopes(allowedScopes);
return new SignInResult(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, memberPrincipal);
}

View File

@@ -11,7 +11,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.DataType;
[VersionedApiBackOfficeRoute(Constants.UdiEntityType.DataType)]
[ApiExplorerSettings(GroupName = "Data Type")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes)]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentOrMediaOrContentTypes)]
public abstract class DataTypeControllerBase : ManagementApiControllerBase
{
protected IActionResult DataTypeOperationStatusResult(DataTypeOperationStatus status) =>

View File

@@ -39,7 +39,7 @@ public class GetAuditLogDocumentController : DocumentControllerBase
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionProtect.ActionLetter, id),
ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)

View File

@@ -66,6 +66,10 @@ public class UpdateDomainsController : DocumentControllerBase
.WithDetail("One or more of the specified domain names were conflicting with domain assignments to other content items.")
.WithExtension("conflictingDomainNames", _domainPresentationFactory.CreateDomainAssignmentModels(result.Result.ConflictingDomains.EmptyNull()))
.Build()),
DomainOperationStatus.InvalidDomainName => BadRequest(problemDetailsBuilder
.WithTitle("Invalid domain name detected")
.WithDetail("One or more of the specified domain names were invalid.")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder
.WithTitle("Unknown domain update operation status.")
.Build()),

View File

@@ -98,6 +98,18 @@ public abstract class DocumentTypeControllerBase : ManagementApiControllerBase
.WithTitle("Name was too long")
.WithDetail("Name cannot be more than 255 characters in length.")
.Build()),
ContentTypeOperationStatus.InvalidElementFlagDocumentHasContent => new BadRequestObjectResult(problemDetailsBuilder
.WithTitle("Invalid IsElement flag")
.WithDetail("Cannot change to element type because content has already been created with this document type.")
.Build()),
ContentTypeOperationStatus.InvalidElementFlagElementIsUsedInPropertyEditorConfiguration => new BadRequestObjectResult(problemDetailsBuilder
.WithTitle("Invalid IsElement flag")
.WithDetail("Cannot change to document type because this element type is used in the configuration of a data type.")
.Build()),
ContentTypeOperationStatus.InvalidElementFlagComparedToParent => new BadRequestObjectResult(problemDetailsBuilder
.WithTitle("Invalid IsElement flag")
.WithDetail("Can not create a documentType with inheritance composition where the parent and the new type's IsElement flag are different.")
.Build()),
_ => new ObjectResult("Unknown content type operation status") { StatusCode = StatusCodes.Status500InternalServerError },
});

View File

@@ -45,7 +45,8 @@ public abstract class UserStartNodeTreeControllerBase<TItem> : EntityTreeControl
IEntitySlim[] children = base.GetPagedChildEntities(parentKey, skip, take, out totalItems);
return UserHasRootAccess() || IgnoreUserStartNodes()
? children
: CalculateAccessMap(() => _userStartNodeEntitiesService.ChildUserAccessEntities(children, UserStartNodePaths), out totalItems);
// Keeping the correct totalItems amount from GetPagedChildEntities
: CalculateAccessMap(() => _userStartNodeEntitiesService.ChildUserAccessEntities(children, UserStartNodePaths), out _);
}
protected override TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities)

View File

@@ -33,6 +33,7 @@ internal static class ApplicationBuilderExtensions
{
innerBuilder.UseExceptionHandler(exceptionBuilder => exceptionBuilder.Run(async context =>
{
var isDebug = context.RequestServices.GetRequiredService<IHostingEnvironment>().IsDebugMode;
Exception? exception = context.Features.Get<IExceptionHandlerPathFeature>()?.Error;
if (exception is null)
{
@@ -42,9 +43,9 @@ internal static class ApplicationBuilderExtensions
var response = new ProblemDetails
{
Title = exception.Message,
Detail = exception.StackTrace,
Detail = isDebug ? exception.StackTrace : null,
Status = StatusCodes.Status500InternalServerError,
Instance = exception.GetType().Name,
Instance = isDebug ? exception.GetType().Name : null,
Type = "Error"
};
await context.Response.WriteAsJsonAsync(response);

View File

@@ -29,6 +29,7 @@ internal static class BackOfficeAuthPolicyBuilderExtensions
builder.Services.AddSingleton<IAuthorizationHandler, UserGroupPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, UserPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, AllowedApplicationHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, BackOfficeHandler>();
builder.Services.AddAuthorization(CreatePolicies);
return builder;
@@ -46,7 +47,7 @@ internal static class BackOfficeAuthPolicyBuilderExtensions
options.AddPolicy(AuthorizationPolicies.BackOfficeAccess, policy =>
{
policy.AuthenticationSchemes.Add(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new BackOfficeRequirement());
});
options.AddPolicy(AuthorizationPolicies.RequireAdminAccess, policy =>
@@ -76,6 +77,7 @@ internal static class BackOfficeAuthPolicyBuilderExtensions
AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDictionaryOrTemplates, Constants.Applications.Translation, Constants.Applications.Settings);
AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDocuments, Constants.Applications.Content);
AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDocumentsOrDocumentTypes, Constants.Applications.Content, Constants.Applications.Settings);
AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDocumentOrMediaOrContentTypes, Constants.Applications.Content, Constants.Applications.Settings, Constants.Applications.Media);
AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessDocumentTypes, Constants.Applications.Settings);
AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessLanguages, Constants.Applications.Settings);
AddAllowedApplicationsPolicy(AuthorizationPolicies.TreeAccessMediaTypes, Constants.Applications.Settings);

View File

@@ -1,11 +1,12 @@
using Umbraco.Cms.Api.Management.Mapping;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Api.Management.Mapping;
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.UserGroup;
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.Membership.Permissions;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Core.Strings;
@@ -20,17 +21,30 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
private readonly IShortStringHelper _shortStringHelper;
private readonly ILanguageService _languageService;
private readonly IPermissionPresentationFactory _permissionPresentationFactory;
private readonly ILogger<UserGroupPresentationFactory> _logger;
[Obsolete("Use the new constructor instead, will be removed in v16.")]
public UserGroupPresentationFactory(
IEntityService entityService,
IShortStringHelper shortStringHelper,
ILanguageService languageService,
IPermissionPresentationFactory permissionPresentationFactory)
: this(entityService, shortStringHelper, languageService, permissionPresentationFactory, StaticServiceProvider.Instance.GetRequiredService<ILogger<UserGroupPresentationFactory>>())
{
}
public UserGroupPresentationFactory(
IEntityService entityService,
IShortStringHelper shortStringHelper,
ILanguageService languageService,
IPermissionPresentationFactory permissionPresentationFactory,
ILogger<UserGroupPresentationFactory> logger)
{
_entityService = entityService;
_shortStringHelper = shortStringHelper;
_languageService = languageService;
_permissionPresentationFactory = permissionPresentationFactory;
_logger = logger;
}
/// <inheritdoc />
@@ -43,10 +57,9 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
Attempt<IEnumerable<string>, UserGroupOperationStatus> languageIsoCodesMappingAttempt = await MapLanguageIdsToIsoCodeAsync(userGroup.AllowedLanguages);
// We've gotten this data from the database, so the mapping should not fail
if (languageIsoCodesMappingAttempt.Success is false)
{
throw new InvalidOperationException($"Unknown language ID in User Group: {userGroup.Name}");
_logger.LogDebug("Unknown language ID in User Group: {0}", userGroup.Name);
}
return new UserGroupResponseModel
@@ -77,10 +90,9 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
Guid? mediaStartNodeKey = GetKeyFromId(userGroup.StartMediaId, UmbracoObjectTypes.Media);
Attempt<IEnumerable<string>, UserGroupOperationStatus> languageIsoCodesMappingAttempt = await MapLanguageIdsToIsoCodeAsync(userGroup.AllowedLanguages);
// We've gotten this data from the database, so the mapping should not fail
if (languageIsoCodesMappingAttempt.Success is false)
{
throw new InvalidOperationException($"Unknown language ID in User Group: {userGroup.Name}");
_logger.LogDebug("Unknown language ID in User Group: {0}", userGroup.Name);
}
return new UserGroupResponseModel
@@ -217,9 +229,10 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
.Select(x => x.IsoCode)
.ToArray();
return isoCodes.Length == ids.Count()
? Attempt.SucceedWithStatus<IEnumerable<string>, UserGroupOperationStatus>(UserGroupOperationStatus.Success, isoCodes)
: Attempt.FailWithStatus<IEnumerable<string>, UserGroupOperationStatus>(UserGroupOperationStatus.LanguageNotFound, isoCodes);
// if a language id does not exist, it simply not returned.
// We do this so we don't have to clean up user group data when deleting languages and to make it easier to restore accidentally removed languages
return Attempt.SucceedWithStatus<IEnumerable<string>, UserGroupOperationStatus>(
UserGroupOperationStatus.Success, isoCodes);
}
private async Task<Attempt<IEnumerable<int>, UserGroupOperationStatus>> MapLanguageIsoCodesToIdsAsync(IEnumerable<string> isoCodes)

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Umbraco.Cms.Api.Management.Security.Authorization.User;
namespace Umbraco.Cms.Api.Management.Security.Authorization.DenyLocalLogin;
@@ -24,12 +25,12 @@ public class DenyLocalLoginHandler : MustSatisfyRequirementAuthorizationHandler<
if (isDenied is false)
{
// AuthorizationPolicies.BackOfficeAccess policy adds this requirement by policy.RequireAuthenticatedUser()
// AuthorizationPolicies.BackOfficeAccess policy adds this requirement by policy.Requirements.Add(new BackOfficeRequirement());
// Since we want to "allow anonymous" for some endpoints (i.e. BackOfficeController.Login()), it is necessary to succeed this requirement
IEnumerable<DenyAnonymousAuthorizationRequirement> denyAnonymousUserRequirements = context.PendingRequirements.OfType<DenyAnonymousAuthorizationRequirement>();
foreach (DenyAnonymousAuthorizationRequirement denyAnonymousUserRequirement in denyAnonymousUserRequirements)
IEnumerable<BackOfficeRequirement> backOfficeRequirements = context.PendingRequirements.OfType<BackOfficeRequirement>();
foreach (BackOfficeRequirement backOfficeRequirement in backOfficeRequirements)
{
context.Succeed(denyAnonymousUserRequirement);
context.Succeed(backOfficeRequirement);
}
}

View File

@@ -17,8 +17,8 @@ internal sealed class AllowedApplicationHandler : MustSatisfyRequirementAuthoriz
protected override Task<bool> IsAuthorized(AuthorizationHandlerContext context, AllowedApplicationRequirement requirement)
{
IUser user = _authorizationHelper.GetUmbracoUser(context.User);
var allowed = user.AllowedSections.ContainsAny(requirement.Applications);
var allowed = _authorizationHelper.TryGetUmbracoUser(context.User, out IUser? user)
&& user.AllowedSections.ContainsAny(requirement.Applications);
return Task.FromResult(allowed);
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Api.Management.Security.Authorization.User;
/// <summary>
/// Ensures authorization is successful for a back office user.
/// </summary>
public class BackOfficeHandler : MustSatisfyRequirementAuthorizationHandler<BackOfficeRequirement>
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurity;
public BackOfficeHandler(IBackOfficeSecurityAccessor backOfficeSecurity)
{
_backOfficeSecurity = backOfficeSecurity;
}
protected override Task<bool> IsAuthorized(AuthorizationHandlerContext context, BackOfficeRequirement requirement)
{
if (context.HasFailed is false && context.HasSucceeded is true)
{
return Task.FromResult(true);
}
if (!_backOfficeSecurity.BackOfficeSecurity?.IsAuthenticated() ?? false)
{
return Task.FromResult(false);
}
var userApprovalSucceeded = !requirement.RequireApproval ||
(_backOfficeSecurity.BackOfficeSecurity?.CurrentUser?.IsApproved ?? false);
return Task.FromResult(userApprovalSucceeded);
}
}

View File

@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
namespace Umbraco.Cms.Api.Management.Security.Authorization.User;
/// <summary>
/// Authorization requirement for the <see cref="BackOfficeHandler" />.
/// </summary>
public class BackOfficeRequirement : IAuthorizationRequirement
{
/// <summary>
/// Initializes a new instance of the <see cref="BackOfficeRequirement" /> class.
/// </summary>
/// <param name="requireApproval">Flag for whether back-office user approval is required.</param>
public BackOfficeRequirement(bool requireApproval = true) => RequireApproval = requireApproval;
/// <summary>
/// Gets a value indicating whether back-office user approval is required.
/// </summary>
public bool RequireApproval { get; }
}

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" VersionOverride="[2.1.8, 3)" />
<PackageReference Include="SixLabors.ImageSharp" VersionOverride="[2.1.9, 3)" />
<PackageReference Include="SixLabors.ImageSharp.Web" VersionOverride="[2.0.2, 3)" />
</ItemGroup>

View File

@@ -22,6 +22,7 @@ public static partial class Constants
public static class LiveEnvironment
{
public const string CompilationDebugCheck = "https://umbra.co/healthchecks-compilation-debug";
public const string RuntimeModeCheck = "https://docs.umbraco.com/umbraco-cms/fundamentals/setup/server-setup/runtime-modes";
}
public static class Configuration

View File

@@ -403,6 +403,9 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<ITemporaryFileToXmlImportService, TemporaryFileToXmlImportService>();
Services.AddUnique<IContentTypeImportService, ContentTypeImportService>();
Services.AddUnique<IMediaTypeImportService, MediaTypeImportService>();
// add validation services
Services.AddUnique<IElementSwitchValidator, ElementSwitchValidator>();
}
}
}

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<language alias="en" intName="English (UK)" localName="English (UK)" lcid="" culture="en-GB">
<creator>
<name>The Umbraco community</name>
@@ -428,6 +428,8 @@
<key alias="compilationDebugCheckErrorMessage">Debug compilation mode is currently enabled. It is recommended to
disable this setting before go live.
</key>
<key alias="runtimeModeCheckSuccessMessage">Runtime mode is set to production.</key>
<key alias="runtimeModeCheckErrorMessage">Runtime mode is not set to Production. It is recommended to set the Runtime Mode to Production for live/production environments.</key>
<!-- The following keys get these tokens passed in:
0: Path to the file not found
-->

View File

@@ -419,6 +419,8 @@
<key alias="compilationDebugCheckErrorMessage">Debug compilation mode is currently enabled. It is recommended to
disable this setting before go live.
</key>
<key alias="runtimeModeCheckSuccessMessage">Runtime mode is set to production.</key>
<key alias="runtimeModeCheckErrorMessage">Runtime mode is not set to Production. It is recommended to set the Runtime Mode to Production for live/production environments.</key>
<!-- The following keys get these tokens passed in:
0: Comma delimitted list of failed folder paths
-->

View File

@@ -219,6 +219,24 @@ public static class ObjectExtensions
}
}
if (target == typeof(DateTime) && input is DateTimeOffset dateTimeOffset)
{
// IMPORTANT: for compatability with various editors, we must discard any Offset information and assume UTC time here
return Attempt.Succeed((object?)new DateTime(
new DateOnly(dateTimeOffset.Year, dateTimeOffset.Month, dateTimeOffset.Day),
new TimeOnly(dateTimeOffset.Hour, dateTimeOffset.Minute, dateTimeOffset.Second, dateTimeOffset.Millisecond, dateTimeOffset.Microsecond),
DateTimeKind.Utc));
}
if (target == typeof(DateTimeOffset) && input is DateTime dateTime)
{
// IMPORTANT: for compatability with various editors, we must discard any DateTimeKind information and assume UTC time here
return Attempt.Succeed((object?)new DateTimeOffset(
new DateOnly(dateTime.Year, dateTime.Month, dateTime.Day),
new TimeOnly(dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond, dateTime.Microsecond),
TimeSpan.Zero));
}
TypeConverter? inputConverter = GetCachedSourceTypeConverter(inputType, target);
if (inputConverter != null)
{

View File

@@ -0,0 +1,104 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.ContentTypeEditing;
namespace Umbraco.Cms.Core.Handlers;
public class WarnDocumentTypeElementSwitchNotificationHandler :
INotificationAsyncHandler<ContentTypeSavingNotification>,
INotificationAsyncHandler<ContentTypeSavedNotification>
{
private const string NotificationStateKey =
"Umbraco.Cms.Core.Handlers.WarnDocumentTypeElementSwitchNotificationHandler";
private readonly IEventMessagesFactory _eventMessagesFactory;
private readonly IContentTypeService _contentTypeService;
private readonly IElementSwitchValidator _elementSwitchValidator;
public WarnDocumentTypeElementSwitchNotificationHandler(
IEventMessagesFactory eventMessagesFactory,
IContentTypeService contentTypeService,
IElementSwitchValidator elementSwitchValidator)
{
_eventMessagesFactory = eventMessagesFactory;
_contentTypeService = contentTypeService;
_elementSwitchValidator = elementSwitchValidator;
}
// To figure out whether a warning should be generated, we need both the state before and after saving
public async Task HandleAsync(ContentTypeSavingNotification notification, CancellationToken cancellationToken)
{
IEnumerable<Guid> updatedKeys = notification.SavedEntities
.Where(e => e.HasIdentity)
.Select(e => e.Key);
IEnumerable<IContentType> persistedItems = _contentTypeService.GetAll(updatedKeys);
var stateInformation = persistedItems
.ToDictionary(
contentType => contentType.Key,
contentType => new DocumentTypeElementSwitchInformation { WasElement = contentType.IsElement });
notification.State[NotificationStateKey] = stateInformation;
}
public async Task HandleAsync(ContentTypeSavedNotification notification, CancellationToken cancellationToken)
{
if (notification.State[NotificationStateKey] is not Dictionary<Guid, DocumentTypeElementSwitchInformation>
stateInformation)
{
return;
}
EventMessages eventMessages = _eventMessagesFactory.Get();
foreach (IContentType savedDocumentType in notification.SavedEntities)
{
if (stateInformation.ContainsKey(savedDocumentType.Key) is false)
{
continue;
}
DocumentTypeElementSwitchInformation state = stateInformation[savedDocumentType.Key];
if (state.WasElement == savedDocumentType.IsElement)
{
// no change
continue;
}
await WarnIfAncestorsAreMisaligned(savedDocumentType, eventMessages);
await WarnIfDescendantsAreMisaligned(savedDocumentType, eventMessages);
}
}
private async Task WarnIfAncestorsAreMisaligned(IContentType contentType, EventMessages eventMessages)
{
if (await _elementSwitchValidator.AncestorsAreAlignedAsync(contentType) == false)
{
// todo update this message when the format has been agreed upon on with the client
eventMessages.Add(new EventMessage(
"DocumentType saved",
"One or more ancestors have a mismatching element flag",
EventMessageType.Warning));
}
}
private async Task WarnIfDescendantsAreMisaligned(IContentType contentType, EventMessages eventMessages)
{
if (await _elementSwitchValidator.DescendantsAreAlignedAsync(contentType) == false)
{
// todo update this message when the format has been agreed upon on with the client
eventMessages.Add(new EventMessage(
"DocumentType saved",
"One or more descendants have a mismatching element flag",
EventMessageType.Warning));
}
}
private class DocumentTypeElementSwitchInformation
{
public bool WasElement { get; set; }
}
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.HealthChecks.Checks.LiveEnvironment;
/// <summary>
/// Health check for the recommended production configuration for the runtime mode.
/// </summary>
[HealthCheck(
"8E31E5C9-7A1D-4ACB-A3A8-6495F3EDB932",
"Runtime Mode",
Description = "The Production Runtime Mode disables development features and checks that settings are configured optimally for production.",
Group = "Live Environment")]
public class RuntimeModeCheck : AbstractSettingsCheck
{
private readonly IOptionsMonitor<RuntimeSettings> _runtimeSettings;
/// <summary>
/// Initializes a new instance of the <see cref="RuntimeModeCheck" /> class.
/// </summary>
public RuntimeModeCheck(ILocalizedTextService textService, IOptionsMonitor<RuntimeSettings> runtimeSettings)
: base(textService) =>
_runtimeSettings = runtimeSettings;
/// <inheritdoc />
public override string ItemPath => Constants.Configuration.ConfigRuntimeMode;
/// <inheritdoc />
public override ValueComparisonType ValueComparisonType => ValueComparisonType.ShouldEqual;
/// <inheritdoc />
public override IEnumerable<AcceptableConfiguration> Values => new List<AcceptableConfiguration>
{
new() { IsRecommended = true, Value = RuntimeMode.Production.ToString() },
};
/// <inheritdoc />
public override string CurrentValue => _runtimeSettings.CurrentValue.Mode.ToString();
/// <inheritdoc />
public override string CheckSuccessMessage => LocalizedTextService.Localize("healthcheck", "runtimeModeCheckSuccessMessage");
/// <inheritdoc />
public override string CheckErrorMessage => LocalizedTextService.Localize("healthcheck", "runtimeModeCheckErrorMessage");
/// <inheritdoc />
public override string ReadMoreLink => Constants.HealthChecks.DocumentationLinks.LiveEnvironment.RuntimeModeCheck;
}

View File

@@ -27,7 +27,6 @@ public interface IDataValueEditor
/// </summary>
bool SupportsReadOnly => false;
/// <summary>
/// Gets the validators to use to validate the edited value.
/// </summary>
@@ -75,4 +74,6 @@ public interface IDataValueEditor
XNode ConvertDbToXml(IPropertyType propertyType, object value);
string ConvertDbToString(IPropertyType propertyType, object? value);
IEnumerable<Guid> ConfiguredElementTypeKeys() => Enumerable.Empty<Guid>();
}

View File

@@ -9,7 +9,7 @@ namespace Umbraco.Cms.Core.PropertyEditors;
public class BlockListConfiguration
{
[ConfigurationField("blocks")]
public BlockConfiguration[] Blocks { get; set; } = null!;
public BlockConfiguration[] Blocks { get; set; } = Array.Empty<BlockConfiguration>();
[ConfigurationField("validationLimit")]
public NumberRange ValidationLimit { get; set; } = new();

View File

@@ -75,6 +75,10 @@ public class DataEditor : IDataEditor
[DataMember(Name = "supportsReadOnly", IsRequired = true)]
public bool SupportsReadOnly { get; set; }
// Adding a virtual method that wraps the default implementation allows derived classes
// to override the default implementation without having to explicitly inherit the interface.
public virtual bool SupportsConfigurableElements => false;
/// <inheritdoc />
[IgnoreDataMember]
public bool IsDeprecated { get; }

View File

@@ -356,6 +356,10 @@ public class DataValueEditor : IDataValueEditor
}
}
// Adding a virtual method that wraps the default implementation allows derived classes
// to override the default implementation without having to explicitly inherit the interface.
public virtual IEnumerable<Guid> ConfiguredElementTypeKeys() => Enumerable.Empty<Guid>();
/// <summary>
/// Used to try to convert the string value to the correct CLR type based on the <see cref="ValueType" /> specified for
/// this value editor.

View File

@@ -16,6 +16,8 @@ public interface IDataEditor : IDiscoverable
bool SupportsReadOnly => false;
bool SupportsConfigurableElements => false;
/// <summary>
/// Gets a value indicating whether the editor is deprecated.
/// </summary>

View File

@@ -6,7 +6,7 @@ namespace Umbraco.Cms.Core.PropertyEditors;
public class RichTextConfiguration : IIgnoreUserStartNodesConfig
{
[ConfigurationField("blocks")]
public RichTextBlockConfiguration[]? Blocks { get; set; } = null!;
public RichTextBlockConfiguration[]? Blocks { get; set; } = Array.Empty<RichTextBlockConfiguration>();
[ConfigurationField("mediaParentId")]
public Guid? MediaParentId { get; set; }

View File

@@ -20,23 +20,19 @@ public class DatePickerValueConverter : PropertyValueConverterBase
internal static DateTime ParseDateTimeValue(object? source)
{
if (source == null)
if (source is null)
{
return DateTime.MinValue;
}
// in XML a DateTime is: string - format "yyyy-MM-ddTHH:mm:ss"
// Actually, not always sometimes it is formatted in UTC style with 'Z' suffixed on the end but that is due to this bug:
// http://issues.umbraco.org/issue/U4-4145, http://issues.umbraco.org/issue/U4-3894
// We should just be using TryConvertTo instead.
if (source is string sourceString)
if (source is DateTime dateTimeValue)
{
Attempt<DateTime> attempt = sourceString.TryConvertTo<DateTime>();
return attempt.Success == false ? DateTime.MinValue : attempt.Result;
return dateTimeValue;
}
// in the database a DateTime is: DateTime
// default value is: DateTime.MinValue
return source is DateTime dateTimeValue ? dateTimeValue : DateTime.MinValue;
Attempt<DateTime> attempt = source.TryConvertTo<DateTime>();
return attempt.Success
? attempt.Result
: DateTime.MinValue;
}
}

View File

@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using System.Security.Principal;
using Umbraco.Cms.Core.Models.Membership;
@@ -20,8 +21,14 @@ internal sealed class AuthorizationHelper : IAuthorizationHelper
/// <inheritdoc/>
public IUser GetUmbracoUser(IPrincipal currentUser)
=> TryGetUmbracoUser(currentUser, out IUser? user)
? user
: throw new InvalidOperationException($"Could not obtain an {nameof(IUser)} instance from {nameof(IPrincipal)}");
/// <inheritdoc/>
public bool TryGetUmbracoUser(IPrincipal currentUser, [NotNullWhen(true)] out IUser? user)
{
IUser? user = null;
user = null;
ClaimsIdentity? umbIdentity = currentUser.GetUmbracoIdentity();
Guid? currentUserKey = umbIdentity?.GetUserKey();
@@ -38,12 +45,6 @@ internal sealed class AuthorizationHelper : IAuthorizationHelper
user = _userService.GetAsync(currentUserKey.Value).GetAwaiter().GetResult();
}
if (user is null)
{
throw new InvalidOperationException(
$"Could not obtain an {nameof(IUser)} instance from {nameof(IPrincipal)}");
}
return user;
return user is not null;
}
}

View File

@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Principal;
using Umbraco.Cms.Core.Models.Membership;
@@ -9,11 +10,31 @@ namespace Umbraco.Cms.Core.Security.Authorization;
public interface IAuthorizationHelper
{
/// <summary>
/// Converts an <see cref="IUser" /> into <see cref="IPrincipal" />.
/// Converts an <see cref="IPrincipal" /> into an <see cref="IUser" />.
/// </summary>
/// <param name="currentUser">The current user's principal.</param>
/// <returns>
/// <see cref="IUser" />.
/// </returns>
IUser GetUmbracoUser(IPrincipal currentUser);
/// <summary>
/// Attempts to convert an <see cref="IPrincipal" /> into an <see cref="IUser" />.
/// </summary>
/// <param name="currentUser">The current user's principal.</param>
/// <param name="user">The resulting <see cref="IUser" />, if the conversion is successful.</param>
/// <returns>True if the conversion is successful, false otherwise</returns>
bool TryGetUmbracoUser(IPrincipal currentUser, [NotNullWhen(true)] out IUser? user)
{
try
{
user = GetUmbracoUser(currentUser);
return true;
}
catch
{
user = null;
return false;
}
}
}

View File

@@ -1,6 +1,9 @@
using Umbraco.Cms.Core.Models;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.ContentTypeEditing;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
@@ -12,8 +15,24 @@ namespace Umbraco.Cms.Core.Services.ContentTypeEditing;
internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<IContentType, IContentTypeService, ContentTypePropertyTypeModel, ContentTypePropertyContainerModel>, IContentTypeEditingService
{
private readonly ITemplateService _templateService;
private readonly IElementSwitchValidator _elementSwitchValidator;
private readonly IContentTypeService _contentTypeService;
public ContentTypeEditingService(
IContentTypeService contentTypeService,
ITemplateService templateService,
IDataTypeService dataTypeService,
IEntityService entityService,
IShortStringHelper shortStringHelper,
IElementSwitchValidator elementSwitchValidator)
: base(contentTypeService, contentTypeService, dataTypeService, entityService, shortStringHelper)
{
_contentTypeService = contentTypeService;
_templateService = templateService;
_elementSwitchValidator = elementSwitchValidator;
}
[Obsolete("Use the constructor that is not marked obsolete, will be removed in v16")]
public ContentTypeEditingService(
IContentTypeService contentTypeService,
ITemplateService templateService,
@@ -24,6 +43,7 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<
{
_contentTypeService = contentTypeService;
_templateService = templateService;
_elementSwitchValidator = StaticServiceProvider.Instance.GetRequiredService<IElementSwitchValidator>();
}
public async Task<Attempt<IContentType?, ContentTypeOperationStatus>> CreateAsync(ContentTypeCreateModel model, Guid userKey)
@@ -52,13 +72,20 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<
public async Task<Attempt<IContentType?, ContentTypeOperationStatus>> UpdateAsync(IContentType contentType, ContentTypeUpdateModel model, Guid userKey)
{
Attempt<IContentType?, ContentTypeOperationStatus> result = await ValidateAndMapForUpdateAsync(contentType, model);
if (result.Success is false)
// this needs to happen before the base call as that one is not a pure function
ContentTypeOperationStatus elementValidationStatus = await ValidateElementStatusForUpdateAsync(contentType, model);
if (elementValidationStatus is not ContentTypeOperationStatus.Success)
{
return result;
return Attempt<IContentType?, ContentTypeOperationStatus>.Fail(elementValidationStatus);
}
contentType = result.Result ?? throw new InvalidOperationException($"{nameof(ValidateAndMapForUpdateAsync)} succeeded but did not yield any result");
Attempt<IContentType?, ContentTypeOperationStatus> baseValidationAttempt = await ValidateAndMapForUpdateAsync(contentType, model);
if (baseValidationAttempt.Success is false)
{
return baseValidationAttempt;
}
contentType = baseValidationAttempt.Result ?? throw new InvalidOperationException($"{nameof(ValidateAndMapForUpdateAsync)} succeeded but did not yield any result");
UpdateHistoryCleanup(contentType, model);
UpdateTemplates(contentType, model);
@@ -77,6 +104,13 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<
bool isElement) =>
await FindAvailableCompositionsAsync(key, currentCompositeKeys, currentPropertyAliases, isElement);
protected override async Task<ContentTypeOperationStatus> AdditionalCreateValidationAsync(
ContentTypeEditingModelBase<ContentTypePropertyTypeModel, ContentTypePropertyContainerModel> model)
{
// validate if the parent documentType (if set) has the same element status as the documentType being created
return await ValidateCreateParentElementStatusAsync(model);
}
// update content type history clean-up
private void UpdateHistoryCleanup(IContentType contentType, ContentTypeModelBase model)
{
@@ -100,6 +134,48 @@ internal sealed class ContentTypeEditingService : ContentTypeEditingServiceBase<
contentType.SetDefaultTemplate(allowedTemplates.FirstOrDefault(t => t.Key == model.DefaultTemplateKey));
}
private async Task<ContentTypeOperationStatus> ValidateElementStatusForUpdateAsync(IContentTypeBase contentType, ContentTypeModelBase model)
{
// no change, ignore rest of validation
if (contentType.IsElement == model.IsElement)
{
return ContentTypeOperationStatus.Success;
}
// this method should only contain blocking validation, warnings are handled by WarnDocumentTypeElementSwitchNotificationHandler
// => check whether the element was used in a block structure prior to updating
if (model.IsElement is false)
{
return await _elementSwitchValidator.ElementToDocumentNotUsedInBlockStructuresAsync(contentType)
? ContentTypeOperationStatus.Success
: ContentTypeOperationStatus.InvalidElementFlagElementIsUsedInPropertyEditorConfiguration;
}
return await _elementSwitchValidator.DocumentToElementHasNoContentAsync(contentType)
? ContentTypeOperationStatus.Success
: ContentTypeOperationStatus.InvalidElementFlagDocumentHasContent;
}
/// <summary>
/// Should be called after it has been established that the composition list is in a valid state and the (composition) parent exists
/// </summary>
private async Task<ContentTypeOperationStatus> ValidateCreateParentElementStatusAsync(
ContentTypeEditingModelBase<ContentTypePropertyTypeModel, ContentTypePropertyContainerModel> model)
{
Guid? parentId = model.Compositions
.SingleOrDefault(composition => composition.CompositionType == CompositionType.Inheritance)?.Key;
if (parentId is null)
{
return ContentTypeOperationStatus.Success;
}
IContentType? parent = await _contentTypeService.GetAsync(parentId.Value);
return parent!.IsElement == model.IsElement
? ContentTypeOperationStatus.Success
: ContentTypeOperationStatus.InvalidElementFlagComparedToParent;
}
protected override IContentType CreateContentType(IShortStringHelper shortStringHelper, int parentId)
=> new ContentType(shortStringHelper, parentId);

View File

@@ -91,6 +91,8 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
return Attempt.FailWithStatus<TContentType?, ContentTypeOperationStatus>(operationStatus, null);
}
await AdditionalCreateValidationAsync(model);
// get the ID of the parent to create the content type under (we already validated that it exists)
var parentId = GetParentId(model, containerKey) ?? throw new ArgumentException("Parent ID could not be found", nameof(model));
TContentType contentType = CreateContentType(_shortStringHelper, parentId);
@@ -137,6 +139,10 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
return Attempt.SucceedWithStatus<TContentType?, ContentTypeOperationStatus>(ContentTypeOperationStatus.Success, contentType);
}
protected virtual async Task<ContentTypeOperationStatus> AdditionalCreateValidationAsync(
ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)
=> await Task.FromResult(ContentTypeOperationStatus.Success);
#region Sanitization
private void SanitizeModelAliases(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)

View File

@@ -0,0 +1,66 @@
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
namespace Umbraco.Cms.Core.Services.ContentTypeEditing;
public class ElementSwitchValidator : IElementSwitchValidator
{
private readonly IContentTypeService _contentTypeService;
private readonly PropertyEditorCollection _propertyEditorCollection;
private readonly IDataTypeService _dataTypeService;
public ElementSwitchValidator(
IContentTypeService contentTypeService,
PropertyEditorCollection propertyEditorCollection,
IDataTypeService dataTypeService)
{
_contentTypeService = contentTypeService;
_propertyEditorCollection = propertyEditorCollection;
_dataTypeService = dataTypeService;
}
public async Task<bool> AncestorsAreAlignedAsync(IContentType contentType)
{
// this call does not return the system roots
var ancestorIds = contentType.AncestorIds();
if (ancestorIds.Length == 0)
{
// if there are no ancestors, validation passes
return true;
}
// if there are any ancestors where IsElement is different from the contentType, the validation fails
return await Task.FromResult(_contentTypeService.GetAll(ancestorIds)
.Any(ancestor => ancestor.IsElement != contentType.IsElement) is false);
}
public async Task<bool> DescendantsAreAlignedAsync(IContentType contentType)
{
IEnumerable<IContentType> descendants = _contentTypeService.GetDescendants(contentType.Id, false);
// if there are any descendants where IsElement is different from the contentType, the validation fails
return await Task.FromResult(descendants.Any(descendant => descendant.IsElement != contentType.IsElement) is false);
}
public async Task<bool> ElementToDocumentNotUsedInBlockStructuresAsync(IContentTypeBase contentType)
{
// get all propertyEditors that support block usage
IDataEditor[] editors = _propertyEditorCollection.Where(pe => pe.SupportsConfigurableElements).ToArray();
var blockEditorAliases = editors.Select(pe => pe.Alias).ToArray();
// get all dataTypes that are based on those propertyEditors
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetByEditorAliasAsync(blockEditorAliases);
// if any dataType has a configuration where this element is selected as a possible block, the validation fails.
return dataTypes.Any(dataType =>
editors.First(editor => editor.Alias == dataType.EditorAlias)
.GetValueEditor(dataType.ConfigurationObject)
.ConfiguredElementTypeKeys().Contains(contentType.Key)) is false;
}
public async Task<bool> DocumentToElementHasNoContentAsync(IContentTypeBase contentType) =>
// if any content for the content type exists, the validation fails.
await Task.FromResult(_contentTypeService.HasContentNodes(contentType.Id) is false);
}

View File

@@ -0,0 +1,14 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services.ContentTypeEditing;
public interface IElementSwitchValidator
{
Task<bool> AncestorsAreAlignedAsync(IContentType contentType);
Task<bool> DescendantsAreAlignedAsync(IContentType contentType);
Task<bool> ElementToDocumentNotUsedInBlockStructuresAsync(IContentTypeBase contentType);
Task<bool> DocumentToElementHasNoContentAsync(IContentTypeBase contentType);
}

View File

@@ -331,6 +331,16 @@ namespace Umbraco.Cms.Core.Services.Implement
return Task.FromResult(dataTypes);
}
/// <inheritdoc />
public async Task<IEnumerable<IDataType>> GetByEditorAliasAsync(string[] propertyEditorAlias)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
IQuery<IDataType> query = Query<IDataType>().Where(x => propertyEditorAlias.Contains(x.EditorAlias));
IEnumerable<IDataType> dataTypes = _dataTypeRepository.Get(query).ToArray();
ConvertMissingEditorsOfDataTypesToLabels(dataTypes);
return await Task.FromResult(dataTypes);
}
/// <inheritdoc />
public Task<IEnumerable<IDataType>> GetByEditorUiAlias(string editorUiAlias)
{

View File

@@ -6,6 +6,7 @@ using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Extensions;
@@ -201,6 +202,11 @@ public class DomainService : RepositoryService, IDomainService
foreach (DomainModel domainModel in updateModel.Domains)
{
domainModel.DomainName = domainModel.DomainName.ToLowerInvariant();
if(Uri.IsWellFormedUriString(domainModel.DomainName, UriKind.RelativeOrAbsolute) is false)
{
return Attempt.FailWithStatus(DomainOperationStatus.InvalidDomainName, new DomainUpdateResult());
}
}
// make sure we're not attempting to assign duplicate domains

View File

@@ -240,4 +240,11 @@ public interface IDataTypeService : IService
/// <param name="dataType">The data type whose configuration to validate.</param>
/// <returns>One or more <see cref="ValidationResult"/> if the configuration data is invalid, an empty collection otherwise.</returns>
IEnumerable<ValidationResult> ValidateConfigurationData(IDataType dataType);
/// <summary>
/// Gets all <see cref="IDataType" /> for a set of property editors
/// </summary>
/// <param name="propertyEditorAlias">Aliases of the property editors</param>
/// <returns>Collection of <see cref="IDataType" /> configured for the property editors</returns>
Task<IEnumerable<IDataType>> GetByEditorAliasAsync(string[] propertyEditorAlias);
}

View File

@@ -1002,7 +1002,6 @@ namespace Umbraco.Cms.Core.Services
MoveToRecycleBinEventInfo<IMedia>[] moveInfo = moves.Select(x => new MoveToRecycleBinEventInfo<IMedia>(x.Item1, x.Item2)).ToArray();
scope.Notifications.Publish(new MediaMovedToRecycleBinNotification(moveInfo, messages).WithStateFrom(movingToRecycleBinNotification));
Audit(AuditType.Move, userId, media.Id, "Move Media to recycle bin");
scope.Complete();
}

View File

@@ -21,4 +21,7 @@ public enum ContentTypeOperationStatus
NotFound,
NotAllowed,
CancelledByNotification,
InvalidElementFlagDocumentHasContent,
InvalidElementFlagElementIsUsedInPropertyEditorConfiguration,
InvalidElementFlagComparedToParent,
}

View File

@@ -7,5 +7,6 @@ public enum DomainOperationStatus
ContentNotFound,
LanguageNotFound,
DuplicateDomainName,
ConflictingDomainName
ConflictingDomainName,
InvalidDomainName
}

View File

@@ -35,11 +35,11 @@ public sealed class HtmlLocalLinkParser
public IEnumerable<Udi?> FindUdisFromLocalLinks(string text)
{
foreach ((var intId, GuidUdi? udi, var tagValue) in FindLocalLinkIds(text))
foreach (LocalLinkTag tagData in FindLocalLinkIds(text))
{
if (udi is not null)
if (tagData.Udi is not null)
{
yield return udi; // In v8, we only care abuot UDIs
yield return tagData.Udi; // In v8, we only care about UDIs
}
}
}
@@ -80,38 +80,41 @@ public sealed class HtmlLocalLinkParser
throw new InvalidOperationException("Could not parse internal links, there is no current UmbracoContext");
}
foreach ((var intId, GuidUdi? udi, var tagValue) in FindLocalLinkIds(text))
foreach (LocalLinkTag tagData in FindLocalLinkIds(text))
{
if (udi is not null)
if (tagData.Udi is not null)
{
var newLink = "#";
if (udi?.EntityType == Constants.UdiEntityType.Document)
if (tagData.Udi?.EntityType == Constants.UdiEntityType.Document)
{
newLink = _publishedUrlProvider.GetUrl(udi.Guid);
newLink = _publishedUrlProvider.GetUrl(tagData.Udi.Guid);
}
else if (udi?.EntityType == Constants.UdiEntityType.Media)
else if (tagData.Udi?.EntityType == Constants.UdiEntityType.Media)
{
newLink = _publishedUrlProvider.GetMediaUrl(udi.Guid);
newLink = _publishedUrlProvider.GetMediaUrl(tagData.Udi.Guid);
}
if (newLink == null)
{
newLink = "#";
}
text = text.Replace(tagValue, "href=\"" + newLink);
text = StripTypeAttributeFromTag(text, tagData.Udi!.EntityType);
text = text.Replace(tagData.TagHref, "href=\"" + newLink);
}
else if (intId.HasValue)
else if (tagData.IntId.HasValue)
{
var newLink = _publishedUrlProvider.GetUrl(intId.Value);
text = text.Replace(tagValue, "href=\"" + newLink);
var newLink = _publishedUrlProvider.GetUrl(tagData.IntId.Value);
text = text.Replace(tagData.TagHref, "href=\"" + newLink);
}
}
return text;
}
private IEnumerable<(int? intId, GuidUdi? udi, string tagValue)> FindLocalLinkIds(string text)
// under normal circumstances, the type attribute is preceded by a space
// to cover the rare occasion where it isn't, we first replace with a a space and then without.
private string StripTypeAttributeFromTag(string tag, string type) =>
tag.Replace($" type=\"{type}\"", string.Empty)
.Replace($"type=\"{type}\"", string.Empty);
private IEnumerable<LocalLinkTag> FindLocalLinkIds(string text)
{
MatchCollection localLinkTagMatches = LocalLinkTagPattern.Matches(text);
foreach (Match linkTag in localLinkTagMatches)
@@ -126,18 +129,22 @@ public sealed class HtmlLocalLinkParser
continue;
}
yield return (null, new GuidUdi(linkTag.Groups["type"].Value, guid), linkTag.Groups["locallink"].Value);
yield return new LocalLinkTag(
null,
new GuidUdi(linkTag.Groups["type"].Value, guid),
linkTag.Groups["locallink"].Value,
linkTag.Value);
}
// also return legacy results for values that have not been migrated
foreach ((int? intId, GuidUdi? udi, string tagValue) legacyResult in FindLegacyLocalLinkIds(text))
foreach (LocalLinkTag legacyResult in FindLegacyLocalLinkIds(text))
{
yield return legacyResult;
}
}
// todo remove at some point?
private IEnumerable<(int? intId, GuidUdi? udi, string tagValue)> FindLegacyLocalLinkIds(string text)
private IEnumerable<LocalLinkTag> FindLegacyLocalLinkIds(string text)
{
// Parse internal links
MatchCollection tags = LocalLinkPattern.Matches(text);
@@ -153,15 +160,41 @@ public sealed class HtmlLocalLinkParser
var guidUdi = udi as GuidUdi;
if (guidUdi is not null)
{
yield return (null, guidUdi, tag.Value);
yield return new LocalLinkTag(null, guidUdi, tag.Value, null);
}
}
if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId))
{
yield return (intId, null, tag.Value);
yield return new LocalLinkTag (intId, null, tag.Value, null);
}
}
}
}
private class LocalLinkTag
{
public LocalLinkTag(int? intId, GuidUdi? udi, string tagHref)
{
IntId = intId;
Udi = udi;
TagHref = tagHref;
}
public LocalLinkTag(int? intId, GuidUdi? udi, string tagHref, string? fullTag)
{
IntId = intId;
Udi = udi;
TagHref = tagHref;
FullTag = fullTag;
}
public int? IntId { get; }
public GuidUdi? Udi { get; }
public string TagHref { get; }
public string? FullTag { get; }
}
}

View File

@@ -75,17 +75,25 @@ public class UdiRange
public static bool operator !=(UdiRange range1, UdiRange range2) => !(range1 == range2);
public static UdiRange Parse(string s)
public static UdiRange Parse(string value)
{
if (Uri.IsWellFormedUriString(s, UriKind.Absolute) == false
|| Uri.TryCreate(s, UriKind.Absolute, out Uri? uri) == false)
if (Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) is false ||
uri.IsWellFormedOriginalString() is false)
{
// if (tryParse) return false;
throw new FormatException(string.Format("String \"{0}\" is not a valid udi range.", s));
throw new FormatException($"String \"{value}\" is not a valid UDI range.");
}
Uri udiUri = uri.Query == string.Empty ? uri : new UriBuilder(uri) { Query = string.Empty }.Uri;
return new UdiRange(Udi.Create(udiUri), uri.Query.TrimStart(Constants.CharArrays.QuestionMark));
// Remove selector from UDI
Uri udiUri = string.IsNullOrEmpty(uri.Query)
? uri
: new UriBuilder(uri) { Query = string.Empty }.Uri;
var udi = Udi.Create(udiUri);
// Only specify selector if query string is not empty
return string.IsNullOrEmpty(uri.Query)
? new UdiRange(udi)
: new UdiRange(udi, uri.Query.TrimStart(Constants.CharArrays.QuestionMark));
}
public override string ToString() => _uriValue.ToString();

View File

@@ -60,9 +60,18 @@ internal sealed class ApiRichTextMarkupParser : ApiRichTextParserBase, IApiRichT
link.SetAttributeValue("href", route.Path);
link.SetAttributeValue("data-start-item-path", route.StartItem.Path);
link.SetAttributeValue("data-start-item-id", route.StartItem.Id.ToString("D"));
link.Attributes["type"]?.Remove();
},
url => link.SetAttributeValue("href", url),
() => link.Attributes.Remove("href"));
url =>
{
link.SetAttributeValue("href", url);
link.Attributes["type"]?.Remove();
},
() =>
{
link.Attributes.Remove("href");
link.Attributes["type"]?.Remove();
});
}
}

View File

@@ -407,6 +407,11 @@ public static partial class UmbracoBuilderExtensions
builder
.AddNotificationHandler<ContentPublishedNotification, AddDomainWarningsWhenPublishingNotificationHandler>();
// Handlers for save warnings
builder
.AddNotificationAsyncHandler<ContentTypeSavingNotification, WarnDocumentTypeElementSwitchNotificationHandler>()
.AddNotificationAsyncHandler<ContentTypeSavedNotification, WarnDocumentTypeElementSwitchNotificationHandler>();
return builder;
}

View File

@@ -2,12 +2,14 @@
// See LICENSE for more details.
using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
using IScope = Umbraco.Cms.Infrastructure.Scoping.IScope;
namespace Umbraco.Cms.Core.Events;
@@ -21,21 +23,35 @@ public sealed class RelateOnTrashNotificationHandler :
private readonly IAuditService _auditService;
private readonly IEntityService _entityService;
private readonly IRelationService _relationService;
private readonly IScopeProvider _scopeProvider;
private readonly ICoreScopeProvider _scopeProvider;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly ILocalizedTextService _textService;
[Obsolete("Use the new constructor instead, will be removed in V16")]
public RelateOnTrashNotificationHandler(
IRelationService relationService,
IEntityService entityService,
ILocalizedTextService textService,
IAuditService auditService,
IScopeProvider scopeProvider)
: this(relationService, entityService, textService, auditService, scopeProvider, StaticServiceProvider.Instance.GetRequiredService<IBackOfficeSecurityAccessor>())
{
}
public RelateOnTrashNotificationHandler(
IRelationService relationService,
IEntityService entityService,
ILocalizedTextService textService,
IAuditService auditService,
IScopeProvider scopeProvider,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_relationService = relationService;
_entityService = entityService;
_textService = textService;
_auditService = auditService;
_scopeProvider = scopeProvider;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
public void Handle(ContentMovedNotification notification)
@@ -56,7 +72,7 @@ public sealed class RelateOnTrashNotificationHandler :
public void Handle(ContentMovedToRecycleBinNotification notification)
{
using (IScope scope = _scopeProvider.CreateScope())
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
{
const string relationTypeAlias = Constants.Conventions.RelationTypes.RelateParentDocumentOnDeleteAlias;
IRelationType? relationType = _relationService.GetRelationTypeByAlias(relationTypeAlias);
@@ -90,7 +106,7 @@ public sealed class RelateOnTrashNotificationHandler :
_auditService.Add(
AuditType.Delete,
item.Entity.WriterId,
_backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? item.Entity.WriterId,
item.Entity.Id,
UmbracoObjectTypes.Document.GetName(),
string.Format(_textService.Localize("recycleBin", "contentTrashed"), item.Entity.Id, originalParentId));
@@ -118,7 +134,7 @@ public sealed class RelateOnTrashNotificationHandler :
public void Handle(MediaMovedToRecycleBinNotification notification)
{
using (IScope scope = _scopeProvider.CreateScope())
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
{
const string relationTypeAlias = Constants.Conventions.RelationTypes.RelateParentMediaFolderOnDeleteAlias;
IRelationType? relationType = _relationService.GetRelationTypeByAlias(relationTypeAlias);
@@ -150,7 +166,7 @@ public sealed class RelateOnTrashNotificationHandler :
_relationService.Save(relation);
_auditService.Add(
AuditType.Delete,
item.Entity.CreatorId,
_backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? item.Entity.WriterId,
item.Entity.Id,
UmbracoObjectTypes.Media.GetName(),
string.Format(_textService.Localize("recycleBin", "mediaTrashed"), item.Entity.Id, originalParentId));

View File

@@ -129,7 +129,7 @@ public abstract class RecurringHostedServiceBase : IHostedService, IDisposable
/// Executes the task.
/// </summary>
/// <param name="state">The task state.</param>
public async void ExecuteAsync(object? state)
public virtual async void ExecuteAsync(object? state)
{
try
{

View File

@@ -51,7 +51,6 @@ public class FilePermissionHelper : IFilePermissionHelper
{
hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.Bin),
hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.Umbraco),
hostingEnvironment.MapPathWebRoot(_globalSettings.UmbracoPath),
hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.Packages),
};
}

View File

@@ -89,6 +89,9 @@ public class UmbracoPlan : MigrationPlan
To<V_14_1_0.MigrateRichTextConfiguration>("{FEF2DAF4-5408-4636-BB0E-B8798DF8F095}");
To<V_14_1_0.MigrateOldRichTextSeedConfiguration>("{A385C5DF-48DC-46B4-A742-D5BB846483BC}");
// To 14.2.0
To<V_14_2_0.AddMissingDateTimeConfiguration>("{20ED404C-6FF9-4F91-8AC9-2B298E0002EB}");
// To 15.0.0
To<V_15_0_0.AddUserClientId>("{7F4F31D8-DD71-4F0D-93FC-2690A924D84B}");
To<V_15_0_0.AddTypeToUser>("{1A8835EF-F8AB-4472-B4D8-D75B7C164022}");

View File

@@ -0,0 +1,50 @@
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_14_2_0;
public class AddMissingDateTimeConfiguration : MigrationBase
{
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
public AddMissingDateTimeConfiguration(IMigrationContext context, IConfigurationEditorJsonSerializer configurationEditorJsonSerializer)
: base(context)
=> _configurationEditorJsonSerializer = configurationEditorJsonSerializer;
protected override void Migrate()
{
Sql<ISqlContext> sql = Sql()
.Select<DataTypeDto>()
.From<DataTypeDto>()
.Where<DataTypeDto>(dto =>
dto.NodeId == Constants.DataTypes.DateTime
&& dto.EditorAlias.Equals(Constants.PropertyEditors.Aliases.DateTime));
DataTypeDto? dataTypeDto = Database.FirstOrDefault<DataTypeDto>(sql);
if (dataTypeDto is null)
{
return;
}
Dictionary<string, object> configurationData = dataTypeDto.Configuration.IsNullOrWhiteSpace()
? new Dictionary<string, object>()
: _configurationEditorJsonSerializer
.Deserialize<Dictionary<string, object?>>(dataTypeDto.Configuration)?
.Where(item => item.Value is not null)
.ToDictionary(item => item.Key, item => item.Value!)
?? new Dictionary<string, object>();
// only proceed with the migration if the data-type has no format assigned
if (configurationData.TryAdd("format", "YYYY-MM-DD HH:mm:ss") is false)
{
return;
}
dataTypeDto.Configuration = _configurationEditorJsonSerializer.Serialize(configurationData);
Database.Update(dataTypeDto);
}
}

View File

@@ -13,7 +13,7 @@ using Umbraco.Cms.Core.Strings;
namespace Umbraco.Cms.Core.PropertyEditors;
internal abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockValuePropertyValueEditorBase<TValue, TLayout>
public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockValuePropertyValueEditorBase<TValue, TLayout>
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{

View File

@@ -7,7 +7,7 @@ using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.PropertyEditors;
internal class BlockEditorValidator<TValue, TLayout> : BlockEditorValidatorBase<TValue, TLayout>
public class BlockEditorValidator<TValue, TLayout> : BlockEditorValidatorBase<TValue, TLayout>
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{

View File

@@ -6,7 +6,7 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
internal abstract class BlockEditorValidatorBase<TValue, TLayout> : ComplexEditorValidator
public abstract class BlockEditorValidatorBase<TValue, TLayout> : ComplexEditorValidator
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{

View File

@@ -10,9 +10,9 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Used to deserialize json values and clean up any values based on the existence of element types and layout structure
/// Used to deserialize json values and clean up any values based on the existence of element types and layout structure.
/// </summary>
internal class BlockEditorValues<TValue, TLayout>
public class BlockEditorValues<TValue, TLayout>
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{

View File

@@ -22,6 +22,7 @@ public class BlockGridPropertyEditor : BlockGridPropertyEditorBase
: base(dataValueEditorFactory, blockValuePropertyIndexValueFactory)
=> _ioHelper = ioHelper;
public override bool SupportsConfigurableElements => true;
#region Pre Value Editor

View File

@@ -111,6 +111,12 @@ public abstract class BlockGridPropertyEditorBase : DataEditor
return validationResults;
}
}
public override IEnumerable<Guid> ConfiguredElementTypeKeys()
{
var configuration = ConfigurationObject as BlockGridConfiguration;
return configuration?.Blocks.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty<Guid>();
}
}
#endregion

View File

@@ -36,6 +36,8 @@ public class BlockListPropertyEditor : BlockListPropertyEditorBase
{
}
public override bool SupportsConfigurableElements => true;
#region Pre Value Editor
protected override IConfigurationEditor CreateConfigurationEditor() =>

View File

@@ -93,6 +93,12 @@ public abstract class BlockListPropertyEditorBase : DataEditor
return ValidateNumberOfBlocks(blockEditorData, validationLimit.Min, validationLimit.Max);
}
}
public override IEnumerable<Guid> ConfiguredElementTypeKeys()
{
var configuration = ConfigurationObject as BlockListConfiguration;
return configuration?.Blocks.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty<Guid>();
}
}
#endregion

View File

@@ -10,7 +10,7 @@ using Umbraco.Cms.Core.Strings;
namespace Umbraco.Cms.Core.PropertyEditors;
internal abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataValueEditor, IDataValueReference, IDataValueTags
public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataValueEditor, IDataValueReference, IDataValueTags
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
@@ -114,6 +114,15 @@ internal abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : Dat
MapBlockItemDataToEditor(property, blockValue.SettingsData);
}
protected IEnumerable<Guid> ConfiguredElementTypeKeys(IBlockConfiguration configuration)
{
yield return configuration.ContentElementTypeKey;
if (configuration.SettingsElementTypeKey is not null)
{
yield return configuration.SettingsElementTypeKey.Value;
}
}
private void MapBlockItemDataToEditor(IProperty property, List<BlockItemData> items)
{
var valEditors = new Dictionary<Guid, IDataValueEditor>();

View File

@@ -47,6 +47,8 @@ public class RichTextPropertyEditor : DataEditor
public override IPropertyIndexValueFactory PropertyIndexValueFactory => _richTextPropertyIndexValueFactory;
public override bool SupportsConfigurableElements => true;
/// <summary>
/// Create a custom value editor
/// </summary>
@@ -238,6 +240,12 @@ public class RichTextPropertyEditor : DataEditor
return RichTextPropertyEditorHelper.SerializeRichTextEditorValue(cleanedUpRichTextEditorValue, _jsonSerializer);
}
public override IEnumerable<Guid> ConfiguredElementTypeKeys()
{
var configuration = ConfigurationObject as RichTextConfiguration;
return configuration?.Blocks?.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty<Guid>();
}
private bool TryParseEditorValue(object? value, [NotNullWhen(true)] out RichTextEditorValue? richTextEditorValue)
=> RichTextPropertyEditorHelper.TryParseRichTextEditorValue(value, _jsonSerializer, _logger, out richTextEditorValue);

View File

@@ -113,7 +113,7 @@ public class IdentityMapDefinition : IMapDefinition
target.PasswordConfig = source.PasswordConfiguration;
target.IsApproved = source.IsApproved;
target.SecurityStamp = source.SecurityStamp;
DateTime? lockedOutUntil = source.LastLockoutDate?.AddMinutes(_securitySettings.UserDefaultLockoutTimeInMinutes);
DateTime? lockedOutUntil = source.LastLockoutDate?.AddMinutes(_securitySettings.MemberDefaultLockoutTimeInMinutes);
target.LockoutEnd = source.IsLockedOut ? (lockedOutUntil ?? DateTime.MaxValue).ToUniversalTime() : null;
target.Comments = source.Comments;
target.LastLockoutDateUtc = source.LastLockoutDate == DateTime.MinValue

View File

@@ -221,10 +221,17 @@ AND cmsContentNu.nodeId IS NULL
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
// Use a more efficient COUNT query
Sql<ISqlContext>? sqlCountQuery = SqlContentSourcesCount()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document));
Sql<ISqlContext>? sqlCount =
SqlContext.Sql("SELECT COUNT(*) FROM (").Append(sqlCountQuery).Append(") npoco_tbl");
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
IEnumerable<ContentSourceDto> dtos = GetContentNodeDtos(sql);
IEnumerable<ContentSourceDto> dtos = GetContentNodeDtos(sql, sqlCount);
foreach (ContentSourceDto row in dtos)
{
@@ -239,10 +246,18 @@ AND cmsContentNu.nodeId IS NULL
.Append(SqlWhereNodeIdX(SqlContext, id))
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
// Use a more efficient COUNT query
Sql<ISqlContext>? sqlCountQuery = SqlContentSourcesCount(SqlContentSourcesSelectUmbracoNodeJoin)
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.Append(SqlWhereNodeIdX(SqlContext, id));
Sql<ISqlContext>? sqlCount =
SqlContext.Sql("SELECT COUNT(*) FROM (").Append(sqlCountQuery).Append(") npoco_tbl");
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
IEnumerable<ContentSourceDto> dtos = GetContentNodeDtos(sql);
IEnumerable<ContentSourceDto> dtos = GetContentNodeDtos(sql, sqlCount);
foreach (ContentSourceDto row in dtos)
{
@@ -262,10 +277,18 @@ AND cmsContentNu.nodeId IS NULL
.WhereIn<ContentDto>(x => x.ContentTypeId, ids)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
// Use a more efficient COUNT query
Sql<ISqlContext> sqlCountQuery = SqlContentSourcesCount()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.WhereIn<ContentDto>(x => x.ContentTypeId, ids);
Sql<ISqlContext>? sqlCount =
SqlContext.Sql("SELECT COUNT(*) FROM (").Append(sqlCountQuery).Append(") npoco_tbl");
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
IEnumerable<ContentSourceDto> dtos = GetContentNodeDtos(sql);
IEnumerable<ContentSourceDto> dtos = GetContentNodeDtos(sql, sqlCount);
foreach (ContentSourceDto row in dtos)
{
@@ -1015,27 +1038,14 @@ WHERE cmsContentNu.nodeId IN (
return dtos;
}
private IEnumerable<ContentSourceDto> GetContentNodeDtos(Sql<ISqlContext> sql)
private IEnumerable<ContentSourceDto> GetContentNodeDtos(Sql<ISqlContext> sql, Sql<ISqlContext> sqlCount)
{
// We need to page here. We don't want to iterate over every single row in one connection cuz this can cause an SQL Timeout.
// We also want to read with a db reader and not load everything into memory, QueryPaged lets us do that.
// QueryPaged is very slow on large sites however, so use fetch if UsePagedSqlQuery is disabled.
IEnumerable<ContentSourceDto> dtos;
if (_nucacheSettings.Value.UsePagedSqlQuery)
{
// Use a more efficient COUNT query
Sql<ISqlContext>? sqlCountQuery = SqlContentSourcesCount()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document));
Sql<ISqlContext>? sqlCount =
SqlContext.Sql("SELECT COUNT(*) FROM (").Append(sqlCountQuery).Append(") npoco_tbl");
dtos = Database.QueryPaged<ContentSourceDto>(_nucacheSettings.Value.SqlPageSize, sql, sqlCount);
}
else
{
dtos = Database.Fetch<ContentSourceDto>(sql);
}
IEnumerable<ContentSourceDto> dtos = _nucacheSettings.Value.UsePagedSqlQuery ?
Database.QueryPaged<ContentSourceDto>(_nucacheSettings.Value.SqlPageSize, sql, sqlCount) :
Database.Fetch<ContentSourceDto>(sql);
return dtos;
}

View File

@@ -43,14 +43,14 @@ public class AspNetCoreHostingEnvironment : IHostingEnvironment
_webHostEnvironment = webHostEnvironment ?? throw new ArgumentNullException(nameof(webHostEnvironment));
_urlProviderMode = _webRoutingSettings.CurrentValue.UrlProviderMode;
SetSiteName(hostingSettings.CurrentValue.SiteName);
SetSiteNameAndDebugMode(hostingSettings.CurrentValue);
// We have to ensure that the OptionsMonitor is an actual options monitor since we have a hack
// where we initially use an OptionsMonitorAdapter, which doesn't implement OnChange.
// See summery of OptionsMonitorAdapter for more information.
if (hostingSettings is OptionsMonitor<HostingSettings>)
{
hostingSettings.OnChange(settings => SetSiteName(settings.SiteName));
hostingSettings.OnChange(settings => SetSiteNameAndDebugMode(settings));
}
ApplicationPhysicalPath = webHostEnvironment.ContentRootPath;
@@ -95,7 +95,7 @@ public class AspNetCoreHostingEnvironment : IHostingEnvironment
_hostingSettings.CurrentValue.ApplicationVirtualPath?.EnsureStartsWith('/') ?? "/";
/// <inheritdoc />
public bool IsDebugMode => _hostingSettings.CurrentValue.Debug;
public bool IsDebugMode { get; private set; }
public string LocalTempPath
{
@@ -188,8 +188,12 @@ public class AspNetCoreHostingEnvironment : IHostingEnvironment
}
}
private void SetSiteName(string? siteName) =>
SiteName = string.IsNullOrWhiteSpace(siteName)
private void SetSiteNameAndDebugMode(HostingSettings hostingSettings)
{
SiteName = string.IsNullOrWhiteSpace(hostingSettings.SiteName)
? _webHostEnvironment.ApplicationName
: siteName;
: hostingSettings.SiteName;
IsDebugMode = hostingSettings.Debug;
}
}

View File

@@ -52,6 +52,7 @@ public static class AuthorizationPolicies
public const string TreeAccessDocumentsOrDocumentTypes = nameof(TreeAccessDocumentsOrDocumentTypes);
public const string TreeAccessMediaOrMediaTypes = nameof(TreeAccessMediaOrMediaTypes);
public const string TreeAccessDictionaryOrTemplates = nameof(TreeAccessDictionaryOrTemplates);
public const string TreeAccessDocumentOrMediaOrContentTypes = nameof(TreeAccessDocumentOrMediaOrContentTypes);
// other
public const string DictionaryPermissionByResource = nameof(DictionaryPermissionByResource);

View File

@@ -3,7 +3,9 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.CreateUmbracoBuilder()
.AddBackOffice()
.AddWebsite()
#if UseDeliveryApi
.AddDeliveryApi()
#endif
.AddComposers()
.Build();
@@ -23,6 +25,9 @@ app.UseUmbraco()
})
.WithEndpoints(u =>
{
/*#if (UmbracoRelease = 'LTS')
u.UseInstallerEndpoints();
#endif */
u.UseBackOfficeEndpoints();
u.UseWebsiteEndpoints();
});

View File

@@ -41,7 +41,7 @@
"KeepUserLoggedIn": false,
"UsernameIsEmail": true,
"HideDisabledUsersInBackoffice": false,
"AllowedUserNameCharacters": "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+\\",
"AllowedUserNameCharacters": "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-'._@+\\",
"UserPassword": {
"RequiredLength": 10,
"RequireNonLetterOrDigit": false,

View File

@@ -11,7 +11,6 @@
<ContentTargetFolders>.</ContentTargetFolders>
<NoWarn>NU5128</NoWarn>
</PropertyGroup>
<ItemGroup>
<Content Include="..\src\Umbraco.Web.UI\Program.cs">
<Link>UmbracoProject\Program.cs</Link>
@@ -20,6 +19,7 @@
<Content Include="UmbracoPackage\**" Exclude="bin;obj" />
<Content Include="UmbracoPackageRcl\**" Exclude="bin;obj" />
<Content Include="UmbracoProject\**" Exclude="bin;obj" />
<Content Include="UmbracoDockerCompose\**" Exclude="bin;obj"/>
<Content Include="..\src\Umbraco.Web.UI\Views\Partials\blocklist\**">
<Link>UmbracoProject\Views\Partials\blocklist\%(RecursiveDir)%(Filename)%(Extension)</Link>
<PackagePath>UmbracoProject\Views\Partials\blocklist</PackagePath>
@@ -32,13 +32,7 @@
<Link>UmbracoProject\Views\_ViewImports.cshtml</Link>
<PackagePath>UmbracoProject\Views</PackagePath>
</Content>
<Content Include="..\src\Umbraco.Web.UI\wwwroot\favicon.ico">
<Link>UmbracoProject\wwwroot\favicon.ico</Link>
<PackagePath>UmbracoProject\wwwroot</PackagePath>
</Content>
</ItemGroup>
<!-- Update template.json files with the default UmbracoVersion value set to the current build version -->
<ItemGroup>
<PackageReference Include="Umbraco.JsonSchema.Extensions" PrivateAssets="all" />
@@ -54,7 +48,7 @@
</_TemplateJsonFiles>
</ItemGroup>
<Copy SourceFiles="@(_TemplateJsonFiles)" DestinationFiles="%(DestinationFile)" />
<JsonPathUpdateValue JsonFile="%(_TemplateJsonFiles.DestinationFile)" Path="$.symbols.UmbracoVersion.defaultValue" Value="&quot;$(PackageVersion)&quot;" />
<JsonPathUpdateValue JsonFile="%(_TemplateJsonFiles.DestinationFile)" Path="$.symbols.FinalVersion.parameters.cases.[0].value" Value="&quot;$(PackageVersion)&quot;" />
<ItemGroup>
<_PackageFiles Include="%(_TemplateJsonFiles.DestinationFile)">
<PackagePath>%(_TemplateJsonFiles.RelativeDir)</PackagePath>

View File

@@ -0,0 +1 @@
DB_PASSWORD=Password1234

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/dotnetcli.host.json",
"symbolInfo": {
"ProjectName": {
"longName": "ProjectName",
"shortName": "P"
},
"DatabasePassword": {
"longName": "DatabasePassword",
"shortName": "dbpw"
},
"Port":
{
"longName": "Port",
"shortName": "p"
}
},
"usageExamples": [
"dotnet new umbraco-compose -P MyProject",
"dotnet new umbraco-compose --ProjectName MyProject",
"dotnet new umbraco-compose -P -MyProject -dbpw MyStr0ngP@ssword",
"dotnet new umbraco-compose -P -MyProject --DatabasePassword MyStr0ngP@ssword"
]
}

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/ide.host.json",
"order": 0,
"icon": "../../icon.png",
"description": {
"id": "UmbracoDockerCompose",
"text": "Umbraco Docker Compose - Docker compose for Umbraco CMS and associated database"
},
"symbolInfo": [
{
"id": "ProjectName",
"isVisible": true
},
{
"id": "DatabasePassword",
"isVisible": true
},
{
"id": "Port",
"isVisible": true
}
]
}

View File

@@ -0,0 +1,49 @@
{
"$schema": "https://json.schemastore.org/template.json",
"author": "Umbraco HQ",
"classifications": [
"Web",
"CMS",
"Umbraco"
],
"name": "Umbraco Docker Compose",
"description": "Creates the prerequisites for developing Umbraco in Docker containers",
"groupIdentity": "Umbraco.Templates.UmbracoDockerCompose",
"identity": "Umbraco.Templates.UmbracoDockerCompose",
"shortName": "umbraco-compose",
"tags": {
"type": "item"
},
"symbols": {
"ProjectName": {
"type": "parameter",
"description": "The name of the project the Docker Compose file will be created for",
"datatype": "string",
"replaces": "UmbracoProject",
"isRequired": true
},
"DatabasePassword": {
"type": "parameter",
"description": "The password to the database, will be stored in .env file",
"datatype": "string",
"replaces": "Password1234",
"defaultValue": "Password1234"
},
"Port": {
"type": "parameter",
"description": "The port forward on the docker container, this is the port you use to access the site",
"datatype": "string",
"replaces": "TEMPLATE_PORT",
"defaultValue": "44372"
},
"ImageName": {
"type": "generated",
"generator": "casing",
"parameters": {
"source": "ProjectName",
"toLower": true
},
"replaces": "umbraco_image"
}
}
}

View File

@@ -0,0 +1,21 @@
FROM mcr.microsoft.com/azure-sql-edge:latest
ENV ACCEPT_EULA=Y
USER root
RUN mkdir /var/opt/sqlserver
RUN chown mssql /var/opt/sqlserver
ENV MSSQL_BACKUP_DIR="/var/opt/mssql"
ENV MSSQL_DATA_DIR="/var/opt/mssql/data"
ENV MSSQL_LOG_DIR="/var/opt/mssql/log"
EXPOSE 1433/tcp
COPY setup.sql /
COPY startup.sh /
COPY healthcheck.sh /
ENTRYPOINT [ "/bin/bash", "startup.sh" ]
CMD [ "/opt/mssql/bin/sqlservr" ]

View File

@@ -0,0 +1,15 @@
value="$(/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -d master -Q "SELECT state_desc FROM sys.databases WHERE name = 'umbracoDb'" | awk 'NR==3')"
# This checks for any non-zero length string, and $value will be empty when the database does not exist.
if [ -n "$value" ]
then
echo "ONLINE"
return 0 # With docker 0 = success
else
echo "OFFLINE"
return 1 # And 1 = unhealthy
fi
# This is useful for debugging
# echo "Value is:"
# echo "$value"

View File

@@ -0,0 +1,10 @@
USE [master]
GO
IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'UmbracoDb')
BEGIN
CREATE DATABASE [umbracoDb]
END;
GO
USE UmbracoDb;

View File

@@ -0,0 +1,23 @@
#!/bin/bash
set -e
# Taken from: https://github.com/CarlSargunar/Umbraco-Docker-Workshop
if [ "$1" = '/opt/mssql/bin/sqlservr' ]; then
# If this is the container's first run, initialize the application database
if [ ! -f /tmp/app-initialized ]; then
# Initialize the application database asynchronously in a background process. This allows a) the SQL Server process to be the main process in the container, which allows graceful shutdown and other goodies, and b) us to only start the SQL Server process once, as opposed to starting, stopping, then starting it again.
function initialize_app_database() {
# Wait a bit for SQL Server to start. SQL Server's process doesn't provide a clever way to check if it's up or not, and it needs to be up before we can import the application database
sleep 15s
#run the setup script to create the DB and the schema in the DB
/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -d master -i setup.sql
# Note that the container has been initialized so future starts won't wipe changes to the data
touch /tmp/app-initialized
}
initialize_app_database &
fi
fi
exec "$@"

View File

@@ -0,0 +1,102 @@
services:
umb_database:
container_name: umbraco_image_database
build:
context: ./Database
environment:
SA_PASSWORD: ${DB_PASSWORD}
MSSQL_SA_PASSWORD: ${DB_PASSWORD}
ports:
- "1433:1433"
- "1434:1434"
volumes:
- umb_database:/var/opt/mssql
networks:
- umbnet
healthcheck:
# This healthcheck is to make sure that the database is up and running before the umbraco container starts.
# It works by querying the database for the state of the umbracoDb database, ensuring it exists.
test: ./healthcheck.sh
interval: 5m
timeout: 5s
retries: 3
start_period: 15s # Bootstrap duration, for this duration failures does not count towards max retries.
start_interval: 5s # How long after the health check has started to run the healthcheck again.
umbraco_image:
image: umbraco_image
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__umbracoDbDSN=Server=umb_database;Database=umbracoDb;User Id=sa;Password=${DB_PASSWORD};TrustServerCertificate=true;
- ConnectionStrings__umbracoDbDSN_ProviderName=Microsoft.Data.SqlClient
volumes:
- umb_media:/app/wwwroot/media
- umb_scripts:/app/wwwroot/scripts
- umb_styles:/app/wwwroot/css
- umb_logs:/app/umbraco/Logs
- umb_views:/app/Views
- umb_data:/app/umbraco
- umb_models:/app/umbraco/models
build:
context: .
dockerfile: UmbracoProject/Dockerfile
args:
- BUILD_CONFIGURATION=Debug
depends_on:
umb_database:
condition: service_healthy
restart: always
ports:
- "TEMPLATE_PORT:8080"
networks:
- umbnet
develop:
# This allows you to run docker compose watch, after doing so the container will rebuild when the models are changed.
# Once a restart only feature is implemented (https://github.com/docker/compose/issues/11446)
# It would be really nice to add a restart only watch to \Views, since the file watchers for recompilation of Razor views does not work with docker.
watch:
- path: ./UmbracoProject/umbraco/models
action: rebuild
# These volumes are all made as bind mounts, meaning that they are bound to the host machine's file system.
# This is to better facilitate local development in the IDE, so the views, models, etc... are available in the IDE.
# This can be changed by removing the driver and driver_opts from the volumes.
volumes:
umb_media:
driver: local
driver_opts:
type: none
device: ./UmbracoProject/wwwroot/media
o: bind
umb_scripts:
driver: local
driver_opts:
type: none
device: ./UmbracoProject/wwwroot/scripts
o: bind
umb_styles:
driver: local
driver_opts:
type: none
device: ./UmbracoProject/wwwroot/css
o: bind
umb_logs:
umb_views:
driver: local
driver_opts:
type: none
device: ./UmbracoProject/Views
o: bind
umb_data:
umb_models:
driver: local
driver_opts:
type: none
device: ./UmbracoProject/umbraco/models
o: bind
umb_database:
networks:
umbnet:
driver: bridge

View File

@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

View File

@@ -8,12 +8,25 @@
},
"UmbracoVersion": {
"longName": "version",
"shortName": "v"
"shortName": "v",
"isHidden": true
},
"UmbracoRelease": {
"longName": "release",
"shortName": "r"
},
"UseHttpsRedirect": {
"longName": "use-https-redirect",
"shortName": ""
},
"UseDeliveryApi": {
"longName": "use-delivery-api",
"shortName": "da"
},
"Docker": {
"longName": "add-docker",
"shortName": ""
},
"SkipRestore": {
"longName": "no-restore",
"shortName": ""
@@ -58,6 +71,18 @@
"longName": "PackageTestSiteName",
"shortName": "p",
"isHidden": true
},
"ModelsBuilderMode": {
"longName": "models-mode",
"shortName": "mm"
},
"StarterKit": {
"longName": "starter-kit",
"shortName": "sk"
},
"DevelopmentMode": {
"longName": "development-mode",
"shortName": "dm"
}
},
"usageExamples": [

View File

@@ -9,11 +9,22 @@
"symbolInfo": [
{
"id": "UmbracoVersion",
"isVisible": true
"isVisible": false
},
{
"id": "UseHttpsRedirect",
"isVisible": true,
"persistenceScope": "templateGroup",
"defaultValue": "true"
},
{
"id": "UseDeliveryApi",
"isVisible": true,
"persistenceScope": "templateGroup"
},
{
"id": "ModelsBuilderMode",
"isVisible": true,
"persistenceScope": "templateGroup"
},
{
@@ -54,6 +65,23 @@
{
"id": "NoNodesViewPath",
"isVisible": true
},
{
"id": "Docker",
"isVisible": true
},
{
"id": "StarterKit",
"isVisible": true
},
{
"id": "UmbracoRelease",
"isVisible": true
},
{
"id": "DevelopmentMode",
"isVisible": true,
"defaultValue": "IDEDevelopment"
}
]
}

View File

@@ -0,0 +1,47 @@
{
"$schema": "https://json.schemastore.org/template.json",
"symbols": {
"StarterKit": {
"displayName": "Starter kit",
"type": "parameter",
"datatype": "choice",
"description": "Choose a starter kit to install.",
"defaultValue": "None",
"replaces": "STARTER_KIT_NAME",
// The choice here should be the name of the starter kit package, since it will be used directly for package reference.
"choices": [
{
"choice": "None",
"description": "No starter kit."
},
{
"choice": "Umbraco.TheStarterKit",
"description": "The Umbraco starter kit.",
"displayName": "The Starter Kit"
}
]
},
// Used to determine the version of the starter kit to install.
// there should be cases for Latest, LTS and Custom for every starterkit added above.
// This has the benefit that all maintenance of starter kits in template can be done from this file.
"StarterKitVersion": {
"type": "generated",
"generator": "switch",
"replaces": "STARTER_KIT_VERSION",
"parameters": {
"evaluator": "C++",
"datatype": "string",
"cases": [
{
"condition": "(StarterKit == 'Umbraco.TheStarterKit' && (UmbracoRelease == 'Latest' || UmbracoRelease == 'Custom'))",
"value": "14.0.0"
},
{
"condition": "(StarterKit == 'Umbraco.TheStarterKit' && UmbracoRelease == 'LTS')",
"value": "13.0.0"
}
]
}
}
}
}

View File

@@ -18,6 +18,7 @@
"sourceName": "UmbracoProject",
"defaultName": "UmbracoProject1",
"preferNameDirectory": true,
"additionalConfigFiles": [ "starterkits.template.json"],
"sources": [
{
"modifiers": [
@@ -26,6 +27,13 @@
"exclude": [
".gitignore"
]
},
{
"condition": "(!Docker)",
"exclude": [
"Dockerfile",
".dockerignore"
]
}
]
}
@@ -46,13 +54,72 @@
"defaultValue": "net9.0",
"replaces": "net9.0"
},
"UmbracoRelease": {
"displayName": "Umbraco Version",
"description": "The Umbraco release to use, either latest or latest long term supported",
"type": "parameter",
"datatype": "choice",
"defaultValue": "Latest",
"choices": [
{
"choice": "Latest",
"description": "The latest umbraco release"
},
{
"choice": "LTS",
"description": "The most recent long term supported version",
"displayName": "Long Term Supported"
}
],
"isRequired": false
},
"UmbracoVersion": {
"displayName": "Umbraco version",
"description": "The version of Umbraco.Cms to add as PackageReference.",
"displayName": "Custom Version",
"description": "The selected custom version of Umbraco, this is obsoleted, and will be removed in a future version of the template.",
"type": "parameter",
"datatype": "string",
"defaultValue": "*",
"replaces": "UMBRACO_VERSION_FROM_TEMPLATE"
"defaultValue": "null",
"replaces": "CUSTOM_VERSION",
"isRequired": false
},
"FinalVersion" : {
"type": "generated",
"generator": "switch",
"datatype": "text",
"description": "The calculated version of Umbraco to use",
"replaces": "UMBRACO_VERSION_FROM_TEMPLATE",
"parameters": {
"evaluator": "C++",
"datatype": "text",
"cases": [
{
"condition": "(UmbracoRelease == 'Latest')",
"value": "*"
},
{
"condition": "(UmbracoRelease == 'LTS')",
"value": "13.4.1"
}
]
}
},
"DotnetVersion":
{
"type": "generated",
"generator": "switch",
"datatype": "text",
"description": "Not relevant at the moment, but if we need to change the dotnet version based on the Umbraco version, we can do it here",
"replaces": "DOTNET_VERSION_FROM_TEMPLATE",
"parameters": {
"evaluator": "C++",
"datatype": "text",
"cases": [
{
"condition": "(true)",
"value": "net8.0"
}
]
}
},
"UseHttpsRedirect": {
"displayName": "Use HTTPS redirect",
@@ -61,6 +128,20 @@
"datatype": "bool",
"defaultValue": "false"
},
"UseDeliveryApi": {
"displayName": "Use Delivery API",
"description": "Enables the Delivery API",
"type": "parameter",
"datatype": "bool",
"defaultValue": "false"
},
"Docker": {
"displayName": "Add Docker file",
"description": "Adds a docker file to the project.",
"type": "parameter",
"datatype": "bool",
"defaultValue": "false"
},
"SkipRestore": {
"displayName": "Skip restore",
"description": "If specified, skips the automatic restore of the project on create.",
@@ -244,6 +325,58 @@
"defaultValue": "",
"replaces": "PACKAGE_PROJECT_NAME_FROM_TEMPLATE"
},
"DevelopmentMode": {
"type": "parameter",
"displayName": "Development mode",
"datatype": "choice",
"description": "Choose the development mode to use for the project.",
"defaultValue": "BackofficeDevelopment",
"choices": [
{
"choice": "BackofficeDevelopment",
"description": "Enables backoffice development, allowing you to develop from within the backoffice, this is the default behaviour.",
"displayName": "Backoffice Development"
},
{
"choice": "IDEDevelopment",
"description": "Configures appsettings.Development.json to Development runtime mode and SourceCodeAuto models builder mode, and configures appsettings.json to Production runtime mode, Nothing models builder mode, and enables UseHttps",
"displayName": "IDE Development"
}
]
},
"ModelsBuilderMode": {
"type": "parameter",
"displayName": "Models builder mode",
"datatype": "choice",
"description": "Choose the models builder mode to use for the project. When development mode is set to IDEDevelopment this only changes the models builder mode appsetttings.development.json",
"defaultValue": "Default",
"replaces": "MODELS_MODE",
"choices": [
{
"choice": "Default",
"description": "Let DevelopmentMode determine the models builder mode."
},
{
"choice": "InMemoryAuto",
"description": "Generate models in memory, automatically updating when a content type change, this means no need for app rebuild, however models are only available in views.",
"displayName": "In Memory Auto"
},
{
"choice": "SourceCodeManual",
"description": "Generate models as source code, only updating when requested manually, this means a interaction and rebuild is required when content type(s) change, however models are available in code.",
"displayName": "Source Code Manual"
},
{
"choice": "SourceCodeAuto",
"description": "Generate models as source code, automatically updating when a content type change, this means a rebuild is required when content type(s) change, however models are available in code.",
"displayName": "Source Code Auto"
},
{
"choice": "Nothing",
"description": "No models are generated, this is recommended for production assuming generated models are used for development."
}
]
},
"Namespace": {
"type": "derived",
"valueSource": "name",

View File

@@ -0,0 +1,33 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["UmbracoProject/UmbracoProject.csproj", "UmbracoProject/"]
RUN dotnet restore "UmbracoProject/UmbracoProject.csproj"
COPY . .
WORKDIR "/src/UmbracoProject"
RUN dotnet build "UmbracoProject.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "UmbracoProject.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
# We need to make sure that the user running the app has write access to the umbraco folder, in order to write logs and other files.
# Since these are volumes they are created as root by the docker daemon.
USER root
RUN mkdir umbraco
RUN mkdir umbraco/Logs
RUN chown $APP_UID umbraco --recursive
#if (UmbracoRelease = 'LTS')
RUN chown $APP_UID wwwroot/umbraco --recursive
#endif
USER $APP_UID
ENTRYPOINT ["dotnet", "UmbracoProject.dll"]

View File

@@ -1,13 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>DOTNET_VERSION_FROM_TEMPLATE</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace Condition="'$(name)' != '$(name{-VALUE-FORMS-}safe_namespace)'">Umbraco.Cms.Web.UI</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!--#if (UmbracoVersion != "null")
<PackageReference Include="Umbraco.Cms" Version="CUSTOM_VERSION" />
#else
<PackageReference Include="Umbraco.Cms" Version="UMBRACO_VERSION_FROM_TEMPLATE" />
#endif-->
<!--#if (StarterKit != "None") -->
<PackageReference Include="STARTER_KIT_NAME" Version="STARTER_KIT_VERSION"/>
<!--#endif -->
</ItemGroup>
<ItemGroup>
@@ -21,11 +29,13 @@
<CopyRazorGenerateFilesToPublishDirectory>true</CopyRazorGenerateFilesToPublishDirectory>
</PropertyGroup>
<!--#if (ModelsBuilderMode == "InMemoryAuto" || (DevelopmentMode == "BackofficeDevelopment" && ModelsBuilderMode == "Default")) -->
<PropertyGroup>
<!-- Remove RazorCompileOnBuild and RazorCompileOnPublish when not using ModelsMode InMemoryAuto -->
<RazorCompileOnBuild>false</RazorCompileOnBuild>
<RazorCompileOnPublish>false</RazorCompileOnPublish>
</PropertyGroup>
<!--#endif -->
<Import Project="..\PACKAGE_PROJECT_NAME_FROM_TEMPLATE\buildTransitive\PACKAGE_PROJECT_NAME_FROM_TEMPLATE.targets" Condition="'$(PackageProjectName)' != ''" />
<ItemGroup Condition="'$(PackageProjectName)' != ''">

View File

@@ -25,6 +25,11 @@
//#endif
"Umbraco": {
"CMS": {
//#if (UseHttpsRedirect || DevelopmentMode == "IDEDevelopment")
"Global": {
"UseHttps": false
},
//#endif
//#if (UsingUnattenedInstall)
"Unattended": {
"InstallUnattended": true,
@@ -36,12 +41,22 @@
"Content": {
"MacroErrors": "Throw"
},
//#if (DevelopmentMode == "IDEDevelopment")
"Runtime": {
"Mode": "Development"
},
//#if (ModelsBuilderMode == "Default")
"ModelsBuilder": {
"ModelsMode": "SourceCodeAuto"
},
////#else
//"ModelsBuilder": {
// "ModelsMode": "MODELS_MODE"
//},
//#endif
//#endif
"Hosting": {
"Debug": true
},
"RuntimeMinification": {
"UseInMemoryCache": true,
"CacheBuster": "Timestamp"
}
}
}

View File

@@ -20,7 +20,7 @@
"CMS": {
"Global": {
"Id": "TELEMETRYID_FROM_TEMPLATE",
//#if (UseHttpsRedirect)
//#if (UseHttpsRedirect || DevelopmentMode == "IDEDevelopment")
"UseHttps": true,
//#endif
//#if (HasNoNodesViewPath)
@@ -37,6 +37,24 @@
"Unattended": {
"UpgradeUnattended": true
},
//#if (UseDeliveryApi)
"DeliveryApi": {
"Enabled": true
},
//#endif
//#if (ModelsBuilderMode != "Default" && DevelopmentMode == "BackOfficeDevelopment")
"ModelsBuilder": {
"ModelsMode": "MODELS_MODE"
},
//#endif
//#if (DevelopmentMode == "IDEDevelopment")
"Runtime": {
"Mode": "Production"
},
"ModelsBuilder": {
"ModelsMode": "Nothing"
},
//#endif
"Security": {
"AllowConcurrentLogins": false
}

View File

@@ -21,7 +21,7 @@
<!-- Third-party packages -->
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
<PackageVersion Include="AutoFixture.NUnit3" Version="4.18.1" />
<PackageVersion Include="Bogus" Version="35.5.1" />
<PackageVersion Include="Bogus" Version="35.6.0" />
<PackageVersion Include="Moq" Version="4.18.4" />
<PackageVersion Include="NUnit" Version="3.14.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="4.5.0" PrivateAssets="all" />

View File

@@ -7,8 +7,8 @@
"name": "acceptancetest",
"hasInstallScript": true,
"dependencies": {
"@umbraco/json-models-builders": "^2.0.9",
"@umbraco/playwright-testhelpers": "^2.0.0-beta.65",
"@umbraco/json-models-builders": "^2.0.17",
"@umbraco/playwright-testhelpers": "^2.0.0-beta.78",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"faker": "^4.1.0",
@@ -132,24 +132,20 @@
}
},
"node_modules/@umbraco/json-models-builders": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.9.tgz",
"integrity": "sha512-p6LjcE38WsFCvLtRRRVOCuMvris3OXeoueFu0FZBOHk2r7PXiqYCBUls/KbKxqpixzVDAb48RBd1hV7sKPcm5A==",
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.17.tgz",
"integrity": "sha512-i7uuojDjWuXkch9XkEClGtlKJ0Lw3BTGpp4qKaUM+btb7g1sn1Gi50+f+478cJvLG6+q6rmQDZCIXqrTU6Ryhg==",
"dependencies": {
"camelize": "^1.0.1"
}
},
"node_modules/@umbraco/playwright-testhelpers": {
"version": "2.0.0-beta.65",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-2.0.0-beta.65.tgz",
"integrity": "sha512-plSD/4hhVaMl2TItAaBOUQyuy0Qo5rW3EGIF0TvL3a01s6hNoW1DrOCZhWsOOsMTkgf+oScLEsVIBMk0uDLQrg==",
"version": "2.0.0-beta.78",
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-2.0.0-beta.78.tgz",
"integrity": "sha512-s9jLCKQRfXH2zAkT4iUzu/XsrrPQRFVWdj7Ps3uvBV8YzdM1EYMAaCKwgZ5OnCSCN87gysYTW++NZyKT2Fg6qQ==",
"dependencies": {
"@umbraco/json-models-builders": "2.0.9",
"camelize": "^1.0.0",
"faker": "^4.1.0",
"form-data": "^4.0.0",
"node-fetch": "^2.6.7",
"xhr2": "^0.2.1"
"@umbraco/json-models-builders": "2.0.17",
"node-fetch": "^2.6.7"
}
},
"node_modules/aggregate-error": {
@@ -419,20 +415,6 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",

View File

@@ -21,8 +21,8 @@
"wait-on": "^7.2.0"
},
"dependencies": {
"@umbraco/json-models-builders": "^2.0.9",
"@umbraco/playwright-testhelpers": "^2.0.0-beta.65",
"@umbraco/json-models-builders": "^2.0.17",
"@umbraco/playwright-testhelpers": "^2.0.0-beta.78",
"camelize": "^1.0.0",
"dotenv": "^16.3.1",
"faker": "^4.1.0",

View File

@@ -9,6 +9,7 @@ test.describe('DataType tests', () => {
const dataTypeName = 'TestDataType';
const folderName = 'TestDataTypeFolder';
const editorAlias = 'Umbraco.DateTime';
const editorUiAlias = 'Umb.PropertyEditorUi.DatePicker';
const dataTypeData = [
{
"alias": "tester",
@@ -29,7 +30,7 @@ test.describe('DataType tests', () => {
test('can create dataType', async ({umbracoApi}) => {
// Act
dataTypeId = await umbracoApi.dataType.create(dataTypeName, editorAlias, dataTypeData);
dataTypeId = await umbracoApi.dataType.create(dataTypeName, editorAlias, editorUiAlias, dataTypeData);
// Assert
expect(umbracoApi.dataType.doesExist(dataTypeId)).toBeTruthy();
@@ -37,7 +38,7 @@ test.describe('DataType tests', () => {
test('can update dataType', async ({umbracoApi}) => {
// Arrange
dataTypeId = await umbracoApi.dataType.create(dataTypeName, editorAlias, []);
dataTypeId = await umbracoApi.dataType.create(dataTypeName, editorAlias, editorUiAlias, []);
const dataType = await umbracoApi.dataType.get(dataTypeId);
dataType.values = dataTypeData;
@@ -52,7 +53,7 @@ test.describe('DataType tests', () => {
test('can delete dataType', async ({umbracoApi}) => {
// Arrange
dataTypeId = await umbracoApi.dataType.create(dataTypeName, editorAlias, dataTypeData);
dataTypeId = await umbracoApi.dataType.create(dataTypeName, editorAlias, editorUiAlias, dataTypeData);
expect(await umbracoApi.dataType.doesExist(dataTypeId)).toBeTruthy();
// Act
@@ -65,7 +66,7 @@ test.describe('DataType tests', () => {
test('can move a dataType to a folder', async ({umbracoApi}) => {
// Arrange
await umbracoApi.dataType.ensureNameNotExists(folderName);
dataTypeId = await umbracoApi.dataType.create(dataTypeName, editorAlias, dataTypeData);
dataTypeId = await umbracoApi.dataType.create(dataTypeName, editorAlias, editorUiAlias, dataTypeData);
expect(await umbracoApi.dataType.doesNameExist(dataTypeName)).toBeTruthy();
dataTypeFolderId = await umbracoApi.dataType.createFolder(folderName);
expect(await umbracoApi.dataType.doesFolderExist(dataTypeFolderId)).toBeTruthy();
@@ -82,7 +83,7 @@ test.describe('DataType tests', () => {
test('can copy a dataType to a folder', async ({umbracoApi}) => {
// Arrange
await umbracoApi.dataType.ensureNameNotExists(folderName);
dataTypeId = await umbracoApi.dataType.create(dataTypeName, editorAlias, dataTypeData);
dataTypeId = await umbracoApi.dataType.create(dataTypeName, editorAlias, editorUiAlias, dataTypeData);
dataTypeFolderId = await umbracoApi.dataType.createFolder(folderName);
const dataType = await umbracoApi.dataType.get(dataTypeId);

View File

@@ -7,7 +7,7 @@ let contentId = '';
const contentName = 'TestContent';
const childContentName = 'ChildContent';
const documentTypeName = 'DocumentTypeForContent';
const childDocumentTypeName = 'ChildDocumentTypeForContent';
const childDocumentTypeName = 'ChildDocumentType';
test.beforeEach(async ({umbracoApi}) => {
await umbracoApi.documentType.ensureNameNotExists(documentTypeName);
@@ -24,7 +24,7 @@ test.afterEach(async ({umbracoApi}) => {
test('can create child node', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => {
// Arrange
childDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentType(childDocumentTypeName);
documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId, true);
documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId);
contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
@@ -32,7 +32,9 @@ test('can create child node', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) =
// Act
await umbracoUi.content.clickActionsMenuForContent(contentName);
await umbracoUi.content.clickCreateButton();
await umbracoUi.content.chooseDocumentType(documentTypeName);
await umbracoUi.content.chooseDocumentType(childDocumentTypeName);
// This wait is needed
await umbracoUi.waitForTimeout(500);
await umbracoUi.content.enterContentName(childContentName);
await umbracoUi.content.clickSaveButton();
@@ -51,8 +53,7 @@ test('can create child node', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) =
await umbracoApi.document.ensureNameNotExists(childContentName);
});
// TODO: Remove skip when the front-end is ready.
test.skip('can create child node in child node', async ({umbracoApi, umbracoUi}) => {
test('can create child node in child node', async ({umbracoApi, umbracoUi}) => {
// Arrange
const childOfChildContentName = 'ChildOfChildContent';
const childOfChildDocumentTypeName = 'ChildOfChildDocumentType';
@@ -60,8 +61,8 @@ test.skip('can create child node in child node', async ({umbracoApi, umbracoUi})
let childContentId: any;
await umbracoApi.documentType.ensureNameNotExists(childOfChildDocumentTypeName);
childOfChildDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentType(childOfChildDocumentTypeName);
childDocumentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(childDocumentTypeName, childOfChildDocumentTypeId, true);
documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId, true);
childDocumentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(childDocumentTypeName, childOfChildDocumentTypeId);
documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId);
contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
childContentId = await umbracoApi.document.createDefaultDocumentWithParent(childContentName, childDocumentTypeId, contentId);
await umbracoUi.goToBackOffice();
@@ -71,7 +72,7 @@ test.skip('can create child node in child node', async ({umbracoApi, umbracoUi})
await umbracoUi.content.clickCaretButtonForContentName(contentName);
await umbracoUi.content.clickActionsMenuForContent(childContentName);
await umbracoUi.content.clickCreateButton();
await umbracoUi.content.clickLabelWithName(childOfChildDocumentTypeName);
await umbracoUi.content.chooseDocumentType(childOfChildDocumentTypeName);
// This wait is needed
await umbracoUi.waitForTimeout(500);
await umbracoUi.content.enterContentName(childOfChildContentName);
@@ -81,9 +82,7 @@ test.skip('can create child node in child node', async ({umbracoApi, umbracoUi})
await umbracoUi.content.isSuccessNotificationVisible();
const childOfChildData = await umbracoApi.document.getChildren(childContentId);
expect(childOfChildData[0].variants[0].name).toBe(childOfChildContentName);
// verify that the child content displays in the tree after reloading children
await umbracoUi.content.clickActionsMenuForContent(contentName);
await umbracoUi.content.clickReloadButton();
// verify that the child content displays in the tree
await umbracoUi.content.clickCaretButtonForContentName(childContentName);
await umbracoUi.content.doesContentTreeHaveName(childOfChildContentName);
@@ -95,7 +94,7 @@ test.skip('can create child node in child node', async ({umbracoApi, umbracoUi})
test('cannot publish child if the parent is not published', async ({umbracoApi, umbracoUi}) => {
// Arrange
childDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentType(childDocumentTypeName);
documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId, true);
documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId);
contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
await umbracoApi.document.createDefaultDocumentWithParent(childContentName, childDocumentTypeId, contentId);
await umbracoUi.goToBackOffice();
@@ -115,7 +114,7 @@ test('cannot publish child if the parent is not published', async ({umbracoApi,
test('can publish with descendants', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => {
// Arrange
childDocumentTypeId = await umbracoApi.documentType.createDefaultDocumentType(childDocumentTypeName);
documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId, true);
documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedChildNode(documentTypeName, childDocumentTypeId);
contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
await umbracoApi.document.createDefaultDocumentWithParent(childContentName, childDocumentTypeId, contentId);
await umbracoUi.goToBackOffice();

View File

@@ -91,7 +91,7 @@ test('can rename content', async ({umbracoApi, umbracoUi}) => {
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.openContent(wrongContentName);
await umbracoUi.content.goToContentWithName(wrongContentName);
await umbracoUi.content.enterContentName(contentName);
await umbracoUi.content.clickSaveButton();
@@ -111,7 +111,7 @@ test('can update content', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => {
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.openContent(contentName);
await umbracoUi.content.goToContentWithName(contentName);
await umbracoUi.content.enterTextstring(contentText);
await umbracoUi.content.clickSaveButton();

View File

@@ -18,13 +18,16 @@ test.afterEach(async ({umbracoApi}) => {
test('can see correct information when published', async ({umbracoApi, umbracoUi}) => {
// Arrange
const notPublishContentLink = 'This document is published but is not in the cache';
documentTypeId = await umbracoApi.documentType.createDefaultDocumentTypeWithAllowAsRoot(documentTypeName);
contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
const dataTypeName = 'Textstring';
const contentText = 'This is test content text';
const dataTypeData = await umbracoApi.dataType.getByName(dataTypeName);
documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataTypeData.id);
contentId = await umbracoApi.document.createDocumentWithTextContent(contentName, documentTypeId, contentText, dataTypeName);
await umbracoUi.goToBackOffice();
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.openContent(contentName);
await umbracoUi.content.goToContentWithName(contentName);
await umbracoUi.content.clickInfoTab();
await umbracoUi.content.doesLinkHaveText(notPublishContentLink);
await umbracoUi.content.clickSaveAndPublishButton();
@@ -47,8 +50,7 @@ test('can see correct information when published', async ({umbracoApi, umbracoUi
await umbracoUi.content.doesCreatedDateHaveText(expectedCreatedDate);
});
// TODO: Remove skip when the frond-end is ready. Currently the document type is not opened after clicking to the button
test.skip('can open document type', async ({umbracoApi, umbracoUi}) => {
test('can open document type', async ({umbracoApi, umbracoUi}) => {
// Arrange
documentTypeId = await umbracoApi.documentType.createDefaultDocumentTypeWithAllowAsRoot(documentTypeName);
contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
@@ -56,8 +58,7 @@ test.skip('can open document type', async ({umbracoApi, umbracoUi}) => {
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.openContent(contentName);
await umbracoUi.content.clickInfoTab();
await umbracoUi.content.goToContentWithName(contentName);
await umbracoUi.content.clickDocumentTypeByName(documentTypeName);
// Assert
@@ -66,7 +67,7 @@ test.skip('can open document type', async ({umbracoApi, umbracoUi}) => {
test('can open template', async ({umbracoApi, umbracoUi}) => {
// Arrange
const templateName = "TestTemplateForContent";
const templateName = 'TestTemplateForContent';
await umbracoApi.template.ensureNameNotExists(templateName);
const templateId = await umbracoApi.template.createDefaultTemplate(templateName);
documentTypeId = await umbracoApi.documentType.createDocumentTypeWithAllowedTemplate(documentTypeName, templateId, true);
@@ -75,8 +76,7 @@ test('can open template', async ({umbracoApi, umbracoUi}) => {
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.openContent(contentName);
await umbracoUi.content.clickInfoTab();
await umbracoUi.content.goToContentWithName(contentName);
await umbracoUi.content.clickTemplateByName(templateName);
// Assert
@@ -88,8 +88,8 @@ test('can open template', async ({umbracoApi, umbracoUi}) => {
test('can change template', async ({umbracoApi, umbracoUi}) => {
// Arrange
const firstTemplateName = "TestTemplateOneForContent";
const secondTemplateName = "TestTemplateTwoForContent";
const firstTemplateName = 'TestTemplateOneForContent';
const secondTemplateName = 'TestTemplateTwoForContent';
await umbracoApi.template.ensureNameNotExists(firstTemplateName);
await umbracoApi.template.ensureNameNotExists(secondTemplateName);
const firstTemplateId = await umbracoApi.template.createDefaultTemplate(firstTemplateName);
@@ -100,8 +100,7 @@ test('can change template', async ({umbracoApi, umbracoUi}) => {
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.openContent(contentName);
await umbracoUi.content.clickInfoTab();
await umbracoUi.content.goToContentWithName(contentName);
await umbracoUi.content.changeTemplate(firstTemplateName, secondTemplateName);
await umbracoUi.content.clickSaveButton();
@@ -116,8 +115,8 @@ test('can change template', async ({umbracoApi, umbracoUi}) => {
test('cannot change to a template that is not allowed in the document type', async ({umbracoApi, umbracoUi}) => {
// Arrange
const firstTemplateName = "TestTemplateOneForContent";
const secondTemplateName = "TestTemplateTwoForContent";
const firstTemplateName = 'TestTemplateOneForContent';
const secondTemplateName = 'TestTemplateTwoForContent';
await umbracoApi.template.ensureNameNotExists(firstTemplateName);
await umbracoApi.template.ensureNameNotExists(secondTemplateName);
const firstTemplateId = await umbracoApi.template.createDefaultTemplate(firstTemplateName);
@@ -128,8 +127,7 @@ test('cannot change to a template that is not allowed in the document type', asy
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
// Act
await umbracoUi.content.openContent(contentName);
await umbracoUi.content.clickInfoTab();
await umbracoUi.content.goToContentWithName(contentName);
await umbracoUi.content.clickEditTemplateByName(firstTemplateName);
// Assert

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