Merge branch 'main' into v17/dev

This commit is contained in:
Andy Butland
2025-07-08 09:26:54 +02:00
556 changed files with 9979 additions and 4688 deletions

View File

@@ -114,7 +114,7 @@ To declare the Published Cache Status Dashboard as a new manifest, we need to ad
},
conditions: [
{
alias: 'Umb.Condition.SectionAlias',
alias: UMB_SECTION_ALIAS_CONDITION_ALIAS,
match: 'Umb.Section.Settings',
},
],

View File

@@ -20,7 +20,7 @@ on:
jobs:
build_and_deploy_job:
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed' && (contains(github.event.pull_request.labels.*.name, 'preview/backoffice')))
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview/backoffice') && github.repository == github.event.pull_request.head.repo.full_name)
runs-on: ubuntu-latest
name: Build and Deploy Job
steps:
@@ -44,7 +44,7 @@ jobs:
###### End of Repository/Build Configurations ######
close_pull_request_job:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
if: github.event_name == 'pull_request' && github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview/backoffice') && github.repository == github.event.pull_request.head.repo.full_name
runs-on: ubuntu-latest
name: Close Pull Request Job
steps:

View File

@@ -23,7 +23,7 @@ env:
jobs:
build_and_deploy_job:
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed' && (contains(github.event.pull_request.labels.*.name, 'preview/storybook')))
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed' && contains(github.event.pull_request.labels.*.name, 'preview/storybook') && github.repository == github.event.pull_request.head.repo.full_name)
runs-on: ubuntu-latest
name: Build and Deploy Job
steps:
@@ -45,7 +45,7 @@ jobs:
###### End of Repository/Build Configurations ######
close_pull_request_job:
if: github.event_name == 'pull_request' && github.event.action == 'closed'
if: github.event_name == 'pull_request' && github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview/storybook') && github.repository == github.event.pull_request.head.repo.full_name
runs-on: ubuntu-latest
name: Close Pull Request Job
steps:

2
.vscode/launch.json vendored
View File

@@ -62,6 +62,7 @@
"cwd": "${workspaceFolder}/src/Umbraco.Web.UI",
"stopAtEntry": false,
"requireExactSource": false,
"postDebugTask": "kill-umbraco-web-ui",
// Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
"serverReadyAction": {
"action": "openExternally",
@@ -96,6 +97,7 @@
"stopAtEntry": false,
"requireExactSource": false,
"checkForDevCert": true,
"postDebugTask": "kill-umbraco-web-ui",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "https://localhost:44339",

19
.vscode/tasks.json vendored
View File

@@ -6,10 +6,7 @@
"detail": "Builds the client and SLN",
"promptOnClose": true,
"group": "build",
"dependsOn": [
"Client Build",
"Dotnet build"
],
"dependsOn": ["Client Build", "Dotnet build"],
"problemMatcher": []
},
{
@@ -71,6 +68,20 @@
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "kill-umbraco-web-ui",
"type": "shell",
"problemMatcher": [],
"osx": {
"command": "pkill -f Umbraco.Web.UI"
},
"linux": {
"command": "pkill -f Umbraco.Web.UI"
},
"windows": {
"command": "taskkill /IM Umbraco.Web.UI.exe /F"
}
}
]
}

View File

@@ -40,8 +40,8 @@
<!-- Package Validation -->
<PropertyGroup>
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
<EnablePackageValidation>false</EnablePackageValidation>
<PackageValidationBaselineVersion>15.0.0</PackageValidationBaselineVersion>
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>16.0.0</PackageValidationBaselineVersion>
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
</PropertyGroup>

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.DataType.Tree;
public class SiblingsDataTypeTreeController : DataTypeTreeControllerBase
{
public SiblingsDataTypeTreeController(IEntityService entityService, IDataTypeService dataTypeService)
: base(entityService, dataTypeService)
{
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<DataTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<DataTypeTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
=> GetSiblings(target, before, after);
}

View File

@@ -53,16 +53,6 @@ public class MoveDocumentController : DocumentControllerBase
return Forbidden();
}
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionMove.ActionLetter, new[] { moveDocumentRequestModel.Target?.Id, id }),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
Attempt<IContent?, ContentEditingOperationStatus> result = await _contentEditingService.MoveAsync(
id,
moveDocumentRequestModel.Target?.Id,

View File

@@ -0,0 +1,40 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.Document.Tree;
[ApiVersion("1.0")]
public class SiblingsDocumentTreeController : DocumentTreeControllerBase
{
public SiblingsDocumentTreeController(
IEntityService entityService,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
IPublicAccessService publicAccessService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IDocumentPresentationFactory documentPresentationFactory)
: base(
entityService,
userStartNodeEntitiesService,
dataTypeService,
publicAccessService,
appCaches,
backofficeSecurityAccessor,
documentPresentationFactory)
{
}
[HttpGet("siblings")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(IEnumerable<DocumentTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<DocumentTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
=> GetSiblings(target, before, after);
}

View File

@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentBlueprint.Tree;
public class SiblingsDocumentBlueprintTreeController : DocumentBlueprintTreeControllerBase
{
public SiblingsDocumentBlueprintTreeController(IEntityService entityService, IDocumentPresentationFactory documentPresentationFactory)
: base(entityService, documentPresentationFactory)
{
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<DocumentBlueprintTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<DocumentBlueprintTreeItemResponseModel>>> Siblings(
CancellationToken cancellationToken,
Guid target,
int before,
int after) =>
GetSiblings(target, before, after);
}

View File

@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentType.Tree;
public class SiblingsDocumentTypeTreeController : DocumentTypeTreeControllerBase
{
public SiblingsDocumentTypeTreeController(IEntityService entityService, IContentTypeService contentTypeService)
: base(entityService, contentTypeService)
{
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<DocumentTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<DocumentTypeTreeItemResponseModel>>> Siblings(
CancellationToken cancellationToken,
Guid target,
int before,
int after) =>
GetSiblings(target, before, after);
}

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Services.Entities;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.Media.Tree;
public class SiblingsMediaTreeController : MediaTreeControllerBase
{
public SiblingsMediaTreeController(
IEntityService entityService,
IUserStartNodeEntitiesService userStartNodeEntitiesService,
IDataTypeService dataTypeService,
AppCaches appCaches,
IBackOfficeSecurityAccessor backofficeSecurityAccessor,
IMediaPresentationFactory mediaPresentationFactory)
: base(entityService, userStartNodeEntitiesService, dataTypeService, appCaches, backofficeSecurityAccessor, mediaPresentationFactory)
{
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<MediaTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<MediaTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
=> GetSiblings(target, before, after);
}

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.MediaType.Tree;
public class SiblingsMediaTypeTreeController : MediaTypeTreeControllerBase
{
public SiblingsMediaTypeTreeController(IEntityService entityService, IMediaTypeService mediaTypeService)
: base(entityService, mediaTypeService)
{
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<MediaTypeTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<MediaTypeTreeItemResponseModel>>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after)
=> GetSiblings(target, before, after);
}

View File

@@ -1,8 +1,11 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Package;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Packaging;
using Umbraco.Cms.Core.Services;
@@ -14,12 +17,25 @@ namespace Umbraco.Cms.Api.Management.Controllers.Package;
public class AllMigrationStatusPackageController : PackageControllerBase
{
private readonly IPackagingService _packagingService;
private readonly IUmbracoMapper _umbracoMapper;
private readonly IPackagePresentationFactory _packagePresentationFactory;
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V18.")]
public AllMigrationStatusPackageController(IPackagingService packagingService, IUmbracoMapper umbracoMapper)
: this(packagingService, StaticServiceProvider.Instance.GetRequiredService<IPackagePresentationFactory>())
{
}
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in V18.")]
public AllMigrationStatusPackageController(IPackagingService packagingService, IUmbracoMapper umbracoMapper, IPackagePresentationFactory packagePresentationFactory)
: this(packagingService, packagePresentationFactory)
{
}
[ActivatorUtilitiesConstructor]
public AllMigrationStatusPackageController(IPackagingService packagingService, IPackagePresentationFactory packagePresentationFactory)
{
_packagingService = packagingService;
_umbracoMapper = umbracoMapper;
_packagePresentationFactory = packagePresentationFactory;
}
/// <summary>
@@ -38,11 +54,7 @@ public class AllMigrationStatusPackageController : PackageControllerBase
{
PagedModel<InstalledPackage> migrationPlans = await _packagingService.GetInstalledPackagesFromMigrationPlansAsync(skip, take);
var viewModel = new PagedViewModel<PackageMigrationStatusResponseModel>
{
Total = migrationPlans.Total,
Items = _umbracoMapper.MapEnumerable<InstalledPackage, PackageMigrationStatusResponseModel>(migrationPlans.Items)
};
PagedViewModel<PackageMigrationStatusResponseModel> viewModel = _packagePresentationFactory.CreatePackageMigrationStatusResponseModel(migrationPlans);
return Ok(viewModel);
}

View File

@@ -15,7 +15,7 @@ public class RebuildPublishedCacheController : PublishedCacheControllerBase
[HttpPost("rebuild")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> Rebuild(CancellationToken cancellationToken)
public Task<IActionResult> Rebuild(CancellationToken cancellationToken)
{
if (_databaseCacheRebuilder.IsRebuilding())
{
@@ -27,10 +27,10 @@ public class RebuildPublishedCacheController : PublishedCacheControllerBase
Type = "Error",
};
return await Task.FromResult(Conflict(problemDetails));
return Task.FromResult<IActionResult>(Conflict(problemDetails));
}
_databaseCacheRebuilder.Rebuild(true);
return await Task.FromResult(Ok());
return Task.FromResult<IActionResult>(Ok());
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.Template.Tree;
public class SiblingsTemplateTreeController : TemplateTreeControllerBase
{
public SiblingsTemplateTreeController(IEntityService entityService)
: base(entityService)
{
}
[HttpGet("siblings")]
[ProducesResponseType(typeof(IEnumerable<NamedEntityTreeItemResponseModel>), StatusCodes.Status200OK)]
public Task<ActionResult<IEnumerable<NamedEntityTreeItemResponseModel>>> Siblings(
CancellationToken cancellationToken,
Guid target,
int before,
int after) =>
GetSiblings(target, before, after);
}

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
@@ -44,6 +44,23 @@ public abstract class EntityTreeControllerBase<TItem> : ManagementApiControllerB
return Task.FromResult<ActionResult<PagedViewModel<TItem>>>(Ok(result));
}
protected Task<ActionResult<IEnumerable<TItem>>> GetSiblings(Guid target, int before, int after)
{
IEntitySlim[] siblings = EntityService.GetSiblings(target, ItemObjectType, before, after, ItemOrdering).ToArray();
if (siblings.Length == 0)
{
return Task.FromResult<ActionResult<IEnumerable<TItem>>>(NotFound());
}
IEntitySlim? entity = siblings.FirstOrDefault();
Guid? parentKey = entity?.ParentId > 0
? EntityService.GetKey(entity.ParentId, ItemObjectType).Result
: Constants.System.RootKey;
TItem[] treeItemsViewModels = MapTreeItemViewModels(parentKey, siblings);
return Task.FromResult<ActionResult<IEnumerable<TItem>>>(Ok(treeItemsViewModels));
}
protected virtual async Task<ActionResult<IEnumerable<TItem>>> GetAncestors(Guid descendantKey, bool includeSelf = true)
{
IEntitySlim[] ancestorEntities = await GetAncestorEntitiesAsync(descendantKey, includeSelf);

View File

@@ -43,7 +43,7 @@ internal static class ApplicationBuilderExtensions
var response = new ProblemDetails
{
Title = exception.Message,
Title = isDebug ? exception.Message : "Server Error",
Detail = isDebug ? exception.StackTrace : null,
Status = statusCode ?? StatusCodes.Status500InternalServerError,
Instance = isDebug ? exception.GetType().Name : null,

View File

@@ -1,4 +1,6 @@
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.ViewModels.Package;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Packaging;
namespace Umbraco.Cms.Api.Management.Factories;
@@ -8,4 +10,6 @@ public interface IPackagePresentationFactory
PackageDefinition CreatePackageDefinition(CreatePackageRequestModel createPackageRequestModel);
PackageConfigurationResponseModel CreateConfigurationResponseModel();
PagedViewModel<PackageMigrationStatusResponseModel> CreatePackageMigrationStatusResponseModel(PagedModel<InstalledPackage> installedPackages) => new();
}

View File

@@ -6,25 +6,58 @@ using Umbraco.Cms.Core.Models.Membership;
namespace Umbraco.Cms.Api.Management.Factories;
/// <summary>
/// Defines factory methods for the creation of user presentation models.
/// </summary>
public interface IUserPresentationFactory
{
/// <summary>
/// Creates a response model for the provided user.
/// </summary>
UserResponseModel CreateResponseModel(IUser user);
/// <summary>
/// Creates a create model for a user based on the provided request model.
/// </summary>
Task<UserCreateModel> CreateCreationModelAsync(CreateUserRequestModel requestModel);
/// <summary>
/// Creates an invite model for a user based on the provided request model.
/// </summary>
Task<UserInviteModel> CreateInviteModelAsync(InviteUserRequestModel requestModel);
/// <summary>
/// Creates an update model for an existing user based on the provided request model.
/// </summary>
Task<UserUpdateModel> CreateUpdateModelAsync(Guid existingUserKey, UpdateUserRequestModel updateModel);
/// <summary>
/// Creates a response model for the current user based on the provided user.
/// </summary>
Task<CurrentUserResponseModel> CreateCurrentUserResponseModelAsync(IUser user);
/// <summary>
/// Creates an resend invite model for a user based on the provided request model.
/// </summary>
Task<UserResendInviteModel> CreateResendInviteModelAsync(ResendInviteUserRequestModel requestModel);
/// <summary>
/// Creates a user configuration model that contains the necessary data for user management operations.
/// </summary>
Task<UserConfigurationResponseModel> CreateUserConfigurationModelAsync();
/// <summary>
/// Creates a current user configuration model that contains the necessary data for the current user's management operations.
/// </summary>
Task<CurrentUserConfigurationResponseModel> CreateCurrentUserConfigurationModelAsync();
/// <summary>
/// Creates a user item response model for the provided user.
/// </summary>
UserItemResponseModel CreateItemResponseModel(IUser user);
/// <summary>
/// Creates a calculated user start nodes response model based on the provided user.
/// </summary>
Task<CalculatedUserStartNodesResponseModel> CreateCalculatedUserStartNodesResponseModelAsync(IUser user);
}

View File

@@ -1,10 +1,12 @@
using System.Collections.Specialized;
using System.Web;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.ViewModels.Package;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Packaging;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
@@ -42,6 +44,25 @@ internal class PackagePresentationFactory : IPackagePresentationFactory
MarketplaceUrl = GetMarketplaceUrl(),
};
public PagedViewModel<PackageMigrationStatusResponseModel> CreatePackageMigrationStatusResponseModel(PagedModel<InstalledPackage> installedPackages)
{
InstalledPackage[] installedPackagesAsArray = installedPackages.Items as InstalledPackage[] ?? installedPackages.Items.ToArray();
return new PagedViewModel<PackageMigrationStatusResponseModel>
{
Total = installedPackages.Total,
Items = installedPackagesAsArray
.GroupBy(package => package.PackageName)
.Select(packages => packages.OrderByDescending(package => package.HasPendingMigrations).First())
.Select(package => new PackageMigrationStatusResponseModel
{
PackageName = package.PackageName ?? string.Empty,
HasPendingMigrations = package.HasPendingMigrations,
})
.ToArray(),
};
}
private string GetMarketplaceUrl()
{
var uriBuilder = new UriBuilder(Constants.Marketplace.Url);

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Management.Mapping.Permissions;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.Security;
using Umbraco.Cms.Api.Management.ViewModels;
@@ -22,6 +23,9 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Factories;
/// <summary>
/// Factory for creating user presentation models, implementing <see cref="IUserPresentationFactory"/>.
/// </summary>
public class UserPresentationFactory : IUserPresentationFactory
{
private readonly IEntityService _entityService;
@@ -34,9 +38,11 @@ public class UserPresentationFactory : IUserPresentationFactory
private readonly IPasswordConfigurationPresentationFactory _passwordConfigurationPresentationFactory;
private readonly IBackOfficeExternalLoginProviders _externalLoginProviders;
private readonly SecuritySettings _securitySettings;
private readonly IUserService _userService;
private readonly IContentService _contentService;
private readonly Dictionary<Type, IPermissionPresentationMapper> _permissionPresentationMappersByType;
/// <summary>
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
/// </summary>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public UserPresentationFactory(
IEntityService entityService,
@@ -61,10 +67,15 @@ public class UserPresentationFactory : IUserPresentationFactory
securitySettings,
externalLoginProviders,
StaticServiceProvider.Instance.GetRequiredService<IUserService>(),
StaticServiceProvider.Instance.GetRequiredService<IContentService>())
StaticServiceProvider.Instance.GetRequiredService<IContentService>(),
StaticServiceProvider.Instance.GetRequiredService<IEnumerable<IPermissionPresentationMapper>>())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
/// </summary>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public UserPresentationFactory(
IEntityService entityService,
AppCaches appCaches,
@@ -78,6 +89,44 @@ public class UserPresentationFactory : IUserPresentationFactory
IBackOfficeExternalLoginProviders externalLoginProviders,
IUserService userService,
IContentService contentService)
: this(
entityService,
appCaches,
mediaFileManager,
imageUrlGenerator,
userGroupPresentationFactory,
absoluteUrlBuilder,
emailSender,
passwordConfigurationPresentationFactory,
securitySettings,
externalLoginProviders,
userService,
contentService,
StaticServiceProvider.Instance.GetRequiredService<IEnumerable<IPermissionPresentationMapper>>())
{
}
// TODO (V17): Remove the unused userService and contentService parameters from this constructor.
/// <summary>
/// Initializes a new instance of the <see cref="UserPresentationFactory"/> class.
/// </summary>
public UserPresentationFactory(
IEntityService entityService,
AppCaches appCaches,
MediaFileManager mediaFileManager,
IImageUrlGenerator imageUrlGenerator,
IUserGroupPresentationFactory userGroupPresentationFactory,
IAbsoluteUrlBuilder absoluteUrlBuilder,
IEmailSender emailSender,
IPasswordConfigurationPresentationFactory passwordConfigurationPresentationFactory,
IOptionsSnapshot<SecuritySettings> securitySettings,
IBackOfficeExternalLoginProviders externalLoginProviders,
#pragma warning disable IDE0060 // Remove unused parameter - need to keep these until the next major to avoid breaking changes and/or ambiguous constructor errors
IUserService userService,
IContentService contentService,
#pragma warning restore IDE0060 // Remove unused parameter
IEnumerable<IPermissionPresentationMapper> permissionPresentationMappers)
{
_entityService = entityService;
_appCaches = appCaches;
@@ -89,10 +138,10 @@ public class UserPresentationFactory : IUserPresentationFactory
_externalLoginProviders = externalLoginProviders;
_securitySettings = securitySettings.Value;
_absoluteUrlBuilder = absoluteUrlBuilder;
_userService = userService;
_contentService = contentService;
_permissionPresentationMappersByType = permissionPresentationMappers.ToDictionary(x => x.PresentationModelToHandle);
}
/// <inheritdoc/>
public UserResponseModel CreateResponseModel(IUser user)
{
var responseModel = new UserResponseModel
@@ -123,6 +172,7 @@ public class UserPresentationFactory : IUserPresentationFactory
return responseModel;
}
/// <inheritdoc/>
public UserItemResponseModel CreateItemResponseModel(IUser user) =>
new()
{
@@ -130,9 +180,10 @@ public class UserPresentationFactory : IUserPresentationFactory
Name = user.Name ?? user.Username,
AvatarUrls = user.GetUserAvatarUrls(_appCaches.RuntimeCache, _mediaFileManager, _imageUrlGenerator)
.Select(url => _absoluteUrlBuilder.ToAbsoluteUrl(url).ToString()),
Kind = user.Kind
Kind = user.Kind,
};
/// <inheritdoc/>
public Task<UserCreateModel> CreateCreationModelAsync(CreateUserRequestModel requestModel)
{
var createModel = new UserCreateModel
@@ -142,12 +193,13 @@ public class UserPresentationFactory : IUserPresentationFactory
Name = requestModel.Name,
UserName = requestModel.UserName,
UserGroupKeys = requestModel.UserGroupIds.Select(x => x.Id).ToHashSet(),
Kind = requestModel.Kind
Kind = requestModel.Kind,
};
return Task.FromResult(createModel);
}
/// <inheritdoc/>
public Task<UserInviteModel> CreateInviteModelAsync(InviteUserRequestModel requestModel)
{
var inviteModel = new UserInviteModel
@@ -162,6 +214,7 @@ public class UserPresentationFactory : IUserPresentationFactory
return Task.FromResult(inviteModel);
}
/// <inheritdoc/>
public Task<UserResendInviteModel> CreateResendInviteModelAsync(ResendInviteUserRequestModel requestModel)
{
var inviteModel = new UserResendInviteModel
@@ -173,6 +226,7 @@ public class UserPresentationFactory : IUserPresentationFactory
return Task.FromResult(inviteModel);
}
/// <inheritdoc/>
public Task<CurrentUserConfigurationResponseModel> CreateCurrentUserConfigurationModelAsync()
{
var model = new CurrentUserConfigurationResponseModel
@@ -188,6 +242,7 @@ public class UserPresentationFactory : IUserPresentationFactory
return Task.FromResult(model);
}
/// <inheritdoc/>
public Task<UserConfigurationResponseModel> CreateUserConfigurationModelAsync() =>
Task.FromResult(new UserConfigurationResponseModel
{
@@ -201,6 +256,7 @@ public class UserPresentationFactory : IUserPresentationFactory
AllowTwoFactor = _externalLoginProviders.HasDenyLocalLogin() is false,
});
/// <inheritdoc/>
public Task<UserUpdateModel> CreateUpdateModelAsync(Guid existingUserKey, UpdateUserRequestModel updateModel)
{
var model = new UserUpdateModel
@@ -214,24 +270,24 @@ public class UserPresentationFactory : IUserPresentationFactory
HasContentRootAccess = updateModel.HasDocumentRootAccess,
MediaStartNodeKeys = updateModel.MediaStartNodeIds.Select(x => x.Id).ToHashSet(),
HasMediaRootAccess = updateModel.HasMediaRootAccess,
UserGroupKeys = updateModel.UserGroupIds.Select(x => x.Id).ToHashSet()
};
model.UserGroupKeys = updateModel.UserGroupIds.Select(x => x.Id).ToHashSet();
return Task.FromResult(model);
}
/// <inheritdoc/>
public async Task<CurrentUserResponseModel> CreateCurrentUserResponseModelAsync(IUser user)
{
var presentationUser = CreateResponseModel(user);
var presentationGroups = await _userGroupPresentationFactory.CreateMultipleAsync(user.Groups);
UserResponseModel presentationUser = CreateResponseModel(user);
IEnumerable<UserGroupResponseModel> presentationGroups = await _userGroupPresentationFactory.CreateMultipleAsync(user.Groups);
var languages = presentationGroups.SelectMany(x => x.Languages).Distinct().ToArray();
var mediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches);
var mediaStartNodeKeys = GetKeysFromIds(mediaStartNodeIds, UmbracoObjectTypes.Media);
ISet<ReferenceByIdModel> mediaStartNodeKeys = GetKeysFromIds(mediaStartNodeIds, UmbracoObjectTypes.Media);
var contentStartNodeIds = user.CalculateContentStartNodeIds(_entityService, _appCaches);
var documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
ISet<ReferenceByIdModel> documentStartNodeKeys = GetKeysFromIds(contentStartNodeIds, UmbracoObjectTypes.Document);
var permissions = GetAggregatedGranularPermissions(user, presentationGroups);
HashSet<IPermissionPresentationModel> permissions = GetAggregatedGranularPermissions(user, presentationGroups);
var fallbackPermissions = presentationGroups.SelectMany(x => x.FallbackPermissions).ToHashSet();
var hasAccessToAllLanguages = presentationGroups.Any(x => x.HasAccessToAllLanguages);
@@ -263,70 +319,56 @@ public class UserPresentationFactory : IUserPresentationFactory
private HashSet<IPermissionPresentationModel> GetAggregatedGranularPermissions(IUser user, IEnumerable<UserGroupResponseModel> presentationGroups)
{
var aggregatedPermissions = new HashSet<IPermissionPresentationModel>();
var permissions = presentationGroups.SelectMany(x => x.Permissions).ToHashSet();
return GetAggregatedGranularPermissions(user, permissions);
}
AggregateAndAddDocumentPermissions(user, aggregatedPermissions, permissions);
private HashSet<IPermissionPresentationModel> GetAggregatedGranularPermissions(IUser user, HashSet<IPermissionPresentationModel> permissions)
{
// The raw permission data consists of several permissions for each entity (e.g. document), as permissions are assigned to user groups
// and a user may be part of multiple groups. We want to aggregate this server-side so we return one set of aggregate permissions per
// entity that the client will use.
// We need to handle here not just permissions known to core (e.g. document and document property value permissions), but also custom
// permissions defined by packages or implemetors.
IEnumerable<(Type, IEnumerable<IPermissionPresentationModel>)> permissionModelsByType = permissions
.GroupBy(x => x.GetType())
.Select(x => (x.Key, x.Select(y => y)));
AggregateAndAddDocumentPropertyValuePermissions(aggregatedPermissions, permissions);
var aggregatedPermissions = new HashSet<IPermissionPresentationModel>();
foreach ((Type Type, IEnumerable<IPermissionPresentationModel> Models) permissionModelByType in permissionModelsByType)
{
if (_permissionPresentationMappersByType.TryGetValue(permissionModelByType.Type, out IPermissionPresentationMapper? mapper))
{
IEnumerable<IPermissionPresentationModel> aggregatedModels = mapper.AggregatePresentationModels(user, permissionModelByType.Models);
foreach (IPermissionPresentationModel aggregatedModel in aggregatedModels)
{
aggregatedPermissions.Add(aggregatedModel);
}
}
else
{
IEnumerable<(string Context, ISet<string> Verbs)> groupedModels = permissionModelByType.Models
.Where(x => x is UnknownTypePermissionPresentationModel)
.Cast<UnknownTypePermissionPresentationModel>()
.GroupBy(x => x.Context)
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
foreach ((string context, ISet<string> verbs) in groupedModels)
{
aggregatedPermissions.Add(new UnknownTypePermissionPresentationModel
{
Context = context,
Verbs = verbs
});
}
}
}
return aggregatedPermissions;
}
private void AggregateAndAddDocumentPermissions(IUser user, HashSet<IPermissionPresentationModel> aggregatedPermissions, HashSet<IPermissionPresentationModel> permissions)
{
// The raw permission data consists of several permissions for each document. We want to aggregate this server-side so
// we return one set of aggregate permissions per document that the client will use.
// Get the unique document keys that have granular permissions.
IEnumerable<Guid> documentKeysWithGranularPermissions = permissions
.Where(x => x is DocumentPermissionPresentationModel)
.Cast<DocumentPermissionPresentationModel>()
.Select(x => x.Document.Id)
.Distinct();
foreach (Guid documentKey in documentKeysWithGranularPermissions)
{
// Retrieve the path of the document.
var path = _contentService.GetById(documentKey)?.Path;
if (string.IsNullOrEmpty(path))
{
continue;
}
// With the path we can call the same logic as used server-side for authorizing access to resources.
EntityPermissionSet permissionsForPath = _userService.GetPermissionsForPath(user, path);
aggregatedPermissions.Add(new DocumentPermissionPresentationModel
{
Document = new ReferenceByIdModel(documentKey),
Verbs = permissionsForPath.GetAllPermissions()
});
}
}
private static void AggregateAndAddDocumentPropertyValuePermissions(HashSet<IPermissionPresentationModel> aggregatedPermissions, HashSet<IPermissionPresentationModel> permissions)
{
// We also have permissions for document type/property type combinations.
// These don't have an ancestor relationship that we need to take into account, but should be aggregated
// and included in the set.
IEnumerable<((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet<string> Verbs)> documentTypePropertyTypeKeysWithGranularPermissions = permissions
.Where(x => x is DocumentPropertyValuePermissionPresentationModel)
.Cast<DocumentPropertyValuePermissionPresentationModel>()
.GroupBy(x => (x.DocumentType.Id, x.PropertyType.Id))
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
foreach (((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet<string> Verbs) documentTypePropertyTypeKey in documentTypePropertyTypeKeysWithGranularPermissions)
{
aggregatedPermissions.Add(new DocumentPropertyValuePermissionPresentationModel
{
DocumentType = new ReferenceByIdModel(documentTypePropertyTypeKey.Key.DocumentTypeId),
PropertyType = new ReferenceByIdModel(documentTypePropertyTypeKey.Key.PropertyTypeId),
Verbs = documentTypePropertyTypeKey.Verbs
});
}
}
/// <inheritdoc/>
public Task<CalculatedUserStartNodesResponseModel> CreateCalculatedUserStartNodesResponseModelAsync(IUser user)
{
var mediaStartNodeIds = user.CalculateMediaStartNodeIds(_entityService, _appCaches);
@@ -357,6 +399,6 @@ public class UserPresentationFactory : IUserPresentationFactory
: new HashSet<ReferenceByIdModel>(models);
}
private bool HasRootAccess(IEnumerable<int>? startNodeIds)
private static bool HasRootAccess(IEnumerable<int>? startNodeIds)
=> startNodeIds?.Contains(Constants.System.Root) is true;
}

View File

@@ -58,6 +58,7 @@ public class PackageViewModelMapDefinition : IMapDefinition
}
// Umbraco.Code.MapAll
[Obsolete("Please use the IPackagePresentationFactory instead. Scheduled for removal in V18.")]
private static void Map(InstalledPackage source, PackageMigrationStatusResponseModel target, MapperContext context)
{
if (source.PackageName is not null)

View File

@@ -1,19 +1,50 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
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.Infrastructure.Persistence.Dtos;
using Umbraco.Cms.Infrastructure.Persistence.Mappers;
namespace Umbraco.Cms.Api.Management.Mapping.Permissions;
/// <summary>
/// Mapping required for mapping all the way from viewmodel to database and back.
/// Implements <see cref="IPermissionPresentationMapper" /> for document permissions.
/// </summary>
/// <remarks>
/// This mapping maps all the way from management api to database in one file intentionally, so it is very clear what it takes, if we wanna add permissions to media or other types in the future.
/// </remarks>
public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissionMapper
{
private readonly Lazy<IEntityService> _entityService;
private readonly Lazy<IUserService> _userService;
/// <summary>
/// Initializes a new instance of the <see cref="DocumentPermissionMapper"/> class.
/// </summary>
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")]
public DocumentPermissionMapper()
: this(
StaticServiceProvider.Instance.GetRequiredService<Lazy<IEntityService>>(),
StaticServiceProvider.Instance.GetRequiredService<Lazy<IUserService>>())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DocumentPermissionMapper"/> class.
/// </summary>
public DocumentPermissionMapper(Lazy<IEntityService> entityService, Lazy<IUserService> userService)
{
_entityService = entityService;
_userService = userService;
}
/// <inheritdoc/>
public string Context => DocumentGranularPermission.ContextType;
public IGranularPermission MapFromDto(UserGroup2GranularPermissionDto dto) =>
new DocumentGranularPermission()
{
@@ -21,8 +52,10 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
Permission = dto.Permission,
};
/// <inheritdoc/>
public Type PresentationModelToHandle => typeof(DocumentPermissionPresentationModel);
/// <inheritdoc/>
public IEnumerable<IPermissionPresentationModel> MapManyAsync(IEnumerable<IGranularPermission> granularPermissions)
{
IEnumerable<IGrouping<Guid?, IGranularPermission>> keyGroups = granularPermissions.GroupBy(x => x.Key);
@@ -40,6 +73,7 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
}
}
/// <inheritdoc/>
public IEnumerable<IGranularPermission> MapToGranularPermissions(IPermissionPresentationModel permissionViewModel)
{
if (permissionViewModel is not DocumentPermissionPresentationModel documentPermissionPresentationModel)
@@ -56,12 +90,14 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
};
yield break;
}
foreach (var verb in documentPermissionPresentationModel.Verbs)
{
if (string.IsNullOrEmpty(verb))
{
continue;
}
yield return new DocumentGranularPermission
{
Key = documentPermissionPresentationModel.Document.Id,
@@ -69,4 +105,37 @@ public class DocumentPermissionMapper : IPermissionPresentationMapper, IPermissi
};
}
}
/// <inheritdoc/>
public IEnumerable<IPermissionPresentationModel> AggregatePresentationModels(IUser user, IEnumerable<IPermissionPresentationModel> models)
{
// Get the unique document keys that have granular permissions.
Guid[] documentKeysWithGranularPermissions = models
.Cast<DocumentPermissionPresentationModel>()
.Select(x => x.Document.Id)
.Distinct()
.ToArray();
// Batch retrieve all documents by their keys.
var documents = _entityService.Value.GetAll<IContent>(documentKeysWithGranularPermissions)
.ToDictionary(doc => doc.Key, doc => doc.Path);
// Iterate through each document key that has granular permissions.
foreach (Guid documentKey in documentKeysWithGranularPermissions)
{
// Retrieve the path from the pre-fetched documents.
if (!documents.TryGetValue(documentKey, out var path) || string.IsNullOrEmpty(path))
{
continue;
}
// With the path we can call the same logic as used server-side for authorizing access to resources.
EntityPermissionSet permissionsForPath = _userService.Value.GetPermissionsForPath(user, path);
yield return new DocumentPermissionPresentationModel
{
Document = new ReferenceByIdModel(documentKey),
Verbs = permissionsForPath.GetAllPermissions(),
};
}
}
}

View File

@@ -1,5 +1,6 @@
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.Membership.Permissions;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Cms.Infrastructure.Persistence.Mappers;
@@ -7,10 +8,18 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Mapping.Permissions;
/// <summary>
/// Implements <see cref="IPermissionPresentationMapper" /> for document property value permissions.
/// </summary>
public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapper, IPermissionMapper
{
/// <inheritdoc/>
public string Context => DocumentPropertyValueGranularPermission.ContextType;
/// <inheritdoc/>
public Type PresentationModelToHandle => typeof(DocumentPropertyValuePermissionPresentationModel);
/// <inheritdoc/>
public IGranularPermission MapFromDto(UserGroup2GranularPermissionDto dto) =>
new DocumentPropertyValueGranularPermission()
{
@@ -18,8 +27,7 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
Permission = dto.Permission,
};
public Type PresentationModelToHandle => typeof(DocumentPropertyValuePermissionPresentationModel);
/// <inheritdoc/>
public IEnumerable<IPermissionPresentationModel> MapManyAsync(IEnumerable<IGranularPermission> granularPermissions)
{
var intermediate = granularPermissions.Where(p => p.Key.HasValue).Select(p =>
@@ -50,6 +58,7 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
}
}
/// <inheritdoc/>
public IEnumerable<IGranularPermission> MapToGranularPermissions(IPermissionPresentationModel permissionViewModel)
{
if (permissionViewModel is not DocumentPropertyValuePermissionPresentationModel documentTypePermissionPresentationModel)
@@ -66,4 +75,23 @@ public class DocumentPropertyValuePermissionMapper : IPermissionPresentationMapp
};
}
}
/// <inheritdoc/>
public IEnumerable<IPermissionPresentationModel> AggregatePresentationModels(IUser user, IEnumerable<IPermissionPresentationModel> models)
{
IEnumerable<((Guid DocumentTypeId, Guid PropertyTypeId) Key, ISet<string> Verbs)> groupedModels = models
.Cast<DocumentPropertyValuePermissionPresentationModel>()
.GroupBy(x => (x.DocumentType.Id, x.PropertyType.Id))
.Select(x => (x.Key, (ISet<string>)x.SelectMany(y => y.Verbs).Distinct().ToHashSet()));
foreach (((Guid DocumentTypeId, Guid PropertyTypeId) key, ISet<string> verbs) in groupedModels)
{
yield return new DocumentPropertyValuePermissionPresentationModel
{
DocumentType = new ReferenceByIdModel(key.DocumentTypeId),
PropertyType = new ReferenceByIdModel(key.PropertyTypeId),
Verbs = verbs
};
}
}
}

View File

@@ -1,15 +1,36 @@
using Umbraco.Cms.Api.Management.ViewModels.UserGroup.Permissions;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.Membership.Permissions;
namespace Umbraco.Cms.Api.Management.Mapping.Permissions;
/// <summary>
/// Defines methods for mapping and aggregating granular permissions to presentation models.
/// </summary>
public interface IPermissionPresentationMapper
{
/// <summary>
/// Gets the context type for the permissions being handled by this mapper.
/// </summary>
string Context { get; }
/// <summary>
/// Gets the type of the presentation model that this mapper handles.
/// </summary>
Type PresentationModelToHandle { get; }
/// <summary>
/// Maps a granular permission entity to a granular permission model.
/// </summary>
IEnumerable<IPermissionPresentationModel> MapManyAsync(IEnumerable<IGranularPermission> granularPermissions);
/// <summary>
/// Maps a granular permission to a granular permission model.
/// </summary>
IEnumerable<IGranularPermission> MapToGranularPermissions(IPermissionPresentationModel permissionViewModel);
/// <summary>
/// Aggregates multiple permission presentation models into a collection containing only one item per entity with aggregated permissions.
/// </summary>
IEnumerable<IPermissionPresentationModel> AggregatePresentationModels(IUser user, IEnumerable<IPermissionPresentationModel> models) => [];
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Umbraco.Cms.Core.Configuration.Models.ContentSettings.get_Error404Collection</Target>
<Left>lib/net9.0/Umbraco.Core.dll</Left>
<Right>lib/net9.0/Umbraco.Core.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>

View File

@@ -25,8 +25,8 @@ public class TypeFinderConfig : ITypeFinderConfig
var s = _settings.AssembliesAcceptingLoadExceptions;
return _assembliesAcceptingLoadExceptions = string.IsNullOrWhiteSpace(s)
? Array.Empty<string>()
: s.Split(',').Select(x => x.Trim()).ToArray();
? []
: s.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
}
}
}

View File

@@ -50,7 +50,7 @@ public class ContentSettings
/// <summary>
/// Gets or sets a value for the collection of error pages.
/// </summary>
public ISet<ContentErrorPage> Error404Collection { get; set; } = new HashSet<ContentErrorPage>();
public IEnumerable<ContentErrorPage> Error404Collection { get; set; } = [];
/// <summary>
/// Gets or sets a value for the preview badge mark-up.

View File

@@ -4,10 +4,23 @@
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// The serializer type that nucache uses to persist documents in the database.
/// The serializer type that the published content cache uses to persist documents in the database.
/// </summary>
public enum NuCacheSerializerType
{
MessagePack = 1, // Default
/// <summary>
/// The default serializer type, which uses MessagePack for serialization.
/// </summary>
MessagePack = 1,
/// <summary>
/// The legacy JSON serializer type, which uses JSON for serialization.
/// </summary>
/// <remarks>
/// This option was provided for backward compatibility for the Umbraco cache implementation used from Umbraco 8 to 14 (NuCache).
/// It is no longer supported with the cache implementation from Umbraco 15 based on .NET's Hybrid cache.
/// Use the faster and more compact <see cref="MessagePack"/> instead.
/// The option is kept available only for a more readable format suitable for testing purposes.
/// </remarks>
JSON = 2,
}

View File

@@ -5,6 +5,9 @@ using System.Globalization;
namespace Umbraco.Extensions;
/// <summary>
/// Provides Extensions for <see cref="DateTime"/>.
/// </summary>
public static class DateTimeExtensions
{
/// <summary>
@@ -18,6 +21,7 @@ public static class DateTimeExtensions
Hour,
Minute,
Second,
Millisecond,
}
/// <summary>
@@ -35,32 +39,15 @@ public static class DateTimeExtensions
/// <param name="truncateTo">The level to truncate the date to.</param>
/// <returns>The truncated date.</returns>
public static DateTime TruncateTo(this DateTime dt, DateTruncate truncateTo)
=> truncateTo switch
{
if (truncateTo == DateTruncate.Year)
{
return new DateTime(dt.Year, 1, 1, 0, 0, 0, dt.Kind);
}
if (truncateTo == DateTruncate.Month)
{
return new DateTime(dt.Year, dt.Month, 1, 0, 0, 0, dt.Kind);
}
if (truncateTo == DateTruncate.Day)
{
return new DateTime(dt.Year, dt.Month, dt.Day, 0, 0, 0, dt.Kind);
}
if (truncateTo == DateTruncate.Hour)
{
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0, dt.Kind);
}
if (truncateTo == DateTruncate.Minute)
{
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, 0, dt.Kind);
}
return new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Kind);
}
DateTruncate.Year => new DateTime(dt.Year, 1, 1, 0, 0, 0, dt.Kind),
DateTruncate.Month => new DateTime(dt.Year, dt.Month, 1, 0, 0, 0, dt.Kind),
DateTruncate.Day => new DateTime(dt.Year, dt.Month, dt.Day, 0, 0, 0, dt.Kind),
DateTruncate.Hour => new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, 0, 0, dt.Kind),
DateTruncate.Minute => new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, 0, dt.Kind),
DateTruncate.Second => new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Kind),
DateTruncate.Millisecond => new DateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Millisecond, dt.Kind),
_ => throw new ArgumentOutOfRangeException(nameof(truncateTo), truncateTo, "Invalid truncation level"),
};
}

View File

@@ -509,8 +509,7 @@ public static class StringExtensions
var convertToHex = input.ConvertToHex();
var hexLength = convertToHex.Length < 32 ? convertToHex.Length : 32;
var hex = convertToHex[..hexLength].PadLeft(32, '0');
Guid output = Guid.Empty;
return Guid.TryParse(hex, out output) ? output : Guid.Empty;
return Guid.TryParse(hex, out Guid output) ? output : Guid.Empty;
}
/// <summary>

View File

@@ -36,7 +36,6 @@ public class UserSettingsFactory : IUserSettingsFactory
private IEnumerable<ConsentLevelModel> CreateConsentLevelModels() =>
Enum.GetValues<TelemetryLevel>()
.ToList()
.Select(level => new ConsentLevelModel
{
Level = level,

View File

@@ -58,8 +58,7 @@ public class LoggingConfiguration : ILoggingConfiguration
public string LogFileNameFormat { get; }
/// <inheritdoc/>
public string[] GetLogFileNameFormatArguments() => _logFileNameFormatArguments.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
public string[] GetLogFileNameFormatArguments() => _logFileNameFormatArguments.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(GetValue)
.ToArray();

View File

@@ -71,12 +71,12 @@ public abstract class BlockEditorDataConverter<TValue, TLayout>
// this method is only meant to have any effect when migrating block editor values
// from the original format to the new, variant enabled format
private void AmendExpose(TValue value)
=> value.Expose = value.ContentData.Select(cd => new BlockItemVariation(cd.Key, null, null)).ToList();
private static void AmendExpose(TValue value)
=> value.Expose = value.ContentData.ConvertAll(cd => new BlockItemVariation(cd.Key, null, null));
// this method is only meant to have any effect when migrating block editor values
// from the original format to the new, variant enabled format
private bool ConvertOriginalBlockFormat(List<BlockItemData> blockItemDatas)
private static bool ConvertOriginalBlockFormat(List<BlockItemData> blockItemDatas)
{
var converted = false;
foreach (BlockItemData blockItemData in blockItemDatas)

View File

@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the IContentService when the SavedBlueprint method is called in the API.
/// </summary>
@@ -14,8 +15,21 @@ public sealed class ContentSavedBlueprintNotification : ObjectNotification<ICont
: base(target, messages)
{
}
public ContentSavedBlueprintNotification(IContent target, IContent? createdFromContent, EventMessages messages)
: base(target, messages)
{
CreatedFromContent = createdFromContent;
}
/// <summary>
/// Getting the saved blueprint <see cref="IContent"/> object.
/// </summary>
public IContent SavedBlueprint => Target;
/// <summary>
/// Getting the saved blueprint <see cref="IContent"/> object.
/// </summary>
public IContent? CreatedFromContent { get; }
}

View File

@@ -19,6 +19,17 @@ public interface IEntityRepository : IRepository
IEnumerable<IEntitySlim> GetAll(Guid objectType, params Guid[] keys);
/// <summary>
/// Gets sibling entities of a specified target entity, within a given range before and after the target, ordered as specified.
/// </summary>
/// <param name="objectType">The object type key of the entities.</param>
/// <param name="targetKey">The key of the target entity whose siblings are to be retrieved.</param>
/// <param name="before">The number of siblings to retrieve before the target entity.</param>
/// <param name="after">The number of siblings to retrieve after the target entity.</param>
/// <param name="ordering">The ordering to apply to the siblings.</param>
/// <returns>Enumerable of sibling entities.</returns>
IEnumerable<IEntitySlim> GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering) => [];
/// <summary>
/// Gets entities for a query
/// </summary>

View File

@@ -115,7 +115,7 @@ public class MultiNodeTreePickerValueConverter : PropertyValueConverterBase, IDe
udi,
ref objectType,
UmbracoObjectTypes.Document,
id => _contentCache.GetById(guidUdi.Guid));
id => _contentCache.GetById(preview, guidUdi.Guid));
break;
case Constants.UdiEntityType.Media:
multiNodeTreePickerItem = GetPublishedContent(
@@ -205,7 +205,7 @@ public class MultiNodeTreePickerValueConverter : PropertyValueConverterBase, IDe
{
Constants.UdiEntityType.Document => entityTypeUdis.Select(udi =>
{
IPublishedContent? content = _contentCache.GetById(udi.Guid);
IPublishedContent? content = _contentCache.GetById(preview, udi.Guid);
return content != null
? _apiContentBuilder.Build(content)
: null;

View File

@@ -256,14 +256,14 @@ namespace Umbraco.Cms.Core.Routing
// if a culture is specified, then try to get domains for that culture
// (else cultureDomains will be null)
// do NOT specify a default culture, else it would pick those domains
IReadOnlyCollection<DomainAndUri>? cultureDomains = SelectByCulture(domainsAndUris, culture, defaultCulture: null);
IReadOnlyList<DomainAndUri>? cultureDomains = SelectByCulture(domainsAndUris, culture, defaultCulture: null);
IReadOnlyCollection<DomainAndUri> considerForBaseDomains = domainsAndUris;
if (cultureDomains != null)
{
if (cultureDomains.Count == 1)
{
// only 1, return
return cultureDomains.First();
return cultureDomains[0];
}
// else restrict to those domains, for base lookup
@@ -272,11 +272,11 @@ namespace Umbraco.Cms.Core.Routing
// look for domains that would be the base of the uri
// we need to order so example.com/foo matches before example.com/
IReadOnlyCollection<DomainAndUri> baseDomains = SelectByBase(considerForBaseDomains.OrderByDescending(d => d.Uri.ToString()).ToList(), uri, culture);
List<DomainAndUri> baseDomains = SelectByBase(considerForBaseDomains.OrderByDescending(d => d.Uri.ToString()).ToArray(), uri, culture);
if (baseDomains.Count > 0)
{
// found, return
return baseDomains.First();
return baseDomains[0];
}
// if nothing works, then try to run the filter to select a domain
@@ -296,7 +296,7 @@ namespace Umbraco.Cms.Core.Routing
private static bool MatchesCulture(DomainAndUri domain, string? culture)
=> culture == null || domain.Culture.InvariantEquals(culture);
private static IReadOnlyCollection<DomainAndUri> SelectByBase(IReadOnlyCollection<DomainAndUri> domainsAndUris, Uri uri, string? culture)
private static List<DomainAndUri> SelectByBase(DomainAndUri[] domainsAndUris, Uri uri, string? culture)
{
// look for domains that would be the base of the uri
// ie current is www.example.com/foo/bar, look for domain www.example.com
@@ -314,7 +314,7 @@ namespace Umbraco.Cms.Core.Routing
return baseDomains;
}
private static IReadOnlyCollection<DomainAndUri>? SelectByCulture(IReadOnlyCollection<DomainAndUri> domainsAndUris, string? culture, string? defaultCulture)
private static List<DomainAndUri>? SelectByCulture(DomainAndUri[] domainsAndUris, string? culture, string? defaultCulture)
{
// we try our best to match cultures, but may end with a bogus domain
if (culture is not null)
@@ -434,13 +434,18 @@ namespace Umbraco.Cms.Core.Routing
private static Domain? FindDomainInPath(IEnumerable<Domain>? domains, string path, int? rootNodeId, bool isWildcard)
{
if (domains is null)
{
return null;
}
var stopNodeId = rootNodeId ?? -1;
return path.Split(Constants.CharArrays.Comma)
.Reverse()
.Select(s => int.Parse(s, CultureInfo.InvariantCulture))
.TakeWhile(id => id != stopNodeId)
.Select(id => domains?.FirstOrDefault(d => d.ContentId == id && d.IsWildcard == isWildcard))
.Select(id => domains.FirstOrDefault(d => d.ContentId == id && d.IsWildcard == isWildcard))
.FirstOrDefault(domain => domain is not null);
}

View File

@@ -45,11 +45,13 @@ internal sealed class ContentBlueprintEditingService
return Task.FromResult<IContent?>(null);
}
IContent scaffold = blueprint.DeepCloneWithResetIdentities();
using ICoreScope scope = CoreScopeProvider.CreateCoreScope();
scope.Notifications.Publish(new ContentScaffoldedNotification(blueprint, blueprint, Constants.System.Root, new EventMessages()));
scope.Notifications.Publish(new ContentScaffoldedNotification(blueprint, scaffold, Constants.System.Root, new EventMessages()));
scope.Complete();
return Task.FromResult<IContent?>(blueprint);
return Task.FromResult<IContent?>(scaffold);
}
public async Task<Attempt<PagedModel<IContent>?, ContentEditingOperationStatus>> GetPagedByContentTypeAsync(Guid contentTypeKey, int skip, int take)
@@ -112,7 +114,7 @@ internal sealed class ContentBlueprintEditingService
// Create Blueprint
var currentUserId = await GetUserIdAsync(userKey);
IContent blueprint = ContentService.CreateContentFromBlueprint(content, name, currentUserId);
IContent blueprint = ContentService.CreateBlueprintFromContent(content, name, currentUserId);
if (key.HasValue)
{
@@ -120,7 +122,7 @@ internal sealed class ContentBlueprintEditingService
}
// Save blueprint
await SaveAsync(blueprint, userKey);
await SaveAsync(blueprint, userKey, content);
return Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, new ContentCreateResult { Content = blueprint });
}
@@ -238,10 +240,10 @@ internal sealed class ContentBlueprintEditingService
protected override OperationResult? Delete(IContent content, int userId) => throw new NotImplementedException();
private async Task SaveAsync(IContent blueprint, Guid userKey)
private async Task SaveAsync(IContent blueprint, Guid userKey, IContent? createdFromContent = null)
{
var currentUserId = await GetUserIdAsync(userKey);
ContentService.SaveBlueprint(blueprint, currentUserId);
ContentService.SaveBlueprint(blueprint, createdFromContent, currentUserId);
}
private bool ValidateUniqueName(string name, IContent content)

View File

@@ -3611,6 +3611,9 @@ public class ContentService : RepositoryService, IContentService
}
public void SaveBlueprint(IContent content, int userId = Constants.Security.SuperUserId)
=> SaveBlueprint(content, null, userId);
public void SaveBlueprint(IContent content, IContent? createdFromContent, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
@@ -3631,7 +3634,7 @@ public class ContentService : RepositoryService, IContentService
Audit(AuditType.Save, userId, content.Id, $"Saved content template: {content.Name}");
scope.Notifications.Publish(new ContentSavedBlueprintNotification(content, evtMsgs));
scope.Notifications.Publish(new ContentSavedBlueprintNotification(content, createdFromContent, evtMsgs));
scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshNode, evtMsgs));
scope.Complete();
@@ -3654,12 +3657,12 @@ public class ContentService : RepositoryService, IContentService
private static readonly string?[] ArrayOfOneNullString = { null };
public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId)
public IContent CreateBlueprintFromContent(
IContent blueprint,
string name,
int userId = Constants.Security.SuperUserId)
{
if (blueprint == null)
{
throw new ArgumentNullException(nameof(blueprint));
}
ArgumentNullException.ThrowIfNull(blueprint);
IContentType contentType = GetContentType(blueprint.ContentType.Alias);
var content = new Content(name, -1, contentType);
@@ -3672,8 +3675,7 @@ public class ContentService : RepositoryService, IContentService
if (blueprint.CultureInfos?.Count > 0)
{
cultures = blueprint.CultureInfos.Values.Select(x => x.Culture);
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
if (blueprint.CultureInfos.TryGetValue(_languageRepository.GetDefaultIsoCode(), out ContentCultureInfos defaultCulture))
{
defaultCulture.Name = name;
@@ -3681,7 +3683,6 @@ public class ContentService : RepositoryService, IContentService
scope.Complete();
}
}
DateTime now = DateTime.Now;
foreach (var culture in cultures)
@@ -3701,6 +3702,11 @@ public class ContentService : RepositoryService, IContentService
return content;
}
/// <inheritdoc />
[Obsolete("Use IContentBlueprintEditingService.GetScaffoldedAsync() instead. Scheduled for removal in V18.")]
public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId)
=> CreateBlueprintFromContent(blueprint, name, userId);
public IEnumerable<IContent> GetBlueprintsForContentTypes(params int[] contentTypeId)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))

View File

@@ -50,7 +50,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
var currentCompositionAliases = currentCompositeKeys.Any()
? allContentTypes.Where(ct => currentCompositeKeys.Contains(ct.Key)).Select(ct => ct.Alias).ToArray()
: Array.Empty<string>();
: [];
ContentTypeAvailableCompositionsResults availableCompositions = _contentTypeService.GetAvailableCompositeContentTypes(
contentType,
@@ -142,9 +142,9 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
return Attempt.SucceedWithStatus<TContentType?, ContentTypeOperationStatus>(ContentTypeOperationStatus.Success, contentType);
}
protected virtual async Task<ContentTypeOperationStatus> AdditionalCreateValidationAsync(
protected virtual Task<ContentTypeOperationStatus> AdditionalCreateValidationAsync(
ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)
=> await Task.FromResult(ContentTypeOperationStatus.Success);
=> Task.FromResult(ContentTypeOperationStatus.Success);
#region Sanitization
@@ -346,7 +346,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
return ContentTypeOperationStatus.Success;
}
private ContentTypeOperationStatus ValidateProperties(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, IContentTypeComposition[] allContentTypeCompositions)
private static ContentTypeOperationStatus ValidateProperties(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, IContentTypeComposition[] allContentTypeCompositions)
{
// grab all content types used for composition and/or inheritance
Guid[] allCompositionKeys = KeysForCompositionTypes(model, CompositionType.Composition, CompositionType.Inheritance);
@@ -365,7 +365,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
return ContentTypeOperationStatus.Success;
}
private ContentTypeOperationStatus ValidateContainers(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, IContentTypeComposition[] allContentTypeCompositions)
private static ContentTypeOperationStatus ValidateContainers(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, IContentTypeComposition[] allContentTypeCompositions)
{
if (model.Containers.Any(container => Enum.TryParse<PropertyGroupType>(container.Type, out _) is false))
{
@@ -420,7 +420,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
return ContentTypeAliasIsInUse(alias) is false;
}
private bool IsReservedContentTypeAlias(string alias)
private static bool IsReservedContentTypeAlias(string alias)
{
var reservedAliases = new[] { "system" };
return reservedAliases.InvariantContains(alias);
@@ -474,7 +474,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
return contentType;
}
private void UpdateAllowedContentTypes(
private static void UpdateAllowedContentTypes(
TContentType contentType,
ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model,
IContentTypeComposition[] allContentTypeCompositions)
@@ -573,7 +573,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
}
}
private string PropertyGroupAlias(string? containerName)
private static string PropertyGroupAlias(string? containerName)
{
if (containerName.IsNullOrWhiteSpace())
{
@@ -625,7 +625,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
return propertyType;
}
private void UpdateCompositions(
private static void UpdateCompositions(
TContentType contentType,
ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model,
IContentTypeComposition[] allContentTypeCompositions)
@@ -658,7 +658,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
}
}
private void UpdateParentContentType(
private static void UpdateParentContentType(
TContentType contentType,
ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model,
IContentTypeComposition[] allContentTypeCompositions)
@@ -677,7 +677,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
#region Shared between model validation and model update
private Guid[] GetDataTypeKeys(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)
private static Guid[] GetDataTypeKeys(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)
=> model.Properties.Select(property => property.DataTypeKey).Distinct().ToArray();
private async Task<IDataType[]> GetDataTypesAsync(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model)
@@ -685,7 +685,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
Guid[] dataTypeKeys = GetDataTypeKeys(model);
return dataTypeKeys.Any()
? (await _dataTypeService.GetAllAsync(GetDataTypeKeys(model))).ToArray()
: Array.Empty<IDataType>();
: [];
}
private int? GetParentId(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, Guid? containerKey)
@@ -711,7 +711,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
return Constants.System.Root;
}
private Guid[] KeysForCompositionTypes(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, params CompositionType[] compositionTypes)
private static Guid[] KeysForCompositionTypes(ContentTypeEditingModelBase<TPropertyTypeModel, TPropertyTypeContainer> model, params CompositionType[] compositionTypes)
=> model.Compositions
.Where(c => compositionTypes.Contains(c.CompositionType))
.Select(c => c.Key)

View File

@@ -20,27 +20,27 @@ public class ElementSwitchValidator : IElementSwitchValidator
_dataTypeService = dataTypeService;
}
public async Task<bool> AncestorsAreAlignedAsync(IContentType contentType)
public 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;
return Task.FromResult(true);
}
// if there are any ancestors where IsElement is different from the contentType, the validation fails
return await Task.FromResult(_contentTypeService.GetMany(ancestorIds)
return Task.FromResult(_contentTypeService.GetMany(ancestorIds)
.Any(ancestor => ancestor.IsElement != contentType.IsElement) is false);
}
public async Task<bool> DescendantsAreAlignedAsync(IContentType contentType)
public 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);
return Task.FromResult(descendants.Any(descendant => descendant.IsElement != contentType.IsElement) is false);
}
public async Task<bool> ElementToDocumentNotUsedInBlockStructuresAsync(IContentTypeBase contentType)
@@ -59,8 +59,8 @@ public class ElementSwitchValidator : IElementSwitchValidator
.ConfiguredElementTypeKeys().Contains(contentType.Key)) is false;
}
public async Task<bool> DocumentToElementHasNoContentAsync(IContentTypeBase contentType) =>
public 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);
Task.FromResult(_contentTypeService.HasContentNodes(contentType.Id) is false);
}

View File

@@ -355,13 +355,13 @@ namespace Umbraco.Cms.Core.Services.Implement
}
/// <inheritdoc />
public async Task<IEnumerable<IDataType>> GetByEditorAliasAsync(string[] propertyEditorAlias)
public 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);
return Task.FromResult(dataTypes);
}
/// <inheritdoc />
@@ -396,7 +396,7 @@ namespace Umbraco.Cms.Core.Services.Implement
return;
}
ConvertMissingEditorsOfDataTypesToLabels(new[] { dataType });
ConvertMissingEditorsOfDataTypesToLabels([dataType]);
}
private void ConvertMissingEditorsOfDataTypesToLabels(IEnumerable<IDataType> dataTypes)
@@ -763,7 +763,7 @@ namespace Umbraco.Cms.Core.Services.Implement
var totalItems = combinedUsages.Count;
// Create the page of items.
IList<(string PropertyAlias, Udi Udi)> pagedUsages = combinedUsages
List<(string PropertyAlias, Udi Udi)> pagedUsages = combinedUsages
.OrderBy(x => x.Udi.EntityType) // Document types first, then media types, then member types.
.ThenBy(x => x.PropertyAlias)
.Skip(skip)
@@ -772,7 +772,7 @@ namespace Umbraco.Cms.Core.Services.Implement
// Get the content types for the UDIs referenced in the page of items to construct the response from.
// They could be document, media or member types.
IList<IContentTypeComposition> contentTypes = GetReferencedContentTypes(pagedUsages);
List<IContentTypeComposition> contentTypes = GetReferencedContentTypes(pagedUsages);
IEnumerable<RelationItemModel> relations = pagedUsages
.Select(x =>
@@ -807,7 +807,7 @@ namespace Umbraco.Cms.Core.Services.Implement
return Task.FromResult(pagedModel);
}
private IList<IContentTypeComposition> GetReferencedContentTypes(IList<(string PropertyAlias, Udi Udi)> pagedUsages)
private List<IContentTypeComposition> GetReferencedContentTypes(List<(string PropertyAlias, Udi Udi)> pagedUsages)
{
IEnumerable<IContentTypeComposition> documentTypes = GetContentTypes(
pagedUsages,
@@ -845,10 +845,10 @@ namespace Umbraco.Cms.Core.Services.Implement
{
IConfigurationEditor? configurationEditor = dataType.Editor?.GetConfigurationEditor();
return configurationEditor == null
? new[]
{
?
[
new ValidationResult($"Data type with editor alias {dataType.EditorAlias} does not have a configuration editor")
}
]
: configurationEditor.Validate(dataType.ConfigurationData);
}

View File

@@ -44,26 +44,51 @@ public class DocumentUrlService : IDocumentUrlService
/// <summary>
/// Model used to cache a single published document along with all it's URL segments.
/// </summary>
private class PublishedDocumentUrlSegments
/// <remarks>Internal for the purpose of unit and benchmark testing.</remarks>
internal class PublishedDocumentUrlSegments
{
/// <summary>
/// Gets or sets the document key.
/// </summary>
public required Guid DocumentKey { get; set; }
/// <summary>
/// Gets or sets the language Id.
/// </summary>
public required int LanguageId { get; set; }
/// <summary>
/// Gets or sets the collection of <see cref="UrlSegment"/> for the document, language and state.
/// </summary>
public required IList<UrlSegment> UrlSegments { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the document is a draft version or not.
/// </summary>
public required bool IsDraft { get; set; }
/// <summary>
/// Model used to represent a URL segment for a document in the cache.
/// </summary>
public class UrlSegment
{
/// <summary>
/// Initializes a new instance of the <see cref="UrlSegment"/> class.
/// </summary>
public UrlSegment(string segment, bool isPrimary)
{
Segment = segment;
IsPrimary = isPrimary;
}
/// <summary>
/// Gets the URL segment string.
/// </summary>
public string Segment { get; }
/// <summary>
/// Gets a value indicating whether this URL segment is the primary one for the document, language and state.
/// </summary>
public bool IsPrimary { get; }
}
}
@@ -116,14 +141,18 @@ public class DocumentUrlService : IDocumentUrlService
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
if (ShouldRebuildUrls())
{
_logger.LogInformation("Rebuilding all URLs.");
_logger.LogInformation("Rebuilding all document URLs.");
await RebuildAllUrlsAsync();
}
_logger.LogInformation("Caching document URLs.");
IEnumerable<PublishedDocumentUrlSegment> publishedDocumentUrlSegments = _documentUrlRepository.GetAll();
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
var languageIdToIsoCode = languages.ToDictionary(x => x.Id, x => x.IsoCode);
int numberOfCachedUrls = 0;
foreach (PublishedDocumentUrlSegments publishedDocumentUrlSegment in ConvertToCacheModel(publishedDocumentUrlSegments))
{
if (cancellationToken.IsCancellationRequested)
@@ -134,9 +163,12 @@ public class DocumentUrlService : IDocumentUrlService
if (languageIdToIsoCode.TryGetValue(publishedDocumentUrlSegment.LanguageId, out var isoCode))
{
UpdateCache(_coreScopeProvider.Context!, publishedDocumentUrlSegment, isoCode);
numberOfCachedUrls++;
}
}
_logger.LogInformation("Cached {NumberOfUrls} document URLs.", numberOfCachedUrls);
_isInitialized = true;
scope.Complete();
}
@@ -168,45 +200,40 @@ public class DocumentUrlService : IDocumentUrlService
scope.Complete();
}
private static IEnumerable<PublishedDocumentUrlSegments> ConvertToCacheModel(IEnumerable<PublishedDocumentUrlSegment> publishedDocumentUrlSegments)
/// <summary>
/// Converts a collection of <see cref="PublishedDocumentUrlSegment"/> to a collection of <see cref="PublishedDocumentUrlSegments"/> for caching purposes.
/// </summary>
/// <param name="publishedDocumentUrlSegments">The collection of <see cref="PublishedDocumentUrlSegment"/> retrieved from the database on startup.</param>
/// <returns>The collection of cache models.</returns>
/// <remarks>Internal for the purpose of unit and benchmark testing.</remarks>
internal static IEnumerable<PublishedDocumentUrlSegments> ConvertToCacheModel(IEnumerable<PublishedDocumentUrlSegment> publishedDocumentUrlSegments)
{
var cacheModels = new List<PublishedDocumentUrlSegments>();
var cacheModels = new Dictionary<(Guid DocumentKey, int LanguageId, bool IsDraft), PublishedDocumentUrlSegments>();
foreach (PublishedDocumentUrlSegment model in publishedDocumentUrlSegments)
{
PublishedDocumentUrlSegments? existingCacheModel = GetModelFromCache(cacheModels, model);
if (existingCacheModel is null)
(Guid DocumentKey, int LanguageId, bool IsDraft) key = (model.DocumentKey, model.LanguageId, model.IsDraft);
if (!cacheModels.TryGetValue(key, out PublishedDocumentUrlSegments? existingCacheModel))
{
cacheModels.Add(new PublishedDocumentUrlSegments
cacheModels[key] = new PublishedDocumentUrlSegments
{
DocumentKey = model.DocumentKey,
LanguageId = model.LanguageId,
UrlSegments = [new PublishedDocumentUrlSegments.UrlSegment(model.UrlSegment, model.IsPrimary)],
IsDraft = model.IsDraft,
});
};
}
else
{
existingCacheModel.UrlSegments = GetUpdatedUrlSegments(existingCacheModel.UrlSegments, model.UrlSegment, model.IsPrimary);
}
}
return cacheModels;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static PublishedDocumentUrlSegments? GetModelFromCache(List<PublishedDocumentUrlSegments> cacheModels, PublishedDocumentUrlSegment model)
=> cacheModels
.SingleOrDefault(x => x.DocumentKey == model.DocumentKey && x.LanguageId == model.LanguageId && x.IsDraft == model.IsDraft);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static IList<PublishedDocumentUrlSegments.UrlSegment> GetUpdatedUrlSegments(IList<PublishedDocumentUrlSegments.UrlSegment> urlSegments, string segment, bool isPrimary)
if (existingCacheModel.UrlSegments.Any(x => x.Segment == model.UrlSegment) is false)
{
if (urlSegments.FirstOrDefault(x => x.Segment == segment) is null)
{
urlSegments.Add(new PublishedDocumentUrlSegments.UrlSegment(segment, isPrimary));
existingCacheModel.UrlSegments.Add(new PublishedDocumentUrlSegments.UrlSegment(model.UrlSegment, model.IsPrimary));
}
}
}
return urlSegments;
return cacheModels.Values;
}
private void RemoveFromCache(IScopeContext scopeContext, Guid documentKey, string isoCode, bool isDraft)

View File

@@ -318,6 +318,39 @@ public class EntityService : RepositoryService, IEntityService
return children;
}
/// <inheritdoc />
public IEnumerable<IEntitySlim> GetSiblings(
Guid key,
UmbracoObjectTypes objectType,
int before,
int after,
Ordering? ordering = null)
{
if (before < 0)
{
throw new ArgumentOutOfRangeException(nameof(before), "The 'before' parameter must be greater than or equal to 0.");
}
if (after < 0)
{
throw new ArgumentOutOfRangeException(nameof(after), "The 'after' parameter must be greater than or equal to 0.");
}
ordering ??= new Ordering("sortOrder");
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IEnumerable<IEntitySlim> siblings = _entityRepository.GetSiblings(
objectType.GetGuid(),
key,
before,
after,
ordering);
scope.Complete();
return siblings;
}
/// <inheritdoc />
public virtual IEnumerable<IEntitySlim> GetDescendants(int id)
{

View File

@@ -52,18 +52,18 @@ internal abstract class EntityTypeContainerService<TTreeEntity, TEntityContainer
/// <inheritdoc />
public async Task<IEnumerable<EntityContainer>> GetAsync(string name, int level)
public Task<IEnumerable<EntityContainer>> GetAsync(string name, int level)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
ReadLock(scope);
return await Task.FromResult(_entityContainerRepository.Get(name, level));
return Task.FromResult(_entityContainerRepository.Get(name, level));
}
/// <inheritdoc />
public async Task<IEnumerable<EntityContainer>> GetAllAsync()
public Task<IEnumerable<EntityContainer>> GetAllAsync()
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
ReadLock(scope);
return await Task.FromResult(_entityContainerRepository.GetMany());
return Task.FromResult(_entityContainerRepository.GetMany());
}
/// <inheritdoc />

View File

@@ -34,14 +34,37 @@ public interface ICacheInstructionService
void DeliverInstructionsInBatches(IEnumerable<RefreshInstruction> instructions, string localIdentity);
/// <summary>
/// Processes and then prunes pending database cache instructions.
/// Processes pending database cache instructions.
/// </summary>
/// <param name="cacheRefreshers">Cache refreshers.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="localIdentity">Local identity of the executing AppDomain.</param>
/// <param name="lastId">Id of the latest processed instruction.</param>
/// <returns>The processing result.</returns>
ProcessInstructionsResult ProcessInstructions(
CacheRefresherCollection cacheRefreshers,
CancellationToken cancellationToken,
string localIdentity,
int lastId) =>
ProcessInstructions(
cacheRefreshers,
ServerRole.Unknown,
cancellationToken,
localIdentity,
lastPruned: DateTime.UtcNow,
lastId);
/// <summary>
/// Processes pending database cache instructions.
/// </summary>
/// <param name="cacheRefreshers">Cache refreshers.</param>
/// <param name="serverRole">Server role.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="localIdentity">Local identity of the executing AppDomain.</param>
/// <param name="lastPruned">Date of last prune operation.</param>
/// <param name="lastId">Id of the latest processed instruction</param>
/// <param name="lastId">Id of the latest processed instruction.</param>
/// <returns>The processing result.</returns>
[Obsolete("Use the non-obsolete overload. Scheduled for removal in V17.")]
ProcessInstructionsResult ProcessInstructions(
CacheRefresherCollection cacheRefreshers,
ServerRole serverRole,

View File

@@ -47,16 +47,35 @@ public interface IContentService : IContentServiceBase<IContent>
/// <summary>
/// Saves a blueprint.
/// </summary>
[Obsolete("Please use the method taking all parameters. Scheduled for removal in Umbraco 18.")]
void SaveBlueprint(IContent content, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Saves a blueprint.
/// </summary>
void SaveBlueprint(IContent content, IContent? createdFromContent, int userId = Constants.Security.SuperUserId)
#pragma warning disable CS0618 // Type or member is obsolete
=> SaveBlueprint(content, userId);
#pragma warning restore CS0618 // Type or member is obsolete
/// <summary>
/// Deletes a blueprint.
/// </summary>
void DeleteBlueprint(IContent content, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Creates a new content item from a blueprint.
/// Creates a blueprint from a content item.
/// </summary>
// TODO: Remove the default implementation when CreateContentFromBlueprint is removed.
IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
/// <summary>
/// (Deprecated) Creates a new content item from a blueprint.
/// </summary>
/// <remarks>If creating content from a blueprint, use <see cref="IContentBlueprintEditingService.GetScaffoldedAsync"/>
/// instead. If creating a blueprint from content use <see cref="CreateBlueprintFromContent"/> instead.</remarks>
[Obsolete("Use IContentBlueprintEditingService.GetScaffoldedAsync() instead. Scheduled for removal in V18.")]
IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId);
/// <summary>

View File

@@ -170,6 +170,22 @@ public interface IEntityService
/// <param name="objectType">The object type of the parent.</param>
IEntitySlim? GetParent(int id, UmbracoObjectTypes objectType);
/// <summary>
/// Gets sibling entities of a specified target entity, within a given range before and after the target, ordered as specified.
/// </summary>
/// <param name="key">The key of the target entity whose siblings are to be retrieved.</param>
/// <param name="objectType">The object type key of the entities.</param>
/// <param name="before">The number of siblings to retrieve before the target entity. Needs to be greater or equal to 0.</param>
/// <param name="after">The number of siblings to retrieve after the target entity. Needs to be greater or equal to 0.</param>
/// <param name="ordering">The ordering to apply to the siblings.</param>
/// <returns>Enumerable of sibling entities.</returns>
IEnumerable<IEntitySlim> GetSiblings(
Guid key,
UmbracoObjectTypes objectType,
int before,
int after,
Ordering? ordering = null) => [];
/// <summary>
/// Gets the children of an entity.
/// </summary>

View File

@@ -17,8 +17,8 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
private Lazy<Dictionary<string, Guid>> _contentTypeAliasToKeyMap;
private ConcurrentDictionary<Guid, NavigationNode> _navigationStructure = new();
private ConcurrentDictionary<Guid, NavigationNode> _recycleBinNavigationStructure = new();
private IList<Guid> _roots = new List<Guid>();
private IList<Guid> _recycleBinRoots = new List<Guid>();
private HashSet<Guid> _roots = [];
private HashSet<Guid> _recycleBinRoots = [];
protected ContentNavigationServiceBase(ICoreScopeProvider coreScopeProvider, INavigationRepository navigationRepository, TContentTypeService typeService)
{
@@ -321,7 +321,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
return Task.CompletedTask;
}
private bool TryGetParentKeyFromStructure(ConcurrentDictionary<Guid, NavigationNode> structure, Guid childKey, out Guid? parentKey)
private static bool TryGetParentKeyFromStructure(ConcurrentDictionary<Guid, NavigationNode> structure, Guid childKey, out Guid? parentKey)
{
if (structure.TryGetValue(childKey, out NavigationNode? childNode))
{
@@ -335,25 +335,32 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
}
private bool TryGetRootKeysFromStructure(
IList<Guid> input,
HashSet<Guid> input,
out IEnumerable<Guid> rootKeys,
Guid? contentTypeKey = null)
{
// Apply contentTypeKey filter
IEnumerable<Guid> filteredKeys = contentTypeKey.HasValue
? input.Where(key => _navigationStructure[key].ContentTypeKey == contentTypeKey.Value)
: input;
var keysWithSortOrder = new List<(Guid Key, int SortOrder)>(input.Count);
foreach (Guid key in input)
{
NavigationNode navigationNode = _navigationStructure[key];
// Apply contentTypeKey filter
if (contentTypeKey.HasValue && navigationNode.ContentTypeKey != contentTypeKey.Value)
{
continue;
}
keysWithSortOrder.Add((key, navigationNode.SortOrder));
}
// TODO can we make this more efficient?
// Sort by SortOrder
rootKeys = filteredKeys
.OrderBy(key => _navigationStructure[key].SortOrder)
.ToList();
keysWithSortOrder.Sort((a, b) => a.SortOrder.CompareTo(b.SortOrder));
rootKeys = keysWithSortOrder.ConvertAll(keyWithSortOrder => keyWithSortOrder.Key);
return true;
}
private bool TryGetChildrenKeysFromStructure(
private static bool TryGetChildrenKeysFromStructure(
ConcurrentDictionary<Guid, NavigationNode> structure,
Guid parentKey,
out IEnumerable<Guid> childrenKeys,
@@ -367,12 +374,12 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
}
// Keep children keys ordered based on their SortOrder
childrenKeys = GetOrderedChildren(parentNode, structure, contentTypeKey).ToList();
childrenKeys = GetOrderedChildren(parentNode, structure, contentTypeKey);
return true;
}
private bool TryGetDescendantsKeysFromStructure(
private static bool TryGetDescendantsKeysFromStructure(
ConcurrentDictionary<Guid, NavigationNode> structure,
Guid parentKey,
out IEnumerable<Guid> descendantsKeys,
@@ -393,7 +400,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
return true;
}
private bool TryGetAncestorsKeysFromStructure(
private static bool TryGetAncestorsKeysFromStructure(
ConcurrentDictionary<Guid, NavigationNode> structure,
Guid childKey,
out IEnumerable<Guid> ancestorsKeys,
@@ -421,7 +428,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
return true;
}
private bool TryGetSiblingsKeysFromStructure(
private static bool TryGetSiblingsKeysFromStructure(
ConcurrentDictionary<Guid, NavigationNode> structure,
Guid key,
out IEnumerable<Guid> siblingsKeys,
@@ -463,14 +470,14 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
return true;
}
private void GetDescendantsRecursively(
private static void GetDescendantsRecursively(
ConcurrentDictionary<Guid, NavigationNode> structure,
NavigationNode node,
List<Guid> descendants,
Guid? contentTypeKey = null)
{
// Get all children regardless of contentType
var childrenKeys = GetOrderedChildren(node, structure).ToList();
IReadOnlyList<Guid> childrenKeys = GetOrderedChildren(node, structure);
foreach (Guid childKey in childrenKeys)
{
// Apply contentTypeKey filter
@@ -487,7 +494,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
}
}
private bool TryRemoveNodeFromParentInStructure(ConcurrentDictionary<Guid, NavigationNode> structure, Guid key, out NavigationNode? nodeToRemove)
private static bool TryRemoveNodeFromParentInStructure(ConcurrentDictionary<Guid, NavigationNode> structure, Guid key, out NavigationNode? nodeToRemove)
{
if (structure.TryGetValue(key, out nodeToRemove) is false)
{
@@ -507,7 +514,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
{
_recycleBinRoots.Add(node.Key);
_roots.Remove(node.Key);
var childrenKeys = GetOrderedChildren(node, _navigationStructure).ToList();
IReadOnlyList<Guid> childrenKeys = GetOrderedChildren(node, _navigationStructure);
foreach (Guid childKey in childrenKeys)
{
@@ -530,7 +537,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
private void RemoveDescendantsRecursively(NavigationNode node)
{
var childrenKeys = GetOrderedChildren(node, _recycleBinNavigationStructure).ToList();
IReadOnlyList<Guid> childrenKeys = GetOrderedChildren(node, _recycleBinNavigationStructure);
foreach (Guid childKey in childrenKeys)
{
if (_recycleBinNavigationStructure.TryGetValue(childKey, out NavigationNode? childNode) is false)
@@ -551,7 +558,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
}
_recycleBinRoots.Remove(node.Key);
var childrenKeys = GetOrderedChildren(node, _recycleBinNavigationStructure).ToList();
IReadOnlyList<Guid> childrenKeys = GetOrderedChildren(node, _recycleBinNavigationStructure);
foreach (Guid childKey in childrenKeys)
{
@@ -570,24 +577,35 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
}
}
private IEnumerable<Guid> GetOrderedChildren(
private static IReadOnlyList<Guid> GetOrderedChildren(
NavigationNode node,
ConcurrentDictionary<Guid, NavigationNode> structure,
Guid? contentTypeKey = null)
{
IEnumerable<Guid> children = node
.Children
.Where(structure.ContainsKey);
// Apply contentTypeKey filter
if (contentTypeKey.HasValue)
if (node.Children.Count < 1)
{
children = children.Where(childKey => structure[childKey].ContentTypeKey == contentTypeKey.Value);
return [];
}
return children
.OrderBy(childKey => structure[childKey].SortOrder)
.ToList();
var childrenWithSortOrder = new List<(Guid ChildNodeKey, int SortOrder)>(node.Children.Count);
foreach (Guid childNodeKey in node.Children)
{
if (!structure.TryGetValue(childNodeKey, out NavigationNode? childNode))
{
continue;
}
// Apply contentTypeKey filter
if (contentTypeKey.HasValue && childNode.ContentTypeKey != contentTypeKey.Value)
{
continue;
}
childrenWithSortOrder.Add((childNodeKey, childNode.SortOrder));
}
childrenWithSortOrder.Sort((a, b) => a.SortOrder.CompareTo(b.SortOrder));
return childrenWithSortOrder.ConvertAll(childWithSortOrder => childWithSortOrder.ChildNodeKey);
}
private bool TryGetContentTypeKey(string contentTypeAlias, out Guid? contentTypeKey)
@@ -613,7 +631,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
return true;
}
private static void BuildNavigationDictionary(ConcurrentDictionary<Guid, NavigationNode> nodesStructure, IList<Guid> roots, IEnumerable<INavigationModel> entities)
private static void BuildNavigationDictionary(ConcurrentDictionary<Guid, NavigationNode> nodesStructure, HashSet<Guid> roots, IEnumerable<INavigationModel> entities)
{
var entityList = entities.ToList();
var idToKeyMap = entityList.ToDictionary(x => x.Id, x => x.Key);

View File

@@ -14,11 +14,13 @@ public class ProcessInstructionsResult
public int LastId { get; private set; }
[Obsolete("Instruction pruning has been moved to a separate background job. Scheduled for removal in V18.")]
public bool InstructionsWerePruned { get; private set; }
public static ProcessInstructionsResult AsCompleted(int numberOfInstructionsProcessed, int lastId) =>
new() { NumberOfInstructionsProcessed = numberOfInstructionsProcessed, LastId = lastId };
[Obsolete("Instruction pruning has been moved to a separate background job. Scheduled for removal in V18.")]
public static ProcessInstructionsResult AsCompletedAndPruned(int numberOfInstructionsProcessed, int lastId) =>
new()
{

View File

@@ -23,23 +23,23 @@ public class ContentQueryService : IContentQueryService
_coreScopeProvider = coreScopeProvider;
}
public async Task<Attempt<ContentScheduleQueryResult?, ContentQueryOperationStatus>> GetWithSchedulesAsync(Guid id)
public Task<Attempt<ContentScheduleQueryResult?, ContentQueryOperationStatus>> GetWithSchedulesAsync(Guid id)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope(autoComplete: true);
IContent? content = await Task.FromResult(_contentService.GetById(id));
IContent? content = _contentService.GetById(id);
if (content == null)
{
return Attempt<ContentScheduleQueryResult, ContentQueryOperationStatus>.Fail(ContentQueryOperationStatus
.ContentNotFound);
return Task.FromResult(Attempt<ContentScheduleQueryResult, ContentQueryOperationStatus>.Fail(ContentQueryOperationStatus
.ContentNotFound));
}
ContentScheduleCollection schedules = _contentService.GetContentScheduleByContentId(id);
return Attempt<ContentScheduleQueryResult?, ContentQueryOperationStatus>
return Task.FromResult(Attempt<ContentScheduleQueryResult?, ContentQueryOperationStatus>
.Succeed(
ContentQueryOperationStatus.Success,
new ContentScheduleQueryResult(content, schedules));
new ContentScheduleQueryResult(content, schedules)));
}
}

View File

@@ -220,7 +220,7 @@ public class RelationService : RepositoryService, IRelationService
}
return relationTypeIds.Count == 0
? Enumerable.Empty<IRelation>()
? []
: GetRelationsByListOfTypeIds(relationTypeIds);
}
@@ -230,8 +230,8 @@ public class RelationService : RepositoryService, IRelationService
IRelationType? relationType = GetRelationType(relationTypeAlias);
return relationType == null
? Enumerable.Empty<IRelation>()
: GetRelationsByListOfTypeIds(new[] { relationType.Id });
? []
: GetRelationsByListOfTypeIds([relationType.Id]);
}
/// <inheritdoc />
@@ -594,7 +594,7 @@ public class RelationService : RepositoryService, IRelationService
EventMessages eventMessages = EventMessagesFactory.Get();
var savingNotification = new RelationTypeSavingNotification(relationType, eventMessages);
if (scope.Notifications.PublishCancelable(savingNotification))
if (await scope.Notifications.PublishCancelableAsync(savingNotification))
{
scope.Complete();
return Attempt.FailWithStatus(RelationTypeOperationStatus.CancelledByNotification, relationType);
@@ -659,7 +659,7 @@ public class RelationService : RepositoryService, IRelationService
EventMessages eventMessages = EventMessagesFactory.Get();
var deletingNotification = new RelationTypeDeletingNotification(relationType, eventMessages);
if (scope.Notifications.PublishCancelable(deletingNotification))
if (await scope.Notifications.PublishCancelableAsync(deletingNotification))
{
scope.Complete();
return Attempt.FailWithStatus<IRelationType?, RelationTypeOperationStatus>(RelationTypeOperationStatus.CancelledByNotification, null);
@@ -670,7 +670,7 @@ public class RelationService : RepositoryService, IRelationService
Audit(AuditType.Delete, currentUser, relationType.Id, "Deleted relation type");
scope.Notifications.Publish(new RelationTypeDeletedNotification(relationType, eventMessages).WithStateFrom(deletingNotification));
scope.Complete();
return await Task.FromResult(Attempt.SucceedWithStatus<IRelationType?, RelationTypeOperationStatus>(RelationTypeOperationStatus.Success, relationType));
return Attempt.SucceedWithStatus<IRelationType?, RelationTypeOperationStatus>(RelationTypeOperationStatus.Success, relationType);
}
/// <inheritdoc />
@@ -726,7 +726,7 @@ public class RelationService : RepositoryService, IRelationService
return _relationTypeRepository.Get(query).FirstOrDefault();
}
private IEnumerable<IRelation> GetRelationsByListOfTypeIds(IEnumerable<int> relationTypeIds)
private List<IRelation> GetRelationsByListOfTypeIds(IEnumerable<int> relationTypeIds)
{
var relations = new List<IRelation>();
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))

View File

@@ -39,8 +39,7 @@ public class StylesheetHelper
// Only match first selector when chained together
Styles = string.Join(
Environment.NewLine,
match.Groups["Styles"].Value.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)
.Select(x => x.Trim()).ToArray()),
match.Groups["Styles"].Value.Split(new[] { "\r\n", "\n" }, StringSplitOptions.TrimEntries)),
});
}
}

View File

@@ -30,13 +30,13 @@ public class StylesheetRule
// instead of using string interpolation (for increased performance)
foreach (var style in
Styles?.Split(Constants.CharArrays.Semicolon, StringSplitOptions.RemoveEmptyEntries) ??
Array.Empty<string>())
[])
{
sb.Append("\t").Append(style.StripNewLines().Trim()).Append(";").Append(Environment.NewLine);
sb.Append('\t').Append(style.StripNewLines().Trim()).Append(';').Append(Environment.NewLine);
}
}
sb.Append("}");
sb.Append('}');
return sb.ToString();
}

View File

@@ -13,7 +13,7 @@ namespace Umbraco.Cms.Core.Strings;
/// Copyright (c) by Matthias Hertel, http://www.mathertel.de
/// This work is licensed under a BSD style license. See http://www.mathertel.de/License.aspx
/// </summary>
internal class Diff
internal sealed class Diff
{
/// <summary>
/// Find the difference in 2 texts, comparing by text lines.

View File

@@ -0,0 +1,61 @@
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Infrastructure.Scoping;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
/// <summary>
/// A background job that prunes cache instructions from the database.
/// </summary>
public class CacheInstructionsPruningJob : IRecurringBackgroundJob
{
private readonly IOptions<GlobalSettings> _globalSettings;
private readonly ICacheInstructionRepository _cacheInstructionRepository;
private readonly ICoreScopeProvider _scopeProvider;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="CacheInstructionsPruningJob"/> class.
/// </summary>
/// <param name="scopeProvider">Provides scopes for database operations.</param>
/// <param name="globalSettings">The global settings configuration.</param>
/// <param name="cacheInstructionRepository">The repository for cache instructions.</param>
/// <param name="timeProvider">The time provider.</param>
public CacheInstructionsPruningJob(
IOptions<GlobalSettings> globalSettings,
ICacheInstructionRepository cacheInstructionRepository,
ICoreScopeProvider scopeProvider,
TimeProvider timeProvider)
{
_globalSettings = globalSettings;
_cacheInstructionRepository = cacheInstructionRepository;
_scopeProvider = scopeProvider;
_timeProvider = timeProvider;
Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenPruneOperations;
}
/// <inheritdoc />
public event EventHandler PeriodChanged
{
add { }
remove { }
}
/// <inheritdoc />
public TimeSpan Period { get; }
/// <inheritdoc />
public Task RunJobAsync()
{
DateTimeOffset pruneDate = _timeProvider.GetUtcNow() - _globalSettings.Value.DatabaseServerMessenger.TimeToRetainInstructions;
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
{
_cacheInstructionRepository.DeleteInstructionsOlderThan(pruneDate.DateTime);
scope.Complete();
}
return Task.CompletedTask;
}
}

View File

@@ -52,6 +52,7 @@ using Umbraco.Cms.Infrastructure.Migrations;
using Umbraco.Cms.Infrastructure.Migrations.Install;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Infrastructure.Persistence.Mappers;
using Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
using Umbraco.Cms.Infrastructure.Routing;
using Umbraco.Cms.Infrastructure.Runtime;
using Umbraco.Cms.Infrastructure.Runtime.RuntimeModeValidators;
@@ -352,11 +353,17 @@ public static partial class UmbracoBuilderExtensions
.AddNotificationHandler<ContentSavingNotification, BlockGridPropertyNotificationHandler>()
.AddNotificationHandler<ContentCopyingNotification, BlockGridPropertyNotificationHandler>()
.AddNotificationHandler<ContentScaffoldedNotification, BlockGridPropertyNotificationHandler>()
.AddNotificationHandler<ContentCopiedNotification, FileUploadPropertyEditor>()
.AddNotificationHandler<ContentDeletedNotification, FileUploadPropertyEditor>()
.AddNotificationHandler<MediaDeletedNotification, FileUploadPropertyEditor>()
.AddNotificationHandler<MediaSavingNotification, FileUploadPropertyEditor>()
.AddNotificationHandler<MemberDeletedNotification, FileUploadPropertyEditor>()
.AddNotificationHandler<ContentSavingNotification, RichTextPropertyNotificationHandler>()
.AddNotificationHandler<ContentCopyingNotification, RichTextPropertyNotificationHandler>()
.AddNotificationHandler<ContentScaffoldedNotification, RichTextPropertyNotificationHandler>()
.AddNotificationHandler<ContentCopiedNotification, FileUploadContentCopiedOrScaffoldedNotificationHandler>()
.AddNotificationHandler<ContentScaffoldedNotification, FileUploadContentCopiedOrScaffoldedNotificationHandler>()
.AddNotificationHandler<ContentSavedBlueprintNotification, FileUploadContentCopiedOrScaffoldedNotificationHandler>()
.AddNotificationHandler<ContentDeletedNotification, FileUploadContentDeletedNotificationHandler>()
.AddNotificationHandler<ContentDeletedBlueprintNotification, FileUploadContentDeletedNotificationHandler>()
.AddNotificationHandler<MediaDeletedNotification, FileUploadContentDeletedNotificationHandler>()
.AddNotificationHandler<MemberDeletedNotification, FileUploadContentDeletedNotificationHandler>()
.AddNotificationHandler<MediaSavingNotification, FileUploadMediaSavingNotificationHandler>()
.AddNotificationHandler<ContentCopiedNotification, ImageCropperPropertyEditor>()
.AddNotificationHandler<ContentDeletedNotification, ImageCropperPropertyEditor>()
.AddNotificationHandler<MediaDeletedNotification, ImageCropperPropertyEditor>()

View File

@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
@@ -28,21 +28,28 @@ internal sealed class DeliveryApiContentIndexHelper : IDeliveryApiContentIndexHe
public void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action<IContent[]> actionToPerform)
{
const int pageSize = 10000;
var pageIndex = 0;
EnumerateApplicableDescendantsForContentIndex(rootContentId, actionToPerform, pageSize);
}
internal void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action<IContent[]> actionToPerform, int pageSize)
{
var itemIndex = 0;
long total;
IQuery<IContent> query = _umbracoDatabaseFactory.SqlContext.Query<IContent>().Where(content => content.Trashed == false);
IContent[] descendants;
IQuery<IContent> query = _umbracoDatabaseFactory.SqlContext.Query<IContent>().Where(content => content.Trashed == false);
do
{
descendants = _contentService
.GetPagedDescendants(rootContentId, pageIndex, pageSize, out _, query, Ordering.By("Path"))
.GetPagedDescendants(rootContentId, itemIndex / pageSize, pageSize, out total, query, Ordering.By("Path"))
.Where(descendant => _deliveryApiSettings.IsAllowedContentType(descendant.ContentType.Alias))
.ToArray();
actionToPerform(descendants.ToArray());
actionToPerform(descendants);
pageIndex++;
itemIndex += pageSize;
}
while (descendants.Length == pageSize);
while (descendants.Length > 0 && itemIndex < total);
}
}

View File

@@ -0,0 +1,38 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache.PropertyEditors;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
namespace Umbraco.Cms.Infrastructure.Extensions;
/// <summary>
/// Defines extensions on <see cref="RichTextEditorValue"/>.
/// </summary>
internal static class RichTextEditorValueExtensions
{
/// <summary>
/// Ensures that the property type property is populated on all blocks.
/// </summary>
/// <param name="richTextEditorValue">The <see cref="RichTextEditorValue"/> providing the blocks.</param>
/// <param name="elementTypeCache">Cache for element types.</param>
public static void EnsurePropertyTypePopulatedOnBlocks(this RichTextEditorValue richTextEditorValue, IBlockEditorElementTypeCache elementTypeCache)
{
Guid[] elementTypeKeys = (richTextEditorValue.Blocks?.ContentData ?? [])
.Select(x => x.ContentTypeKey)
.Union((richTextEditorValue.Blocks?.SettingsData ?? [])
.Select(x => x.ContentTypeKey))
.Distinct()
.ToArray();
IEnumerable<IContentType> elementTypes = elementTypeCache.GetMany(elementTypeKeys);
foreach (BlockItemData dataItem in (richTextEditorValue.Blocks?.ContentData ?? [])
.Union(richTextEditorValue.Blocks?.SettingsData ?? []))
{
foreach (BlockPropertyValue item in dataItem.Values)
{
item.PropertyType = elementTypes.FirstOrDefault(x => x.Key == dataItem.ContentTypeKey)?.PropertyTypes.FirstOrDefault(pt => pt.Alias == item.Alias);
}
}
}
}

View File

@@ -77,8 +77,9 @@ public class PackageMigrationRunner
/// </summary>
public async Task<Attempt<bool, PackageMigrationOperationStatus>> RunPendingPackageMigrations(string packageName)
{
// Check if there are any migrations
if (_packageMigrationPlans.ContainsKey(packageName) == false)
// Check if there are any migrations (note that the key for _packageMigrationPlans is the migration plan name, not the package name).
if (_packageMigrationPlans.Values
.Any(x => x.PackageName.InvariantEquals(packageName)) is false)
{
return Attempt.FailWithStatus(PackageMigrationOperationStatus.NotFound, false);
}
@@ -121,8 +122,8 @@ public class PackageMigrationRunner
}
using (_profilingLogger.TraceDuration<PackageMigrationRunner>(
"Starting unattended package migration for " + migrationName,
"Unattended upgrade completed for " + migrationName))
"Starting package migration for " + migrationName,
"Package migration completed for " + migrationName))
{
Upgrader upgrader = new(plan);

View File

@@ -1,4 +1,4 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Services;
@@ -63,7 +63,7 @@ public class MoveDocumentBlueprintsToFolders : MigrationBase
}
blueprint.ParentId = container.Id;
_contentService.SaveBlueprint(blueprint);
_contentService.SaveBlueprint(blueprint, null);
}
}
}

View File

@@ -1298,6 +1298,14 @@ namespace Umbraco.Extensions
#region Utilities
public static Sql<ISqlContext> AppendSubQuery(this Sql<ISqlContext> sql, Sql<ISqlContext> subQuery, string alias)
{
// Append the subquery as a derived table with an alias
sql.Append("(").Append(subQuery.SQL, subQuery.Arguments).Append($") AS {alias}");
return sql;
}
private static string[] GetColumns<TDto>(this Sql<ISqlContext> sql, string? tableAlias = null, string? referenceName = null, Expression<Func<TDto, object?>>[]? columnExpressions = null, bool withAlias = true, bool forInsert = false)
{
PocoData? pd = sql.SqlContext.PocoDataFactory.ForType(typeof (TDto));

View File

@@ -145,6 +145,59 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended
return entity;
}
/// <inheritdoc/>
public IEnumerable<IEntitySlim> GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering)
{
// Ideally we don't want to have to do a second query for the parent ID, but the siblings query is already messy enough
// without us also having to do a nested query for the parent ID too.
Sql<ISqlContext> parentIdQuery = Sql()
.Select<NodeDto>(x => x.ParentId)
.From<NodeDto>()
.Where<NodeDto>(x => x.UniqueId == targetKey);
var parentId = Database.ExecuteScalar<int>(parentIdQuery);
Sql<ISqlContext> orderingSql = Sql();
ApplyOrdering(ref orderingSql, ordering);
// Get all children of the parent node which is not trashed, ordered by SortOrder, and assign each a row number.
// These row numbers are important, we need them to select the "before" and "after" siblings of the target node.
Sql<ISqlContext> rowNumberSql = Sql()
.Select($"ROW_NUMBER() OVER ({orderingSql.SQL}) AS rn")
.AndSelect<NodeDto>(n => n.UniqueId)
.From<NodeDto>()
.Where<NodeDto>(x => x.ParentId == parentId && x.Trashed == false);
// Find the specific row number of the target node.
// We need this to determine the bounds of the row numbers to select.
Sql<ISqlContext> targetRowSql = Sql()
.Select("rn")
.From().AppendSubQuery(rowNumberSql, "Target")
.Where<NodeDto>(x => x.UniqueId == targetKey, "Target");
// We have to reuse the target row sql arguments, however, we also need to add the "before" and "after" values to the arguments.
// If we try to do this directly in the params array it'll consider the initial argument array as a single argument.
IEnumerable<object> beforeArguments = targetRowSql.Arguments.Concat([before]);
IEnumerable<object> afterArguments = targetRowSql.Arguments.Concat([after]);
// Select the UniqueId of nodes which row number is within the specified range of the target node's row number.
Sql<ISqlContext>? mainSql = Sql()
.Select("UniqueId")
.From().AppendSubQuery(rowNumberSql, "NumberedNodes")
.Where($"rn >= ({targetRowSql.SQL}) - @3", beforeArguments.ToArray())
.Where($"rn <= ({targetRowSql.SQL}) + @3", afterArguments.ToArray())
.OrderBy("rn");
List<Guid>? keys = Database.Fetch<Guid>(mainSql);
if (keys is null || keys.Count == 0)
{
return [];
}
return PerformGetAll(objectType, ordering, sql => sql.WhereIn<NodeDto>(x => x.UniqueId, keys));
}
public IEntitySlim? Get(Guid key, Guid objectTypeId)
{
var isContent = objectTypeId == Constants.ObjectTypes.Document ||
@@ -216,6 +269,20 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended
return GetEntities(sql, isContent, isMedia, isMember);
}
private IEnumerable<IEntitySlim> PerformGetAll(
Guid objectType,
Ordering ordering,
Action<Sql<ISqlContext>>? filter = null)
{
var isContent = objectType == Constants.ObjectTypes.Document ||
objectType == Constants.ObjectTypes.DocumentBlueprint;
var isMedia = objectType == Constants.ObjectTypes.Media;
var isMember = objectType == Constants.ObjectTypes.Member;
Sql<ISqlContext> sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectType, ordering, filter);
return GetEntities(sql, isContent, isMedia, isMember);
}
public IEnumerable<TreeEntityPath> GetAllPaths(Guid objectType, params int[]? ids) =>
ids?.Any() ?? false
? PerformGetAllPaths(objectType, sql => sql.WhereIn<NodeDto>(x => x.NodeId, ids.Distinct()))
@@ -452,6 +519,21 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended
return AddGroupBy(isContent, isMedia, isMember, sql, true);
}
protected Sql<ISqlContext> GetFullSqlForEntityType(
bool isContent,
bool isMedia,
bool isMember,
Guid objectType,
Ordering ordering,
Action<Sql<ISqlContext>>? filter)
{
Sql<ISqlContext> sql = GetBaseWhere(isContent, isMedia, isMember, false, filter, new[] { objectType });
AddGroupBy(isContent, isMedia, isMember, sql, false);
ApplyOrdering(ref sql, ordering);
return sql;
}
protected Sql<ISqlContext> GetBase(bool isContent, bool isMedia, bool isMember, Action<Sql<ISqlContext>>? filter, bool isCount = false)
=> GetBase(isContent, isMedia, isMember, filter, [], isCount);

View File

@@ -57,10 +57,17 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
Database.Delete<ExternalLoginDto>("WHERE userOrMemberKey=@userOrMemberKey", new { userOrMemberKey });
/// <inheritdoc />
public void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) =>
Database.Execute(Sql()
.Delete<ExternalLoginDto>()
.WhereNotIn<ExternalLoginDto>(x => x.LoginProvider, currentLoginProviders));
public void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders)
{
Sql<ISqlContext> sql = Sql()
.Select<ExternalLoginDto>(x => x.Id)
.From<ExternalLoginDto>()
.Where<ExternalLoginDto>(x => !x.LoginProvider.StartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix)) // Only remove external logins relating to backoffice users, not members.
.WhereNotIn<ExternalLoginDto>(x => x.LoginProvider, currentLoginProviders);
var toDelete = Database.Query<ExternalLoginDto>(sql).Select(x => x.Id).ToList();
DeleteExternalLogins(toDelete);
}
/// <inheritdoc />
public void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins)
@@ -100,13 +107,7 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
}
// do the deletes, updates and inserts
if (toDelete.Count > 0)
{
// Before we can remove the external login, we must remove the external login tokens associated with that external login,
// otherwise we'll get foreign key constraint errors
Database.DeleteMany<ExternalLoginTokenDto>().Where(x => toDelete.Contains(x.ExternalLoginId)).Execute();
Database.DeleteMany<ExternalLoginDto>().Where(x => toDelete.Contains(x.Id)).Execute();
}
DeleteExternalLogins(toDelete);
foreach (KeyValuePair<int, IExternalLogin> u in toUpdate)
{
@@ -116,6 +117,19 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
Database.InsertBulk(toInsert.Select(i => ExternalLoginFactory.BuildDto(userOrMemberKey, i)));
}
private void DeleteExternalLogins(List<int> externalLoginIds)
{
if (externalLoginIds.Count == 0)
{
return;
}
// Before we can remove the external login, we must remove the external login tokens associated with that external login,
// otherwise we'll get foreign key constraint errors
Database.DeleteMany<ExternalLoginTokenDto>().Where(x => externalLoginIds.Contains(x.ExternalLoginId)).Execute();
Database.DeleteMany<ExternalLoginDto>().Where(x => externalLoginIds.Contains(x.Id)).Execute();
}
/// <inheritdoc />
public void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens)
{

View File

@@ -1,10 +1,8 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Diagnostics.CodeAnalysis;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
@@ -16,10 +14,16 @@ using Umbraco.Cms.Core.Strings;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Provides an abstract base class for property value editors based on block editors.
/// </summary>
public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockValuePropertyValueEditorBase<TValue, TLayout>
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
/// <summary>
/// Initializes a new instance of the <see cref="BlockEditorPropertyValueEditor{TValue, TLayout}"/> class.
/// </summary>
protected BlockEditorPropertyValueEditor(
PropertyEditorCollection propertyEditors,
DataValueReferenceFactoryCollection dataValueReferenceFactories,
@@ -62,13 +66,7 @@ public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockVal
return BlockEditorValues.DeserializeAndClean(rawJson)?.BlockValue;
}
/// <summary>
/// Ensure that sub-editor values are translated through their ToEditor methods
/// </summary>
/// <param name="property"></param>
/// <param name="culture"></param>
/// <param name="segment"></param>
/// <returns></returns>
/// <inheritdoc />
public override object ToEditor(IProperty property, string? culture = null, string? segment = null)
{
var val = property.GetValue(culture, segment);
@@ -95,38 +93,48 @@ public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockVal
return blockEditorData.BlockValue;
}
/// <summary>
/// Ensure that sub-editor values are translated through their FromEditor methods
/// </summary>
/// <param name="editorValue"></param>
/// <param name="currentValue"></param>
/// <returns></returns>
/// <inheritdoc />
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
{
if (editorValue.Value == null || string.IsNullOrWhiteSpace(editorValue.Value.ToString()))
// Note: we can't early return here if editorValue is null or empty, because these is the following case:
// - current value not null (which means doc has at least one element in block list)
// - editor value (new value) is null (which means doc has no elements in block list)
// If we check editor value for null value and return before MapBlockValueFromEditor, then we will not trigger updates for properties.
// For most of the properties this is fine, but for properties which contain other state it might be critical (e.g. file upload field).
// So, we must run MapBlockValueFromEditor even if editorValue is null or string.IsNullOrWhiteSpace(editorValue.Value.ToString()) is true.
BlockEditorData<TValue, TLayout>? currentBlockEditorData = GetBlockEditorData(currentValue);
BlockEditorData<TValue, TLayout>? blockEditorData = GetBlockEditorData(editorValue.Value);
// We can skip MapBlockValueFromEditor if both editorValue and currentValue values are empty.
if (IsBlockEditorDataEmpty(currentBlockEditorData) && IsBlockEditorDataEmpty(blockEditorData))
{
return null;
}
BlockEditorData<TValue, TLayout>? blockEditorData;
MapBlockValueFromEditor(blockEditorData?.BlockValue, currentBlockEditorData?.BlockValue, editorValue.ContentKey);
if (IsBlockEditorDataEmpty(blockEditorData))
{
return null;
}
return JsonSerializer.Serialize(blockEditorData.BlockValue);
}
private BlockEditorData<TValue, TLayout>? GetBlockEditorData(object? value)
{
try
{
blockEditorData = BlockEditorValues.DeserializeAndClean(editorValue.Value);
return BlockEditorValues.DeserializeAndClean(value);
}
catch
{
// if this occurs it means the data is invalid, shouldn't happen but has happened if we change the data format.
return string.Empty;
// If this occurs it means the data is invalid. It shouldn't happen could if we change the data format.
return null;
}
}
if (blockEditorData == null || blockEditorData.BlockValue.ContentData.Count == 0)
{
return string.Empty;
}
MapBlockValueFromEditor(blockEditorData.BlockValue);
// return json
return JsonSerializer.Serialize(blockEditorData.BlockValue);
}
private static bool IsBlockEditorDataEmpty([NotNullWhen(false)] BlockEditorData<TValue, TLayout>? editorData)
=> editorData is null || editorData.BlockValue.ContentData.Count == 0;
}

View File

@@ -1,4 +1,4 @@
using Umbraco.Cms.Core.Cache.PropertyEditors;
using Umbraco.Cms.Core.Cache.PropertyEditors;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.Validation;
@@ -88,7 +88,13 @@ public abstract class BlockEditorValidatorBase<TValue, TLayout> : ComplexEditorV
foreach (var group in itemDataGroups)
{
var allElementTypes = _elementTypeCache.GetMany(group.Items.Select(x => x.ContentTypeKey).ToArray()).ToDictionary(x => x.Key);
Guid[] elementTypeKeys = group.Items.Select(x => x.ContentTypeKey).ToArray();
if (elementTypeKeys.Length == 0)
{
continue;
}
var allElementTypes = _elementTypeCache.GetMany(elementTypeKeys).ToDictionary(x => x.Key);
for (var i = 0; i < group.Items.Length; i++)
{

View File

@@ -129,10 +129,115 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
return result;
}
protected void MapBlockValueFromEditor(TValue blockValue)
[Obsolete("This method is no longer used within Umbraco. Please use the overload taking all parameters. Scheduled for removal in Umbraco 17.")]
protected void MapBlockValueFromEditor(TValue blockValue) => MapBlockValueFromEditor(blockValue, null, Guid.Empty);
protected void MapBlockValueFromEditor(TValue? editedBlockValue, TValue? currentBlockValue, Guid contentKey)
{
MapBlockItemDataFromEditor(blockValue.ContentData);
MapBlockItemDataFromEditor(blockValue.SettingsData);
MapBlockItemDataFromEditor(
editedBlockValue?.ContentData ?? [],
currentBlockValue?.ContentData ?? [],
contentKey);
MapBlockItemDataFromEditor(
editedBlockValue?.SettingsData ?? [],
currentBlockValue?.SettingsData ?? [],
contentKey);
}
private void MapBlockItemDataFromEditor(List<BlockItemData> editedItems, List<BlockItemData> currentItems, Guid contentKey)
{
// Create mapping between edited and current block items.
IEnumerable<BlockStateMapping<BlockItemData>> itemsMapping = GetBlockStatesMapping(editedItems, currentItems, (mapping, current) => mapping.Edited?.Key == current.Key);
foreach (BlockStateMapping<BlockItemData> itemMapping in itemsMapping)
{
// Create mapping between edited and current block item values.
IEnumerable<BlockStateMapping<BlockPropertyValue>> valuesMapping = GetBlockStatesMapping(itemMapping.Edited?.Values, itemMapping.Current?.Values, (mapping, current) => mapping.Edited?.Alias == current.Alias);
foreach (BlockStateMapping<BlockPropertyValue> valueMapping in valuesMapping)
{
BlockPropertyValue? editedValue = valueMapping.Edited;
BlockPropertyValue? currentValue = valueMapping.Current;
IPropertyType propertyType = editedValue?.PropertyType
?? currentValue?.PropertyType
?? throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them from editor.", nameof(editedItems));
// Lookup the property editor.
IDataEditor? propertyEditor = _propertyEditors[propertyType.PropertyEditorAlias];
if (propertyEditor is null)
{
continue;
}
// Fetch the property types prevalue.
var configuration = _dataTypeConfigurationCache.GetConfiguration(propertyType.DataTypeKey);
// Create a real content property data object.
var propertyData = new ContentPropertyData(editedValue?.Value, configuration)
{
ContentKey = contentKey,
PropertyTypeKey = propertyType.Key,
};
// Get the property editor to do it's conversion.
IDataValueEditor valueEditor = propertyEditor.GetValueEditor();
var newValue = valueEditor.FromEditor(propertyData, currentValue?.Value);
// Update the raw value since this is what will get serialized out.
if (editedValue != null)
{
editedValue.Value = newValue;
}
}
}
}
private sealed class BlockStateMapping<T>
{
public T? Edited { get; set; }
public T? Current { get; set; }
}
private static IEnumerable<BlockStateMapping<T>> GetBlockStatesMapping<T>(IList<T>? editedItems, IList<T>? currentItems, Func<BlockStateMapping<T>, T, bool> condition)
{
// filling with edited items first
List<BlockStateMapping<T>> mapping = editedItems?
.Select(editedItem => new BlockStateMapping<T>
{
Current = default,
Edited = editedItem,
})
.ToList()
?? [];
if (currentItems is null)
{
return mapping;
}
// then adding current items
foreach (T currentItem in currentItems)
{
BlockStateMapping<T>? mappingItem = mapping.FirstOrDefault(x => condition(x, currentItem));
if (mappingItem == null) // if there is no edited item, then adding just current
{
mapping.Add(new BlockStateMapping<T>
{
Current = currentItem,
Edited = default,
});
}
else
{
mappingItem.Current = currentItem;
}
}
return mapping;
}
protected void MapBlockValueToEditor(IProperty property, TValue blockValue, string? culture, string? segment)
@@ -197,40 +302,6 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
}
}
private void MapBlockItemDataFromEditor(List<BlockItemData> items)
{
foreach (BlockItemData item in items)
{
foreach (BlockPropertyValue blockPropertyValue in item.Values)
{
IPropertyType? propertyType = blockPropertyValue.PropertyType;
if (propertyType is null)
{
throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them from editor.", nameof(items));
}
// Lookup the property editor
IDataEditor? propertyEditor = _propertyEditors[propertyType.PropertyEditorAlias];
if (propertyEditor is null)
{
continue;
}
// Fetch the property types prevalue
var configuration = _dataTypeConfigurationCache.GetConfiguration(propertyType.DataTypeKey);
// Create a fake content property data object
var propertyData = new ContentPropertyData(blockPropertyValue.Value, configuration);
// Get the property editor to do it's conversion
var newValue = propertyEditor.GetValueEditor().FromEditor(propertyData, blockPropertyValue.Value);
// update the raw value since this is what will get serialized out
blockPropertyValue.Value = newValue;
}
}
}
/// <summary>
/// Updates the invariant data in the source with the invariant data in the value if allowed
/// </summary>

View File

@@ -10,10 +10,16 @@ using Umbraco.Cms.Core.Media;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
// TODO (V17):
// - Remove the implementation of INotificationHandler as these have all been refactored out into sepate notification handler classes.
// - Remove the unused parameters from the constructor.
/// <summary>
/// Defines the file upload property editor.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.UploadField,
ValueEditorIsReusable = true)]
@@ -22,12 +28,11 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator,
INotificationHandler<MediaDeletedNotification>, INotificationHandler<MediaSavingNotification>,
INotificationHandler<MemberDeletedNotification>
{
private readonly IContentService _contentService;
private readonly IOptionsMonitor<ContentSettings> _contentSettings;
private readonly IIOHelper _ioHelper;
private readonly MediaFileManager _mediaFileManager;
private readonly UploadAutoFillProperties _uploadAutoFillProperties;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadPropertyEditor"/> class.
/// </summary>
public FileUploadPropertyEditor(
IDataValueEditorFactory dataValueEditorFactory,
MediaFileManager mediaFileManager,
@@ -37,14 +42,11 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator,
IIOHelper ioHelper)
: base(dataValueEditorFactory)
{
_mediaFileManager = mediaFileManager ?? throw new ArgumentNullException(nameof(mediaFileManager));
_contentSettings = contentSettings;
_uploadAutoFillProperties = uploadAutoFillProperties;
_contentService = contentService;
_ioHelper = ioHelper;
SupportsReadOnly = true;
}
/// <inheritdoc/>
public bool TryGetMediaPath(string? propertyEditorAlias, object? value, [MaybeNullWhen(false)] out string mediaPath)
{
if (propertyEditorAlias == Alias &&
@@ -59,53 +61,6 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator,
return false;
}
public void Handle(ContentCopiedNotification notification)
{
// get the upload field properties with a value
IEnumerable<IProperty> properties = notification.Original.Properties.Where(IsUploadField);
// copy files
var isUpdated = false;
foreach (IProperty property in properties)
{
// copy each of the property values (variants, segments) to the destination
foreach (IPropertyValue propertyValue in property.Values)
{
var propVal = property.GetValue(propertyValue.Culture, propertyValue.Segment);
if (propVal == null || !(propVal is string str) || str.IsNullOrWhiteSpace())
{
continue;
}
var sourcePath = _mediaFileManager.FileSystem.GetRelativePath(str);
var copyPath = _mediaFileManager.CopyFile(notification.Copy, property.PropertyType, sourcePath);
notification.Copy.SetValue(property.Alias, _mediaFileManager.FileSystem.GetUrl(copyPath),
propertyValue.Culture, propertyValue.Segment);
isUpdated = true;
}
}
// if updated, re-save the copy with the updated value
if (isUpdated)
{
_contentService.Save(notification.Copy);
}
}
public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
public void Handle(MediaSavingNotification notification)
{
foreach (IMedia entity in notification.SavedEntities)
{
AutoFillProperties(entity);
}
}
public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
/// <inheritdoc />
protected override IConfigurationEditor CreateConfigurationEditor() =>
new FileUploadConfigurationEditor(_ioHelper);
@@ -117,86 +72,43 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator,
protected override IDataValueEditor CreateValueEditor()
=> DataValueEditorFactory.Create<FileUploadPropertyValueEditor>(Attribute!);
/// <summary>
/// Gets a value indicating whether a property is an upload field.
/// </summary>
/// <param name="property">The property.</param>
/// <returns>
/// <c>true</c> if the specified property is an upload field; otherwise, <c>false</c>.
/// </returns>
private static bool IsUploadField(IProperty property) => property.PropertyType.PropertyEditorAlias ==
Constants.PropertyEditors.Aliases.UploadField;
#region Obsolete notification handler notifications
/// <summary>
/// The paths to all file upload property files contained within a collection of content entities
/// </summary>
/// <param name="entities"></param>
private IEnumerable<string> ContainedFilePaths(IEnumerable<IContentBase> entities) => entities
.SelectMany(x => x.Properties)
.Where(IsUploadField)
.SelectMany(GetFilePathsFromPropertyValues)
.Distinct();
/// <summary>
/// Look through all property values stored against the property and resolve any file paths stored
/// </summary>
/// <param name="prop"></param>
/// <returns></returns>
private IEnumerable<string> GetFilePathsFromPropertyValues(IProperty prop)
/// <inheritdoc/>
[Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadContentCopiedNotificationHandler. Scheduled for removal in Umbraco 17.")]
public void Handle(ContentCopiedNotification notification)
{
IReadOnlyCollection<IPropertyValue> propVals = prop.Values;
foreach (IPropertyValue propertyValue in propVals)
{
// check if the published value contains data and return it
var propVal = propertyValue.PublishedValue;
if (propVal != null && propVal is string str1 && !str1.IsNullOrWhiteSpace())
{
yield return _mediaFileManager.FileSystem.GetRelativePath(str1);
// This handler is no longer registered. Logic has been migrated to FileUploadContentCopiedNotificationHandler.
}
// check if the edited value contains data and return it
propVal = propertyValue.EditedValue;
if (propVal != null && propVal is string str2 && !str2.IsNullOrWhiteSpace())
/// <inheritdoc/>
[Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadMediaSavingNotificationHandler. Scheduled for removal in Umbraco 17.")]
public void Handle(MediaSavingNotification notification)
{
yield return _mediaFileManager.FileSystem.GetRelativePath(str2);
}
}
// This handler is no longer registered. Logic has been migrated to FileUploadMediaSavingNotificationHandler.
}
private void DeleteContainedFiles(IEnumerable<IContentBase> deletedEntities)
/// <inheritdoc/>
[Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadContentDeletedNotificationHandler. Scheduled for removal in Umbraco 17.")]
public void Handle(ContentDeletedNotification notification)
{
IEnumerable<string> filePathsToDelete = ContainedFilePaths(deletedEntities);
_mediaFileManager.DeleteMediaFiles(filePathsToDelete);
// This handler is no longer registered. Logic has been migrated to FileUploadContentDeletedNotificationHandler.
}
/// <summary>
/// Auto-fill properties (or clear).
/// </summary>
private void AutoFillProperties(IContentBase model)
{
IEnumerable<IProperty> properties = model.Properties.Where(IsUploadField);
foreach (IProperty property in properties)
/// <inheritdoc/>
[Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadMediaDeletedNotificationHandler. Scheduled for removal in Umbraco 17.")]
public void Handle(MediaDeletedNotification notification)
{
ImagingAutoFillUploadField? autoFillConfig = _contentSettings.CurrentValue.GetConfig(property.Alias);
if (autoFillConfig == null)
{
continue;
// This handler is no longer registered. Logic has been migrated to FileUploadMediaDeletedNotificationHandler.
}
foreach (IPropertyValue pvalue in property.Values)
/// <inheritdoc/>
[Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadMemberDeletedNotificationHandler. Scheduled for removal in Umbraco 17.")]
public void Handle(MemberDeletedNotification notification)
{
var svalue = property.GetValue(pvalue.Culture, pvalue.Segment) as string;
if (string.IsNullOrWhiteSpace(svalue))
{
_uploadAutoFillProperties.Reset(model, autoFillConfig, pvalue.Culture, pvalue.Segment);
}
else
{
_uploadAutoFillProperties.Populate(model, autoFillConfig,
_mediaFileManager.FileSystem.GetRelativePath(svalue), pvalue.Culture, pvalue.Segment);
}
}
}
// This handler is no longer registered. Logic has been migrated to FileUploadMemberDeletedNotificationHandler.
}
#endregion
}

View File

@@ -12,6 +12,7 @@ using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Infrastructure.PropertyEditors;
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Extensions;
@@ -23,12 +24,15 @@ namespace Umbraco.Cms.Core.PropertyEditors;
internal class FileUploadPropertyValueEditor : DataValueEditor
{
private readonly MediaFileManager _mediaFileManager;
private readonly IJsonSerializer _jsonSerializer;
private readonly ITemporaryFileService _temporaryFileService;
private readonly IScopeProvider _scopeProvider;
private readonly IFileStreamSecurityValidator _fileStreamSecurityValidator;
private readonly FileUploadValueParser _valueParser;
private ContentSettings _contentSettings;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadPropertyValueEditor"/> class.
/// </summary>
public FileUploadPropertyValueEditor(
DataEditorAttribute attribute,
MediaFileManager mediaFileManager,
@@ -42,10 +46,11 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
{
_mediaFileManager = mediaFileManager ?? throw new ArgumentNullException(nameof(mediaFileManager));
_jsonSerializer = jsonSerializer;
_temporaryFileService = temporaryFileService;
_scopeProvider = scopeProvider;
_fileStreamSecurityValidator = fileStreamSecurityValidator;
_valueParser = new FileUploadValueParser(jsonSerializer);
_contentSettings = contentSettings.CurrentValue ?? throw new ArgumentNullException(nameof(contentSettings));
contentSettings.OnChange(x => _contentSettings = x);
@@ -56,6 +61,7 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
IsAllowedInDataTypeConfiguration));
}
/// <inheritdoc/>
public override object? ToEditor(IProperty property, string? culture = null, string? segment = null)
{
// the stored property value (if any) is the path to the file; convert it to the client model (FileUploadValue)
@@ -63,11 +69,12 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
return propertyValue is string stringValue
? new FileUploadValue
{
Src = stringValue
Src = stringValue,
}
: null;
}
/// <inheritdoc/>
/// <summary>
/// Converts the client model (FileUploadValue) into the value can be stored in the database (the file path).
/// </summary>
@@ -83,12 +90,12 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
/// </remarks>
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
{
FileUploadValue? editorModelValue = ParseFileUploadValue(editorValue.Value);
FileUploadValue? editorModelValue = _valueParser.Parse(editorValue.Value);
// no change?
// No change or created from blueprint.
if (editorModelValue?.TemporaryFileId.HasValue is not true && string.IsNullOrEmpty(editorModelValue?.Src) is false)
{
return currentValue;
return editorModelValue.Src;
}
// the current editor value (if any) is the path to the file
@@ -146,28 +153,8 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
return filepath is null ? null : _mediaFileManager.FileSystem.GetUrl(filepath);
}
private FileUploadValue? ParseFileUploadValue(object? editorValue)
{
if (editorValue is null)
{
return null;
}
if (editorValue is string sourceString && sourceString.DetectIsJson() is false)
{
return new FileUploadValue()
{
Src = sourceString
};
}
return _jsonSerializer.TryDeserialize(editorValue, out FileUploadValue? modelValue)
? modelValue
: throw new ArgumentException($"Could not parse editor value to a {nameof(FileUploadValue)} object.");
}
private Guid? TryParseTemporaryFileKey(object? editorValue)
=> ParseFileUploadValue(editorValue)?.TemporaryFileId;
=> _valueParser.Parse(editorValue)?.TemporaryFileId;
private TemporaryFileModel? TryGetTemporaryFile(Guid temporaryFileKey)
=> _temporaryFileService.GetAsync(temporaryFileKey).GetAwaiter().GetResult();
@@ -199,8 +186,7 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
}
// get the filepath
// in case we are using the old path scheme, try to re-use numbers (bah...)
var filepath = _mediaFileManager.GetMediaPath(file.FileName, contentKey, propertyTypeKey); // fs-relative path
string filepath = GetMediaPath(file, dataTypeConfiguration, contentKey, propertyTypeKey);
using (Stream filestream = file.OpenReadStream())
{
@@ -211,9 +197,19 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
// TODO: Here it would make sense to do the auto-fill properties stuff but the API doesn't allow us to do that right
// since we'd need to be able to return values for other properties from these methods
_mediaFileManager.FileSystem.AddFile(filepath, filestream, true); // must overwrite!
_mediaFileManager.FileSystem.AddFile(filepath, filestream, overrideIfExists: true); // must overwrite!
}
return filepath;
}
/// <summary>
/// Provides media path.
/// </summary>
/// <returns>File system relative path</returns>
protected virtual string GetMediaPath(TemporaryFileModel file, object? dataTypeConfiguration, Guid contentKey, Guid propertyTypeKey)
{
// in case we are using the old path scheme, try to re-use numbers (bah...)
return _mediaFileManager.GetMediaPath(file.FileName, contentKey, propertyTypeKey);
}
}

View File

@@ -0,0 +1,45 @@
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.PropertyEditors;
/// <summary>
/// Handles the parsing of raw values to <see cref="FileUploadValue"/> objects.
/// </summary>
internal sealed class FileUploadValueParser
{
private readonly IJsonSerializer _jsonSerializer;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadValueParser"/> class.
/// </summary>
/// <param name="jsonSerializer"></param>
public FileUploadValueParser(IJsonSerializer jsonSerializer) => _jsonSerializer = jsonSerializer;
/// <summary>
/// Parses raw value to a <see cref="FileUploadValue"/>.
/// </summary>
/// <param name="editorValue">The editor value.</param>
/// <returns><a cref="FileUploadValue"></a> value</returns>
/// <exception cref="ArgumentException"></exception>
public FileUploadValue? Parse(object? editorValue)
{
if (editorValue is null)
{
return null;
}
if (editorValue is string sourceString && sourceString.DetectIsJson() is false)
{
return new FileUploadValue()
{
Src = sourceString,
};
}
return _jsonSerializer.TryDeserialize(editorValue, out FileUploadValue? modelValue)
? modelValue
: throw new ArgumentException($"Could not parse editor value to a {nameof(FileUploadValue)} object.");
}
}

View File

@@ -0,0 +1,329 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache.PropertyEditors;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
/// <summary>
/// Implements a notification handler that processes file uploads when content is copied or scaffolded from a blueprint, making
/// sure the new content references a new instance of the file.
/// </summary>
internal sealed class FileUploadContentCopiedOrScaffoldedNotificationHandler : FileUploadNotificationHandlerBase,
INotificationHandler<ContentCopiedNotification>,
INotificationHandler<ContentScaffoldedNotification>,
INotificationHandler<ContentSavedBlueprintNotification>
{
private readonly IContentService _contentService;
private readonly BlockEditorValues<BlockListValue, BlockListLayoutItem> _blockListEditorValues;
private readonly BlockEditorValues<BlockGridValue, BlockGridLayoutItem> _blockGridEditorValues;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadContentCopiedOrScaffoldedNotificationHandler"/> class.
/// </summary>
public FileUploadContentCopiedOrScaffoldedNotificationHandler(
IJsonSerializer jsonSerializer,
MediaFileManager mediaFileManager,
IBlockEditorElementTypeCache elementTypeCache,
ILogger<FileUploadContentCopiedOrScaffoldedNotificationHandler> logger,
IContentService contentService)
: base(jsonSerializer, mediaFileManager, elementTypeCache)
{
_blockListEditorValues = new(new BlockListEditorDataConverter(jsonSerializer), elementTypeCache, logger);
_blockGridEditorValues = new(new BlockGridEditorDataConverter(jsonSerializer), elementTypeCache, logger);
_contentService = contentService;
}
/// <inheritdoc/>
public void Handle(ContentCopiedNotification notification) => Handle(notification.Original, notification.Copy, (IContent c) => _contentService.Save(c));
/// <inheritdoc/>
public void Handle(ContentScaffoldedNotification notification) => Handle(notification.Original, notification.Scaffold);
/// <inheritdoc/>
public void Handle(ContentSavedBlueprintNotification notification)
{
if (notification.CreatedFromContent is null)
{
// If there is no original content, we don't need to copy files.
return;
}
Handle(notification.CreatedFromContent, notification.SavedBlueprint, (IContent c) => _contentService.SaveBlueprint(c, null));
}
private void Handle(IContent source, IContent destination, Action<IContent>? postUpdateAction = null)
{
var isUpdated = false;
foreach (IProperty property in source.Properties)
{
if (IsUploadFieldPropertyType(property.PropertyType))
{
isUpdated |= UpdateUploadFieldProperty(destination, property);
continue;
}
if (IsBlockListPropertyType(property.PropertyType))
{
isUpdated |= UpdateBlockProperty(destination, property, _blockListEditorValues);
continue;
}
if (IsBlockGridPropertyType(property.PropertyType))
{
isUpdated |= UpdateBlockProperty(destination, property, _blockGridEditorValues);
continue;
}
if (IsRichTextPropertyType(property.PropertyType))
{
isUpdated |= UpdateRichTextProperty(destination, property);
continue;
}
}
// If updated, re-save the destination with the updated value.
if (isUpdated && postUpdateAction is not null)
{
postUpdateAction(destination);
}
}
private bool UpdateUploadFieldProperty(IContent content, IProperty property)
{
var isUpdated = false;
// Copy each of the property values (variants, segments) to the destination.
foreach (IPropertyValue propertyValue in property.Values)
{
var propVal = property.GetValue(propertyValue.Culture, propertyValue.Segment);
if (propVal == null || propVal is not string sourceUrl || string.IsNullOrWhiteSpace(sourceUrl))
{
continue;
}
var copyUrl = CopyFile(sourceUrl, content, property.PropertyType);
content.SetValue(property.Alias, copyUrl, propertyValue.Culture, propertyValue.Segment);
isUpdated = true;
}
return isUpdated;
}
private bool UpdateBlockProperty<TValue, TLayout>(IContent content, IProperty property, BlockEditorValues<TValue, TLayout> blockEditorValues)
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
var isUpdated = false;
foreach (IPropertyValue blockPropertyValue in property.Values)
{
var rawBlockPropertyValue = property.GetValue(blockPropertyValue.Culture, blockPropertyValue.Segment);
BlockEditorData<TValue, TLayout>? blockEditorData = GetBlockEditorData(rawBlockPropertyValue, blockEditorValues);
(bool hasUpdates, string? updatedValue) = UpdateBlockEditorData(content, blockEditorData);
if (hasUpdates)
{
content.SetValue(property.Alias, updatedValue, blockPropertyValue.Culture, blockPropertyValue.Segment);
}
isUpdated |= hasUpdates;
}
return isUpdated;
}
private (bool, string?) UpdateBlockEditorData<TValue, TLayout>(IContent content, BlockEditorData<TValue, TLayout>? blockEditorData)
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
var isUpdated = false;
if (blockEditorData is null)
{
return (isUpdated, null);
}
IEnumerable<BlockPropertyValue> blockPropertyValues = blockEditorData.BlockValue.ContentData
.Concat(blockEditorData.BlockValue.SettingsData)
.SelectMany(x => x.Values);
isUpdated = UpdateBlockPropertyValues(content, isUpdated, blockPropertyValues);
var updatedValue = JsonSerializer.Serialize(blockEditorData.BlockValue);
return (isUpdated, updatedValue);
}
private bool UpdateRichTextProperty(IContent content, IProperty property)
{
var isUpdated = false;
foreach (IPropertyValue blockPropertyValue in property.Values)
{
var rawBlockPropertyValue = property.GetValue(blockPropertyValue.Culture, blockPropertyValue.Segment);
RichTextBlockValue? richTextBlockValue = GetRichTextBlockValue(rawBlockPropertyValue);
(bool hasUpdates, string? updatedValue) = UpdateBlockEditorData(content, richTextBlockValue);
if (hasUpdates && string.IsNullOrEmpty(updatedValue) is false)
{
RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(rawBlockPropertyValue);
if (richTextEditorValue is not null)
{
richTextEditorValue.Blocks = JsonSerializer.Deserialize<RichTextBlockValue>(updatedValue);
content.SetValue(property.Alias, JsonSerializer.Serialize(richTextEditorValue), blockPropertyValue.Culture, blockPropertyValue.Segment);
}
}
isUpdated |= hasUpdates;
}
return isUpdated;
}
private (bool, string?) UpdateBlockEditorData(IContent content, RichTextBlockValue? richTextBlockValue)
{
var isUpdated = false;
if (richTextBlockValue is null)
{
return (isUpdated, null);
}
IEnumerable<BlockPropertyValue> blockPropertyValues = richTextBlockValue.ContentData
.Concat(richTextBlockValue.SettingsData)
.SelectMany(x => x.Values);
isUpdated = UpdateBlockPropertyValues(content, isUpdated, blockPropertyValues);
var updatedValue = JsonSerializer.Serialize(richTextBlockValue);
return (isUpdated, updatedValue);
}
private bool UpdateBlockPropertyValues(IContent content, bool isUpdated, IEnumerable<BlockPropertyValue> blockPropertyValues)
{
foreach (BlockPropertyValue blockPropertyValue in blockPropertyValues)
{
if (blockPropertyValue.Value is null)
{
continue;
}
IPropertyType? propertyType = blockPropertyValue.PropertyType;
if (propertyType is null)
{
continue;
}
if (IsUploadFieldPropertyType(propertyType))
{
isUpdated |= UpdateUploadFieldBlockPropertyValue(blockPropertyValue, content, propertyType);
continue;
}
if (IsBlockListPropertyType(propertyType))
{
(bool hasUpdates, string? newValue) = UpdateBlockPropertyValue(blockPropertyValue, content, _blockListEditorValues);
isUpdated |= hasUpdates;
blockPropertyValue.Value = newValue;
continue;
}
if (IsBlockGridPropertyType(propertyType))
{
(bool hasUpdates, string? newValue) = UpdateBlockPropertyValue(blockPropertyValue, content, _blockGridEditorValues);
isUpdated |= hasUpdates;
blockPropertyValue.Value = newValue;
continue;
}
if (IsRichTextPropertyType(propertyType))
{
(bool hasUpdates, string? newValue) = UpdateRichTextPropertyValue(blockPropertyValue, content);
if (hasUpdates && string.IsNullOrEmpty(newValue) is false)
{
RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(blockPropertyValue.Value);
if (richTextEditorValue is not null)
{
isUpdated |= hasUpdates;
richTextEditorValue.Blocks = JsonSerializer.Deserialize<RichTextBlockValue>(newValue);
blockPropertyValue.Value = richTextEditorValue;
}
}
continue;
}
}
return isUpdated;
}
private bool UpdateUploadFieldBlockPropertyValue(BlockPropertyValue blockItemDataValue, IContent content, IPropertyType propertyType)
{
FileUploadValue? fileUploadValue = FileUploadValueParser.Parse(blockItemDataValue.Value);
// if original value is empty, we do not need to copy file
if (string.IsNullOrWhiteSpace(fileUploadValue?.Src))
{
return false;
}
var copyFileUrl = CopyFile(fileUploadValue.Src, content, propertyType);
blockItemDataValue.Value = copyFileUrl;
return true;
}
private (bool, string?) UpdateBlockPropertyValue<TValue, TLayout>(BlockPropertyValue blockItemDataValue, IContent content, BlockEditorValues<TValue, TLayout> blockEditorValues)
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
BlockEditorData<TValue, TLayout>? blockItemEditorDataValue = GetBlockEditorData(blockItemDataValue.Value, blockEditorValues);
return UpdateBlockEditorData(content, blockItemEditorDataValue);
}
private (bool, string?) UpdateRichTextPropertyValue(BlockPropertyValue blockItemDataValue, IContent content)
{
RichTextBlockValue? richTextBlockValue = GetRichTextBlockValue(blockItemDataValue.Value);
return UpdateBlockEditorData(content, richTextBlockValue);
}
private string CopyFile(string sourceUrl, IContent destinationContent, IPropertyType propertyType)
{
var sourcePath = MediaFileManager.FileSystem.GetRelativePath(sourceUrl);
var copyPath = MediaFileManager.CopyFile(destinationContent, propertyType, sourcePath);
return MediaFileManager.FileSystem.GetUrl(copyPath);
}
}

View File

@@ -0,0 +1,236 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache.PropertyEditors;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Infrastructure.Extensions;
namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
/// <summary>
/// Provides base class for notification handler that processes file uploads when a content entity is deleted, removing associated files.
/// </summary>
internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadNotificationHandlerBase,
INotificationHandler<ContentDeletedNotification>,
INotificationHandler<ContentDeletedBlueprintNotification>,
INotificationHandler<MediaDeletedNotification>,
INotificationHandler<MemberDeletedNotification>
{
private readonly BlockEditorValues<BlockListValue, BlockListLayoutItem> _blockListEditorValues;
private readonly BlockEditorValues<BlockGridValue, BlockGridLayoutItem> _blockGridEditorValues;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadContentDeletedNotificationHandler"/> class.
/// </summary>
public FileUploadContentDeletedNotificationHandler(
IJsonSerializer jsonSerializer,
MediaFileManager mediaFileManager,
IBlockEditorElementTypeCache elementTypeCache,
ILogger<FileUploadContentDeletedNotificationHandler> logger)
: base(jsonSerializer, mediaFileManager, elementTypeCache)
{
_blockListEditorValues = new(new BlockListEditorDataConverter(jsonSerializer), elementTypeCache, logger);
_blockGridEditorValues = new(new BlockGridEditorDataConverter(jsonSerializer), elementTypeCache, logger);
}
/// <inheritdoc/>
public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
/// <inheritdoc/>
public void Handle(ContentDeletedBlueprintNotification notification) => DeleteContainedFiles(notification.DeletedBlueprints);
/// <inheritdoc/>
public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
/// <inheritdoc/>
public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
/// <summary>
/// Deletes all file upload property files contained within a collection of content entities.
/// </summary>
/// <param name="deletedEntities"></param>
private void DeleteContainedFiles(IEnumerable<IContentBase> deletedEntities)
{
IReadOnlyList<string> filePathsToDelete = ContainedFilePaths(deletedEntities);
MediaFileManager.DeleteMediaFiles(filePathsToDelete);
}
/// <summary>
/// Gets the paths to all file upload property files contained within a collection of content entities.
/// </summary>
private IReadOnlyList<string> ContainedFilePaths(IEnumerable<IContentBase> entities)
{
var paths = new List<string>();
foreach (IProperty? property in entities.SelectMany(x => x.Properties))
{
if (IsUploadFieldPropertyType(property.PropertyType))
{
paths.AddRange(GetPathsFromUploadFieldProperty(property));
continue;
}
if (IsBlockListPropertyType(property.PropertyType))
{
paths.AddRange(GetPathsFromBlockProperty(property, _blockListEditorValues));
continue;
}
if (IsBlockGridPropertyType(property.PropertyType))
{
paths.AddRange(GetPathsFromBlockProperty(property, _blockGridEditorValues));
continue;
}
if (IsRichTextPropertyType(property.PropertyType))
{
paths.AddRange(GetPathsFromRichTextProperty(property));
continue;
}
}
return paths.Distinct().ToList().AsReadOnly();
}
private IEnumerable<string> GetPathsFromUploadFieldProperty(IProperty property)
{
foreach (IPropertyValue propertyValue in property.Values)
{
if (propertyValue.PublishedValue != null && propertyValue.PublishedValue is string publishedUrl && !string.IsNullOrWhiteSpace(publishedUrl))
{
yield return MediaFileManager.FileSystem.GetRelativePath(publishedUrl);
}
if (propertyValue.EditedValue != null && propertyValue.EditedValue is string editedUrl && !string.IsNullOrWhiteSpace(editedUrl))
{
yield return MediaFileManager.FileSystem.GetRelativePath(editedUrl);
}
}
}
private IReadOnlyCollection<string> GetPathsFromBlockProperty<TValue, TLayout>(IProperty property, BlockEditorValues<TValue, TLayout> blockEditorValues)
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
var paths = new List<string>();
foreach (IPropertyValue blockPropertyValue in property.Values)
{
paths.AddRange(GetPathsFromBlockValue(GetBlockEditorData(blockPropertyValue.PublishedValue, blockEditorValues)?.BlockValue));
paths.AddRange(GetPathsFromBlockValue(GetBlockEditorData(blockPropertyValue.EditedValue, blockEditorValues)?.BlockValue));
}
return paths;
}
private IReadOnlyCollection<string> GetPathsFromBlockValue(BlockValue? blockValue)
{
var paths = new List<string>();
if (blockValue is null)
{
return paths;
}
IEnumerable<BlockPropertyValue> blockPropertyValues = blockValue.ContentData
.Concat(blockValue.SettingsData)
.SelectMany(x => x.Values);
foreach (BlockPropertyValue blockPropertyValue in blockPropertyValues)
{
if (blockPropertyValue.Value == null)
{
continue;
}
IPropertyType? propertyType = blockPropertyValue.PropertyType;
if (propertyType == null)
{
continue;
}
if (IsUploadFieldPropertyType(propertyType))
{
FileUploadValue? originalValue = FileUploadValueParser.Parse(blockPropertyValue.Value);
if (string.IsNullOrWhiteSpace(originalValue?.Src))
{
continue;
}
paths.Add(MediaFileManager.FileSystem.GetRelativePath(originalValue.Src));
continue;
}
if (IsBlockListPropertyType(propertyType))
{
paths.AddRange(GetPathsFromBlockPropertyValue(blockPropertyValue, _blockListEditorValues));
continue;
}
if (IsBlockGridPropertyType(propertyType))
{
paths.AddRange(GetPathsFromBlockPropertyValue(blockPropertyValue, _blockGridEditorValues));
continue;
}
if (IsRichTextPropertyType(propertyType))
{
paths.AddRange(GetPathsFromRichTextPropertyValue(blockPropertyValue));
continue;
}
}
return paths;
}
private IReadOnlyCollection<string> GetPathsFromBlockPropertyValue<TValue, TLayout>(BlockPropertyValue blockItemDataValue, BlockEditorValues<TValue, TLayout> blockEditorValues)
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
BlockEditorData<TValue, TLayout>? blockItemEditorDataValue = GetBlockEditorData(blockItemDataValue.Value, blockEditorValues);
return GetPathsFromBlockValue(blockItemEditorDataValue?.BlockValue);
}
private IReadOnlyCollection<string> GetPathsFromRichTextProperty(IProperty property)
{
var paths = new List<string>();
IPropertyValue? propertyValue = property.Values.FirstOrDefault();
if (propertyValue is null)
{
return paths;
}
paths.AddRange(GetPathsFromBlockValue(GetRichTextBlockValue(propertyValue.PublishedValue)));
paths.AddRange(GetPathsFromBlockValue(GetRichTextBlockValue(propertyValue.EditedValue)));
return paths;
}
private IReadOnlyCollection<string> GetPathsFromRichTextPropertyValue(BlockPropertyValue blockItemDataValue)
{
RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(blockItemDataValue.Value);
// Ensure the property type is populated on all blocks.
richTextEditorValue?.EnsurePropertyTypePopulatedOnBlocks(ElementTypeCache);
return GetPathsFromBlockValue(richTextEditorValue?.Blocks);
}
}

View File

@@ -0,0 +1,77 @@
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Cache.PropertyEditors;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Media;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
/// <summary>
/// Implements a notification handler that processes file uploads media is saved, completing properties on the media item.
/// </summary>
internal sealed class FileUploadMediaSavingNotificationHandler : FileUploadNotificationHandlerBase, INotificationHandler<MediaSavingNotification>
{
private readonly IOptionsMonitor<ContentSettings> _contentSettings;
private readonly UploadAutoFillProperties _uploadAutoFillProperties;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadMediaSavingNotificationHandler"/> class.
/// </summary>
public FileUploadMediaSavingNotificationHandler(
IJsonSerializer jsonSerializer,
MediaFileManager mediaFileManager,
IBlockEditorElementTypeCache elementTypeCache,
IOptionsMonitor<ContentSettings> contentSettings,
UploadAutoFillProperties uploadAutoFillProperties)
: base(jsonSerializer, mediaFileManager, elementTypeCache)
{
_contentSettings = contentSettings;
_uploadAutoFillProperties = uploadAutoFillProperties;
}
/// <inheritdoc/>
public void Handle(MediaSavingNotification notification)
{
foreach (IMedia entity in notification.SavedEntities)
{
AutoFillProperties(entity);
}
}
private void AutoFillProperties(IContentBase model)
{
IEnumerable<IProperty> properties = model.Properties.Where(x => IsUploadFieldPropertyType(x.PropertyType));
foreach (IProperty property in properties)
{
ImagingAutoFillUploadField? autoFillConfig = _contentSettings.CurrentValue.GetConfig(property.Alias);
if (autoFillConfig == null)
{
continue;
}
foreach (IPropertyValue pvalue in property.Values)
{
var svalue = property.GetValue(pvalue.Culture, pvalue.Segment) as string;
if (string.IsNullOrWhiteSpace(svalue))
{
_uploadAutoFillProperties.Reset(model, autoFillConfig, pvalue.Culture, pvalue.Segment);
}
else
{
_uploadAutoFillProperties.Populate(
model,
autoFillConfig,
MediaFileManager.FileSystem.GetRelativePath(svalue),
pvalue.Culture,
pvalue.Segment);
}
}
}
}
}

View File

@@ -0,0 +1,140 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache.PropertyEditors;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Infrastructure.Extensions;
namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
/// <summary>
/// Provides a base class for all notification handlers relating to file uploads in property editors.
/// </summary>
internal abstract class FileUploadNotificationHandlerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadNotificationHandlerBase"/> class.
/// </summary>
protected FileUploadNotificationHandlerBase(
IJsonSerializer jsonSerializer,
MediaFileManager mediaFileManager,
IBlockEditorElementTypeCache elementTypeCache)
{
JsonSerializer = jsonSerializer;
MediaFileManager = mediaFileManager;
ElementTypeCache = elementTypeCache;
FileUploadValueParser = new FileUploadValueParser(jsonSerializer);
}
/// <summary>
/// Gets the <see cref="IJsonSerializer" /> used for serializing and deserializing values.
/// </summary>
protected IJsonSerializer JsonSerializer { get; }
/// <summary>
/// Gets the <see cref="MediaFileManager" /> used for managing media files.
/// </summary>
protected MediaFileManager MediaFileManager { get; }
/// <summary>
/// Gets the <IBlockEditorElementTypeCache> used for caching block editor element types.
/// </summary>
protected IBlockEditorElementTypeCache ElementTypeCache { get; }
/// <summary>
/// Gets the <see cref="FileUploadValueParser" /> used for parsing file upload values.
/// </summary>
protected FileUploadValueParser FileUploadValueParser { get; }
/// <summary>
/// Gets a value indicating whether a property is an upload field.
/// </summary>
/// <param name="propertyType">The property type.</param>
/// <returns>
/// <c>true</c> if the specified property is an upload field; otherwise, <c>false</c>.
/// </returns>
protected static bool IsUploadFieldPropertyType(IPropertyType propertyType)
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.UploadField;
/// <summary>
/// Gets a value indicating whether a property is an block list field.
/// </summary>
/// <param name="propertyType">The property type.</param>
/// <returns>
/// <c>true</c> if the specified property is an block list field; otherwise, <c>false</c>.
/// </returns>
protected static bool IsBlockListPropertyType(IPropertyType propertyType)
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.BlockList;
/// <summary>
/// Gets a value indicating whether a property is an block grid field.
/// </summary>
/// <param name="propertyType">The property type.</param>
/// <returns>
/// <c>true</c> if the specified property is an block grid field; otherwise, <c>false</c>.
/// </returns>
protected static bool IsBlockGridPropertyType(IPropertyType propertyType)
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.BlockGrid;
/// <summary>
/// Gets a value indicating whether a property is an rich text field (supporting blocks).
/// </summary>
/// <param name="propertyType">The property type.</param>
/// <returns>
/// <c>true</c> if the specified property is an rich text field; otherwise, <c>false</c>.
/// </returns>
protected static bool IsRichTextPropertyType(IPropertyType propertyType)
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.RichText ||
propertyType.PropertyEditorAlias == "Umbraco.TinyMCE";
/// <summary>
/// Deserializes the block editor data value.
/// </summary>
protected static BlockEditorData<TValue, TLayout>? GetBlockEditorData<TValue, TLayout>(object? value, BlockEditorValues<TValue, TLayout> blockListEditorValues)
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
try
{
return blockListEditorValues.DeserializeAndClean(value);
}
catch
{
// If this occurs it means the data is invalid. Shouldn't happen but could if we change the data format.
return null;
}
}
/// <summary>
/// Deserializes the rich text editor value.
/// </summary>
protected RichTextEditorValue? GetRichTextEditorValue(object? value)
{
if (value is null)
{
return null;
}
JsonSerializer.TryDeserialize(value, out RichTextEditorValue? richTextEditorValue);
return richTextEditorValue;
}
/// <summary>
/// Deserializes the rich text block value.
/// </summary>
protected RichTextBlockValue? GetRichTextBlockValue(object? value)
{
RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(value);
if (richTextEditorValue?.Blocks is null)
{
return null;
}
// Ensure the property type is populated on all blocks.
richTextEditorValue.EnsurePropertyTypePopulatedOnBlocks(ElementTypeCache);
return richTextEditorValue.Blocks;
}
}

View File

@@ -16,6 +16,7 @@ using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Core.Templates;
using Umbraco.Cms.Infrastructure.Extensions;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
@@ -33,8 +34,11 @@ public class RichTextPropertyEditor : DataEditor
private readonly IRichTextPropertyIndexValueFactory _richTextPropertyIndexValueFactory;
/// <summary>
/// The constructor will setup the property editor based on the attribute if one is found.
/// Initializes a new instance of the <see cref="RichTextPropertyEditor"/> class.
/// </summary>
/// <remarks>
/// The constructor will setup the property editor based on the attribute if one is found.
/// </remarks>
public RichTextPropertyEditor(
IDataValueEditorFactory dataValueEditorFactory,
IIOHelper ioHelper,
@@ -43,6 +47,7 @@ public class RichTextPropertyEditor : DataEditor
{
_ioHelper = ioHelper;
_richTextPropertyIndexValueFactory = richTextPropertyIndexValueFactory;
SupportsReadOnly = true;
}
@@ -95,6 +100,7 @@ public class RichTextPropertyEditor : DataEditor
private readonly IRichTextRequiredValidator _richTextRequiredValidator;
private readonly IRichTextRegexValidator _richTextRegexValidator;
private readonly ILogger<RichTextPropertyValueEditor> _logger;
private readonly IBlockEditorElementTypeCache _elementTypeCache;
public RichTextPropertyValueEditor(
DataEditorAttribute attribute,
@@ -123,6 +129,7 @@ public class RichTextPropertyEditor : DataEditor
_localLinkParser = localLinkParser;
_pastedImages = pastedImages;
_htmlSanitizer = htmlSanitizer;
_elementTypeCache = elementTypeCache;
_richTextRequiredValidator = richTextRequiredValidator;
_richTextRegexValidator = richTextRegexValidator;
_jsonSerializer = jsonSerializer;
@@ -242,7 +249,23 @@ public class RichTextPropertyEditor : DataEditor
/// <returns></returns>
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
{
if (TryParseEditorValue(editorValue.Value, out RichTextEditorValue? richTextEditorValue) is false)
// See note on BlockEditorPropertyValueEditor.FromEditor for why we can't return early with only a null or empty editorValue.
TryParseEditorValue(editorValue.Value, out RichTextEditorValue? richTextEditorValue);
TryParseEditorValue(currentValue, out RichTextEditorValue? currentRichTextEditorValue);
// We can early return if we have a null value and the current value doesn't have any blocks.
if (richTextEditorValue is null && currentRichTextEditorValue?.Blocks is null)
{
return null;
}
// Ensure the property type is populated on all blocks.
richTextEditorValue?.EnsurePropertyTypePopulatedOnBlocks(_elementTypeCache);
currentRichTextEditorValue?.EnsurePropertyTypePopulatedOnBlocks(_elementTypeCache);
RichTextEditorValue cleanedUpRichTextEditorValue = CleanAndMapBlocks(richTextEditorValue, blockValue => MapBlockValueFromEditor(blockValue, currentRichTextEditorValue?.Blocks, editorValue.ContentKey));
if (string.IsNullOrWhiteSpace(richTextEditorValue?.Markup))
{
return null;
}
@@ -253,11 +276,6 @@ public class RichTextPropertyEditor : DataEditor
var config = editorValue.DataTypeConfiguration as RichTextConfiguration;
Guid mediaParentId = config?.MediaParentId ?? Guid.Empty;
if (string.IsNullOrWhiteSpace(richTextEditorValue.Markup))
{
return null;
}
var parseAndSavedTempImages = _pastedImages
.FindAndPersistPastedTempImagesAsync(richTextEditorValue.Markup, mediaParentId, userKey)
.GetAwaiter()
@@ -267,8 +285,6 @@ public class RichTextPropertyEditor : DataEditor
richTextEditorValue.Markup = sanitized.NullOrWhiteSpaceAsNull() ?? string.Empty;
RichTextEditorValue cleanedUpRichTextEditorValue = CleanAndMapBlocks(richTextEditorValue, MapBlockValueFromEditor);
// return json
return RichTextPropertyEditorHelper.SerializeRichTextEditorValue(cleanedUpRichTextEditorValue, _jsonSerializer);
}
@@ -377,19 +393,26 @@ public class RichTextPropertyEditor : DataEditor
private bool TryParseEditorValue(object? value, [NotNullWhen(true)] out RichTextEditorValue? richTextEditorValue)
=> RichTextPropertyEditorHelper.TryParseRichTextEditorValue(value, _jsonSerializer, _logger, out richTextEditorValue);
private RichTextEditorValue CleanAndMapBlocks(RichTextEditorValue richTextEditorValue, Action<RichTextBlockValue> handleMapping)
private RichTextEditorValue CleanAndMapBlocks(RichTextEditorValue? richTextEditorValue, Action<RichTextBlockValue> handleMapping)
{
if (richTextEditorValue.Blocks is null)
// We handle mapping of blocks even if the edited value is empty, so property editors can clean up any resources
// relating to removed blocks, e.g. files uploaded to the media library from the file upload property editor.
BlockEditorData<RichTextBlockValue, RichTextBlockLayoutItem>? blockEditorData = null;
if (richTextEditorValue?.Blocks is not null)
{
blockEditorData = ConvertAndClean(richTextEditorValue.Blocks);
}
handleMapping(blockEditorData?.BlockValue ?? new RichTextBlockValue());
if (richTextEditorValue?.Blocks is null)
{
// no blocks defined, store empty block value
return MarkupWithEmptyBlocks();
}
BlockEditorData<RichTextBlockValue, RichTextBlockLayoutItem>? blockEditorData = ConvertAndClean(richTextEditorValue.Blocks);
if (blockEditorData is not null)
{
handleMapping(blockEditorData.BlockValue);
return new RichTextEditorValue
{
Markup = richTextEditorValue.Markup,
@@ -402,7 +425,7 @@ public class RichTextPropertyEditor : DataEditor
RichTextEditorValue MarkupWithEmptyBlocks() => new()
{
Markup = richTextEditorValue.Markup,
Markup = richTextEditorValue?.Markup ?? string.Empty,
Blocks = new RichTextBlockValue(),
};
}

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Text.RegularExpressions;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Serialization;
@@ -50,7 +51,7 @@ internal class RichTextPropertyIndexValueFactory : BlockValuePropertyIndexValueF
};
// the actual content (RTE content without markup, i.e. the actual words) must be indexed under the property alias
var richTextWithoutMarkup = richTextEditorValue.Markup.StripHtml();
var richTextWithoutMarkup = StripHtmlForIndexing(richTextEditorValue.Markup);
if (richTextEditorValue.Blocks?.ContentData.Any() is not true)
{
// no blocks; index the content for the culture and be done with it
@@ -132,4 +133,27 @@ internal class RichTextPropertyIndexValueFactory : BlockValuePropertyIndexValueF
protected override IEnumerable<RawDataItem> GetDataItems(RichTextEditorValue input, bool published)
=> GetDataItems(input.Blocks?.ContentData ?? [], input.Blocks?.Expose ?? [], published);
/// <summary>
/// Strips HTML tags from content while preserving whitespace from line breaks.
/// This addresses the issue where &lt;br&gt; tags don't create word boundaries when HTML is stripped.
/// </summary>
/// <param name="html">The HTML content to strip</param>
/// <returns>Plain text with proper word boundaries</returns>
private static string StripHtmlForIndexing(string html)
{
if (string.IsNullOrWhiteSpace(html))
{
return string.Empty;
}
// Replace <br> and <br/> tags (with any amount of whitespace and attributes) with spaces
// This regex matches:
// - <br> (with / without spaces or attributes)
// - <br /> (with / without spaces or attributes)
html = Regex.Replace(html, @"<br\b[^>]*/?>\s*", " ", RegexOptions.IgnoreCase);
// Use the existing Microsoft StripHtml function for everything else
return html.StripHtml();
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Models.Blocks;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// A handler for Rich Text editors used to bind to notifications.
/// </summary>
public class RichTextPropertyNotificationHandler : BlockEditorPropertyNotificationHandlerBase<RichTextBlockLayoutItem>
{
public RichTextPropertyNotificationHandler(ILogger<RichTextPropertyNotificationHandler> logger)
: base(logger)
{
}
protected override string EditorAlias => Constants.PropertyEditors.Aliases.RichText;
}

View File

@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models.TemporaryFile;
using Umbraco.Cms.Core.Models.Validation;
@@ -6,20 +6,20 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
internal class TemporaryFileUploadValidator : IValueValidator
public class TemporaryFileUploadValidator : IValueValidator
{
private readonly GetContentSettings _getContentSettings;
private readonly ParseTemporaryFileKey _parseTemporaryFileKey;
private readonly GetTemporaryFileModel _getTemporaryFileModel;
private readonly ValidateFileType? _validateFileType;
internal delegate ContentSettings GetContentSettings();
public delegate ContentSettings GetContentSettings();
internal delegate Guid? ParseTemporaryFileKey(object? editorValue);
public delegate Guid? ParseTemporaryFileKey(object? editorValue);
internal delegate TemporaryFileModel? GetTemporaryFileModel(Guid temporaryFileKey);
public delegate TemporaryFileModel? GetTemporaryFileModel(Guid temporaryFileKey);
internal delegate bool ValidateFileType(string extension, object? dataTypeConfiguration);
public delegate bool ValidateFileType(string extension, object? dataTypeConfiguration);
public TemporaryFileUploadValidator(
GetContentSettings getContentSettings,

View File

@@ -122,42 +122,29 @@ namespace Umbraco.Cms
/// <inheritdoc />
public ProcessInstructionsResult ProcessInstructions(
CacheRefresherCollection cacheRefreshers,
ServerRole serverRole,
CancellationToken cancellationToken,
string localIdentity,
DateTime lastPruned,
int lastId)
{
using (!_profilingLogger.IsEnabled(Core.Logging.LogLevel.Debug) ? null : _profilingLogger.DebugDuration<CacheInstructionService>("Syncing from database..."))
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
var numberOfInstructionsProcessed = ProcessDatabaseInstructions(cacheRefreshers, cancellationToken, localIdentity, ref lastId);
// Check for pruning throttling.
if (cancellationToken.IsCancellationRequested || DateTime.UtcNow - lastPruned <=
_globalSettings.DatabaseServerMessenger.TimeBetweenPruneOperations)
{
scope.Complete();
return ProcessInstructionsResult.AsCompleted(numberOfInstructionsProcessed, lastId);
}
var instructionsWerePruned = false;
switch (serverRole)
{
case ServerRole.Single:
case ServerRole.SchedulingPublisher:
PruneOldInstructions();
instructionsWerePruned = true;
break;
}
scope.Complete();
return instructionsWerePruned
? ProcessInstructionsResult.AsCompletedAndPruned(numberOfInstructionsProcessed, lastId)
: ProcessInstructionsResult.AsCompleted(numberOfInstructionsProcessed, lastId);
}
}
/// <inheritdoc />
[Obsolete("Use the non-obsolete overload. Scheduled for removal in V17.")]
public ProcessInstructionsResult ProcessInstructions(
CacheRefresherCollection cacheRefreshers,
ServerRole serverRole,
CancellationToken cancellationToken,
string localIdentity,
DateTime lastPruned,
int lastId) =>
ProcessInstructions(cacheRefreshers, cancellationToken, localIdentity, lastId);
private CacheInstruction CreateCacheInstruction(IEnumerable<RefreshInstruction> instructions, string localIdentity)
=> new(
@@ -486,21 +473,6 @@ namespace Umbraco.Cms
return jsonRefresher;
}
/// <summary>
/// Remove old instructions from the database
/// </summary>
/// <remarks>
/// Always leave the last (most recent) record in the db table, this is so that not all instructions are removed which
/// would cause
/// the site to cold boot if there's been no instruction activity for more than TimeToRetainInstructions.
/// See: http://issues.umbraco.org/issue/U4-7643#comment=67-25085
/// </remarks>
private void PruneOldInstructions()
{
DateTime pruneDate = DateTime.UtcNow - _globalSettings.DatabaseServerMessenger.TimeToRetainInstructions;
_cacheInstructionRepository.DeleteInstructionsOlderThan(pruneDate);
}
}
}
}

View File

@@ -331,8 +331,9 @@ public class PackagingService : IPackagingService
/// <inheritdoc/>
public Task<PagedModel<InstalledPackage>> GetInstalledPackagesFromMigrationPlansAsync(int skip, int take)
{
IReadOnlyDictionary<string, string?>? keyValues =
_keyValueService.FindByKeyPrefix(Constants.Conventions.Migrations.KeyValuePrefix);
IReadOnlyDictionary<string, string?> keyValues =
_keyValueService.FindByKeyPrefix(Constants.Conventions.Migrations.KeyValuePrefix)
?? new Dictionary<string, string?>();
InstalledPackage[] installedPackages = _packageMigrationPlans
.GroupBy(plan => (plan.PackageName, plan.PackageId))
@@ -343,15 +344,21 @@ public class PackagingService : IPackagingService
PackageName = group.Key.PackageName,
};
var packageKey = Constants.Conventions.Migrations.KeyValuePrefix + (group.Key.PackageId ?? group.Key.PackageName);
var currentState = keyValues?
.GetValueOrDefault(packageKey);
package.PackageMigrationPlans = group
.Select(plan => new InstalledPackageMigrationPlans
.Select(plan =>
{
CurrentMigrationId = currentState,
FinalMigrationId = plan.FinalState,
// look for migration states in this order:
// - plan name
// - package identifier
// - package name
var currentState =
keyValues.GetValueOrDefault($"{Constants.Conventions.Migrations.KeyValuePrefix}{plan.Name}")
?? keyValues.GetValueOrDefault($"{Constants.Conventions.Migrations.KeyValuePrefix}{plan.PackageId ?? plan.PackageName}");
return new InstalledPackageMigrationPlans
{
CurrentMigrationId = currentState, FinalMigrationId = plan.FinalState,
};
});
return package;

View File

@@ -16,12 +16,41 @@ namespace Umbraco.Cms.Infrastructure.Sync;
/// </summary>
public class BatchedDatabaseServerMessenger : DatabaseServerMessenger
{
private readonly IRequestAccessor _requestAccessor;
private readonly IRequestCache _requestCache;
/// <summary>
/// Initializes a new instance of the <see cref="BatchedDatabaseServerMessenger" /> class.
/// </summary>
public BatchedDatabaseServerMessenger(
IMainDom mainDom,
CacheRefresherCollection cacheRefreshers,
ILogger<BatchedDatabaseServerMessenger> logger,
ISyncBootStateAccessor syncBootStateAccessor,
IHostingEnvironment hostingEnvironment,
ICacheInstructionService cacheInstructionService,
IJsonSerializer jsonSerializer,
IRequestCache requestCache,
LastSyncedFileManager lastSyncedFileManager,
IOptionsMonitor<GlobalSettings> globalSettings)
: base(
mainDom,
cacheRefreshers,
logger,
true,
syncBootStateAccessor,
hostingEnvironment,
cacheInstructionService,
jsonSerializer,
lastSyncedFileManager,
globalSettings)
{
_requestCache = requestCache;
}
/// <summary>
/// Initializes a new instance of the <see cref="BatchedDatabaseServerMessenger" /> class.
/// </summary>
[Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in V18.")]
public BatchedDatabaseServerMessenger(
IMainDom mainDom,
CacheRefresherCollection cacheRefreshers,
@@ -35,11 +64,18 @@ public class BatchedDatabaseServerMessenger : DatabaseServerMessenger
IRequestAccessor requestAccessor,
LastSyncedFileManager lastSyncedFileManager,
IOptionsMonitor<GlobalSettings> globalSettings)
: base(mainDom, cacheRefreshers, serverRoleAccessor, logger, true, syncBootStateAccessor, hostingEnvironment,
cacheInstructionService, jsonSerializer, lastSyncedFileManager, globalSettings)
: this(
mainDom,
cacheRefreshers,
logger,
syncBootStateAccessor,
hostingEnvironment,
cacheInstructionService,
jsonSerializer,
requestCache,
lastSyncedFileManager,
globalSettings)
{
_requestCache = requestCache;
_requestAccessor = requestAccessor;
}
/// <inheritdoc />

View File

@@ -31,11 +31,9 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
*/
private readonly IMainDom _mainDom;
private readonly IServerRoleAccessor _serverRoleAccessor;
private readonly ISyncBootStateAccessor _syncBootStateAccessor;
private readonly ManualResetEvent _syncIdle;
private bool _disposedValue;
private DateTime _lastPruned;
private DateTime _lastSync;
private bool _syncing;
@@ -45,7 +43,6 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
protected DatabaseServerMessenger(
IMainDom mainDom,
CacheRefresherCollection cacheRefreshers,
IServerRoleAccessor serverRoleAccessor,
ILogger<DatabaseServerMessenger> logger,
bool distributedEnabled,
ISyncBootStateAccessor syncBootStateAccessor,
@@ -59,7 +56,6 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
_cancellationToken = _cancellationTokenSource.Token;
_mainDom = mainDom;
_cacheRefreshers = cacheRefreshers;
_serverRoleAccessor = serverRoleAccessor;
_hostingEnvironment = hostingEnvironment;
Logger = logger;
_syncBootStateAccessor = syncBootStateAccessor;
@@ -67,7 +63,7 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
JsonSerializer = jsonSerializer;
_lastSyncedFileManager = lastSyncedFileManager;
GlobalSettings = globalSettings.CurrentValue;
_lastPruned = _lastSync = DateTime.UtcNow;
_lastSync = DateTime.UtcNow;
_syncIdle = new ManualResetEvent(true);
globalSettings.OnChange(x => GlobalSettings = x);
@@ -84,6 +80,36 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
_initialized = new Lazy<SyncBootState?>(InitializeWithMainDom);
}
/// <summary>
/// Initializes a new instance of the <see cref="DatabaseServerMessenger" /> class.
/// </summary>
[Obsolete("Use the non-obsolete constructor. Scheduled for removal in V18.")]
protected DatabaseServerMessenger(
IMainDom mainDom,
CacheRefresherCollection cacheRefreshers,
IServerRoleAccessor serverRoleAccessor,
ILogger<DatabaseServerMessenger> logger,
bool distributedEnabled,
ISyncBootStateAccessor syncBootStateAccessor,
IHostingEnvironment hostingEnvironment,
ICacheInstructionService cacheInstructionService,
IJsonSerializer jsonSerializer,
LastSyncedFileManager lastSyncedFileManager,
IOptionsMonitor<GlobalSettings> globalSettings)
: this(
mainDom,
cacheRefreshers,
logger,
distributedEnabled,
syncBootStateAccessor,
hostingEnvironment,
cacheInstructionService,
jsonSerializer,
lastSyncedFileManager,
globalSettings)
{
}
public GlobalSettings GlobalSettings { get; private set; }
protected ILogger<DatabaseServerMessenger> Logger { get; }
@@ -146,17 +172,10 @@ public abstract class DatabaseServerMessenger : ServerMessengerBase, IDisposable
{
ProcessInstructionsResult result = CacheInstructionService.ProcessInstructions(
_cacheRefreshers,
_serverRoleAccessor.CurrentServerRole,
_cancellationToken,
LocalIdentity,
_lastPruned,
_lastSyncedFileManager.LastSyncedId);
if (result.InstructionsWerePruned)
{
_lastPruned = _lastSync;
}
if (result.LastId > 0)
{
_lastSyncedFileManager.SaveLastSyncedId(result.LastId);

View File

@@ -1,9 +1,9 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// Serializes/Deserializes <see cref="ContentCacheDataModel" /> document to the SQL Database as a string
/// Serializes/Deserializes <see cref="ContentCacheDataModel" /> document to the SQL Database as a string.
/// </summary>
/// <remarks>
/// Resolved from the <see cref="IContentCacheDataSerializerFactory" />. This cannot be resolved from DI.
@@ -11,12 +11,12 @@ namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
internal interface IContentCacheDataSerializer
{
/// <summary>
/// Deserialize the data into a <see cref="ContentCacheDataModel" />
/// Deserialize the data into a <see cref="ContentCacheDataModel" />.
/// </summary>
ContentCacheDataModel? Deserialize(IReadOnlyContentBase content, string? stringData, byte[]? byteData, bool published);
/// <summary>
/// Serializes the <see cref="ContentCacheDataModel" />
/// Serializes the <see cref="ContentCacheDataModel" />.
/// </summary>
ContentCacheDataSerializationResult Serialize(IReadOnlyContentBase content, ContentCacheDataModel model, bool published);
}

View File

@@ -1,14 +1,21 @@
using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// Serializes/deserializes <see cref="ContentCacheDataModel" /> documents to the SQL Database as JSON.
/// </summary>
internal class JsonContentNestedDataSerializer : IContentCacheDataSerializer
{
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters =
{
new JsonObjectConverter(),
},
};
/// <inheritdoc />
@@ -36,4 +43,27 @@ internal class JsonContentNestedDataSerializer : IContentCacheDataSerializer
var json = JsonSerializer.Serialize(model, _jsonSerializerOptions);
return new ContentCacheDataSerializationResult(json, null);
}
/// <summary>
/// Provides a converter for handling JSON objects that can be of various types (string, number, boolean, null, or complex types).
/// </summary>
internal class JsonObjectConverter : JsonConverter<object>
{
/// <inheritdoc/>
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> reader.TokenType switch
{
JsonTokenType.String => reader.GetString(),
JsonTokenType.Number => reader.TryGetInt64(out var value) ? value : reader.GetDouble(),
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.Null => null,
_ => JsonDocument.ParseValue(ref reader).RootElement.Clone(), // fallback for complex types
};
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
=> JsonSerializer.Serialize(writer, value, value.GetType(), options);
}
}

View File

@@ -1,4 +1,4 @@
using System.Text;
using System.Text;
using K4os.Compression.LZ4;
using MessagePack;
using MessagePack.Resolvers;
@@ -8,14 +8,17 @@ using Umbraco.Cms.Core.PropertyEditors;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// Serializes/Deserializes <see cref="ContentCacheDataModel" /> document to the SQL Database as bytes using
/// MessagePack
/// Serializes/deserializes <see cref="ContentCacheDataModel" /> documents to the SQL Database as bytes using
/// MessagePack.
/// </summary>
internal sealed class MsgPackContentNestedDataSerializer : IContentCacheDataSerializer
{
private readonly MessagePackSerializerOptions _options;
private readonly IPropertyCacheCompression _propertyOptions;
/// <summary>
/// Initializes a new instance of the <see cref="MsgPackContentNestedDataSerializer"/> class.
/// </summary>
public MsgPackContentNestedDataSerializer(IPropertyCacheCompression propertyOptions)
{
_propertyOptions = propertyOptions ?? throw new ArgumentNullException(nameof(propertyOptions));
@@ -40,6 +43,7 @@ internal sealed class MsgPackContentNestedDataSerializer : IContentCacheDataSeri
.WithSecurity(MessagePackSecurity.UntrustedData);
}
/// <inheritdoc/>
public ContentCacheDataModel? Deserialize(IReadOnlyContentBase content, string? stringData, byte[]? byteData, bool published)
{
if (byteData != null)
@@ -62,6 +66,7 @@ internal sealed class MsgPackContentNestedDataSerializer : IContentCacheDataSeri
return null;
}
/// <inheritdoc/>
public ContentCacheDataSerializationResult Serialize(IReadOnlyContentBase content, ContentCacheDataModel model, bool published)
{
Compress(content, model, published);
@@ -69,11 +74,10 @@ internal sealed class MsgPackContentNestedDataSerializer : IContentCacheDataSeri
return new ContentCacheDataSerializationResult(null, bytes);
}
public string ToJson(byte[] bin)
{
var json = MessagePackSerializer.ConvertToJson(bin, _options);
return json;
}
/// <summary>
/// Converts the binary MessagePack data to a JSON string representation.
/// </summary>
public string ToJson(byte[] bin) => MessagePackSerializer.ConvertToJson(bin, _options);
/// <summary>
/// Used during serialization to compress properties

View File

@@ -189,6 +189,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddRecurringBackgroundJob<WebhookFiring>();
builder.Services.AddRecurringBackgroundJob<WebhookLoggingCleanup>();
builder.Services.AddRecurringBackgroundJob<ReportSiteJob>();
builder.Services.AddRecurringBackgroundJob<CacheInstructionsPruningJob>();
builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory);

View File

@@ -4,12 +4,12 @@ import remarkGfm from 'remark-gfm';
const config: StorybookConfig = {
stories: ['../@(src|libs|apps|storybook)/**/*.mdx', '../@(src|libs|apps|storybook)/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
getAbsolutePath('@storybook/addon-links'),
getAbsolutePath('@storybook/addon-essentials'),
getAbsolutePath('@storybook/addon-a11y'),
{
name: '@storybook/addon-docs',
name: getAbsolutePath('@storybook/addon-docs'),
options: {
mdxPluginOptions: {
mdxCompileOptions: {
@@ -19,10 +19,12 @@ const config: StorybookConfig = {
},
},
],
framework: {
name: getAbsolutePath('@storybook/web-components-vite'),
options: {},
},
staticDirs: [
'../public-assets',
'../public',
@@ -32,12 +34,11 @@ const config: StorybookConfig = {
to: 'assets/icons',
},
],
typescript: {
check: true,
},
docs: {
autodocs: true
},
managerHead(head, { configType }) {
const base = process.env.VITE_BASE_PATH || '/';
const injections = [
@@ -45,10 +46,12 @@ const config: StorybookConfig = {
];
return configType === 'PRODUCTION' ? `${injections.join('')}${head}` : head;
},
refs: {
uui: {
title: 'Umbraco UI Library',
url: 'https://62189360eeb21b003ab2f4ad-vfnpsanjps.chromatic.com/',
url: 'https://uui.umbraco.com/',
expanded: false,
},
},
};

View File

@@ -1,4 +1,4 @@
import { addons } from '@storybook/manager-api';
import { addons } from 'storybook/manager-api';
addons.setConfig({
enableShortcuts: false,

View File

@@ -5,7 +5,7 @@ import 'element-internals-polyfill';
import '@umbraco-ui/uui';
import { html } from 'lit';
import { setCustomElements } from '@storybook/web-components';
import { setCustomElements } from '@storybook/web-components-vite';
import { startMockServiceWorker } from '../src/mocks';

View File

@@ -1,7 +1,10 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import js from '@eslint/js';
import globals from 'globals';
import importPlugin from 'eslint-plugin-import';
import localRules from 'eslint-plugin-local-rules';
import storybook from 'eslint-plugin-storybook';
import wcPlugin from 'eslint-plugin-wc';
import litPlugin from 'eslint-plugin-lit';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
@@ -13,8 +16,8 @@ export default [
js.configs.recommended,
...tseslint.configs.recommended,
wcPlugin.configs['flat/recommended'],
litPlugin.configs['flat/recommended'],
jsdoc.configs['flat/recommended'], // We use the non typescript version to allow types to be defined in the jsdoc comments. This will allow js docs as an alternative to typescript types.
litPlugin.configs['flat/recommended'], // We use the non typescript version to allow types to be defined in the jsdoc comments. This will allow js docs as an alternative to typescript types.
jsdoc.configs['flat/recommended'],
localRules.configs.all,
eslintPluginPrettierRecommended,
@@ -101,4 +104,5 @@ export default [
},
},
},
...storybook.configs['flat/recommended'],
];

View File

@@ -0,0 +1,20 @@
# Collection Example
This example demonstrates how to register a collection with collection views.
The example includes:
- Collection Registration
- Collection Repository
- Collection Pagination
- Table Collection View
- Card Collection View
- Collection as a Dashboard
- Collection as a Workspace View
TODO: This example is not complete, it is missing the following features:
- Collection Action
- Collection Filtering
- Entity Actions
- Selection + Bulk Actions

View File

@@ -0,0 +1,82 @@
import type { ExampleCollectionItemModel } from '../repository/types.js';
import type { UmbDefaultCollectionContext } from '@umbraco-cms/backoffice/collection';
import { UMB_COLLECTION_CONTEXT } from '@umbraco-cms/backoffice/collection';
import { css, html, customElement, state, repeat } from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
@customElement('example-card-collection-view')
export class ExampleCardCollectionViewElement extends UmbLitElement {
@state()
private _items: Array<ExampleCollectionItemModel> = [];
#collectionContext?: UmbDefaultCollectionContext<ExampleCollectionItemModel>;
constructor() {
super();
this.consumeContext(UMB_COLLECTION_CONTEXT, (instance) => {
this.#collectionContext = instance;
this.#observeCollectionItems();
});
}
#observeCollectionItems() {
this.observe(this.#collectionContext?.items, (items) => (this._items = items || []), 'umbCollectionItemsObserver');
}
override render() {
return html`
<div id="card-grid">
${repeat(
this._items,
(item) => item.unique,
(item) =>
html` <uui-card>
<uui-icon name="icon-newspaper"></uui-icon>
<div>${item.name}</div>
</uui-card>`,
)}
</div>
`;
}
static override styles = [
UmbTextStyles,
css`
:host {
display: flex;
flex-direction: column;
}
#card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-auto-rows: 200px;
gap: var(--uui-size-space-5);
}
uui-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
height: 100%;
uui-icon {
font-size: 2em;
margin-bottom: var(--uui-size-space-4);
}
}
`,
];
}
export { ExampleCardCollectionViewElement as element };
declare global {
interface HTMLElementTagNameMap {
'example-card-collection-view': ExampleCardCollectionViewElement;
}
}

View File

@@ -0,0 +1,23 @@
import { EXAMPLE_COLLECTION_ALIAS } from '../constants.js';
import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection';
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'collectionView',
alias: 'Example.CollectionView.Card',
name: 'Example Card Collection View',
js: () => import('./collection-view.element.js'),
weight: 50,
meta: {
label: 'Card',
icon: 'icon-grid',
pathName: 'card',
},
conditions: [
{
alias: UMB_COLLECTION_ALIAS_CONDITION,
match: EXAMPLE_COLLECTION_ALIAS,
},
],
},
];

View File

@@ -0,0 +1 @@
export const EXAMPLE_COLLECTION_ALIAS = 'Example.Collection';

View File

@@ -0,0 +1,20 @@
import { EXAMPLE_COLLECTION_ALIAS } from './constants.js';
import { EXAMPLE_COLLECTION_REPOSITORY_ALIAS } from './repository/constants.js';
import { manifests as cardViewManifests } from './card-view/manifests.js';
import { manifests as repositoryManifests } from './repository/manifests.js';
import { manifests as tableViewManifests } from './table-view/manifests.js';
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'collection',
kind: 'default',
alias: EXAMPLE_COLLECTION_ALIAS,
name: 'Example Collection',
meta: {
repositoryAlias: EXAMPLE_COLLECTION_REPOSITORY_ALIAS,
},
},
...cardViewManifests,
...repositoryManifests,
...tableViewManifests,
];

View File

@@ -0,0 +1,64 @@
import type { ExampleCollectionFilterModel, ExampleCollectionItemModel } from './types.js';
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';
import type { UmbCollectionRepository } from '@umbraco-cms/backoffice/collection';
export class ExampleCollectionRepository
extends UmbRepositoryBase
implements UmbCollectionRepository<ExampleCollectionItemModel, ExampleCollectionFilterModel>
{
async requestCollection(args: ExampleCollectionFilterModel) {
const skip = args.skip || 0;
const take = args.take || 10;
// Simulating a data fetch. This would in most cases be replaced with an API call.
let items = [
{
unique: '3e31e9c5-7d66-4c99-a9e5-d9f2b1e2b22f',
entityType: 'example',
name: 'Example Item 1',
},
{
unique: 'bc9b6e24-4b11-4dd6-8d4e-7c4f70e59f3c',
entityType: 'example',
name: 'Example Item 2',
},
{
unique: '5a2f4e3a-ef7e-470e-8c3c-3d859c02ae0d',
entityType: 'example',
name: 'Example Item 3',
},
{
unique: 'f4c3d8b8-6d79-4c87-9aa9-56b1d8fda702',
entityType: 'example',
name: 'Example Item 4',
},
{
unique: 'c9f0a8a3-1b49-4724-bde3-70e31592eb6e',
entityType: 'example',
name: 'Example Item 5',
},
];
// Simulating filtering based on the args
if (args.filter) {
items = items.filter((item) => item.name.toLowerCase().includes(args.filter!.toLowerCase()));
}
// Get the total number of items before pagination
const totalItems = items.length;
// Simulating pagination
const start = skip;
const end = start + take;
items = items.slice(start, end);
const data = {
items,
total: totalItems,
};
return { data };
}
}
export { ExampleCollectionRepository as api };

View File

@@ -0,0 +1 @@
export const EXAMPLE_COLLECTION_REPOSITORY_ALIAS = 'Example.Repository.Collection';

View File

@@ -0,0 +1,10 @@
import { EXAMPLE_COLLECTION_REPOSITORY_ALIAS } from './constants.js';
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'repository',
alias: EXAMPLE_COLLECTION_REPOSITORY_ALIAS,
name: 'Example Collection Repository',
api: () => import('./collection.repository.js'),
},
];

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