diff --git a/src/Umbraco.Core/PropertyEditors/FileUploadConfiguration.cs b/src/Umbraco.Core/PropertyEditors/FileUploadConfiguration.cs index b1531c6548c1..e7c9bf4e28bf 100644 --- a/src/Umbraco.Core/PropertyEditors/FileUploadConfiguration.cs +++ b/src/Umbraco.Core/PropertyEditors/FileUploadConfiguration.cs @@ -3,8 +3,8 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// /// Represents the configuration for the file upload address value editor. /// -public class FileUploadConfiguration : IFileExtensionsConfig +public class FileUploadConfiguration { [ConfigurationField("fileExtensions")] - public List FileExtensions { get; set; } = new(); + public IEnumerable FileExtensions { get; set; } = Enumerable.Empty(); } diff --git a/src/Umbraco.Core/PropertyEditors/IFileExtensionConfig.cs b/src/Umbraco.Core/PropertyEditors/IFileExtensionConfig.cs deleted file mode 100644 index 611954395670..000000000000 --- a/src/Umbraco.Core/PropertyEditors/IFileExtensionConfig.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Umbraco.Cms.Core.PropertyEditors; - -/// -/// Marker interface for any editor configuration that supports defining file extensions -/// -public interface IFileExtensionsConfig -{ - List FileExtensions { get; set; } -} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs index bc28d6160484..8bf735f898b0 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs @@ -156,8 +156,8 @@ private bool IsAllowedInDataTypeConfiguration(string extension, object? dataType { // If FileExtensions is empty and no allowed extensions have been specified, we allow everything. // If there are any extensions specified, we need to check that the uploaded extension is one of them. - return fileUploadConfiguration.FileExtensions.IsCollectionEmpty() || - fileUploadConfiguration.FileExtensions.Any(x => x.Value?.InvariantEquals(extension) ?? false); + return fileUploadConfiguration.FileExtensions.Any() is false || + fileUploadConfiguration.FileExtensions.Contains(extension); } return false; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs index 544a8aafe27d..239d4652bb53 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs @@ -34,7 +34,6 @@ public sealed class RichTextEditorPastedImages private readonly IMediaImportService _mediaImportService; private readonly IImageUrlGenerator _imageUrlGenerator; private readonly IUserService _userService; - private readonly ContentSettings _contentSettings; [Obsolete("Please use the non-obsolete constructor. Will be removed in V16.")] public RichTextEditorPastedImages( @@ -81,7 +80,7 @@ public RichTextEditorPastedImages( IMediaImportService mediaImportService, IImageUrlGenerator imageUrlGenerator, IOptions contentSettings) - : this(umbracoContextAccessor, publishedUrlProvider, temporaryFileService, scopeProvider, mediaImportService, imageUrlGenerator, contentSettings) + : this(umbracoContextAccessor, publishedUrlProvider, temporaryFileService, scopeProvider, mediaImportService, imageUrlGenerator) { } @@ -91,8 +90,7 @@ public RichTextEditorPastedImages( ITemporaryFileService temporaryFileService, IScopeProvider scopeProvider, IMediaImportService mediaImportService, - IImageUrlGenerator imageUrlGenerator, - IOptions contentSettings) + IImageUrlGenerator imageUrlGenerator) { _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); @@ -101,27 +99,12 @@ public RichTextEditorPastedImages( _scopeProvider = scopeProvider; _mediaImportService = mediaImportService; _imageUrlGenerator = imageUrlGenerator; - _contentSettings = contentSettings.Value; // this obviously is not correct. however, we only use IUserService in an obsolete method, // so this is better than having even more obsolete constructors for V16 _userService = StaticServiceProvider.Instance.GetRequiredService(); } - /// - /// Used by the RTE (and grid RTE) for converting inline base64 images to Media items. - /// - /// HTML from the Rich Text Editor property editor. - /// - /// - /// Formatted HTML. - /// Thrown if image extension is not allowed - internal string FindAndPersistEmbeddedImages(string html, Guid mediaParentFolder, Guid userId) - { - // FIXME: the FindAndPersistEmbeddedImages implementation from #14546 must be ported to V14 and added here - return html; - } - [Obsolete($"Please use {nameof(FindAndPersistPastedTempImagesAsync)}. Will be removed in V16.")] public string FindAndPersistPastedTempImages(string html, Guid mediaParentFolder, int userId, IImageUrlGenerator imageUrlGenerator) => FindAndPersistPastedTempImages(html, mediaParentFolder, userId); @@ -179,7 +162,7 @@ public async Task FindAndPersistPastedTempImagesAsync(string html, Guid { using Stream fileStream = temporaryFile.OpenReadStream(); Guid? parentFolderKey = mediaParentFolder == Guid.Empty ? Constants.System.RootKey : mediaParentFolder; - IMedia mediaFile = await _mediaImportService.ImportAsync(temporaryFile.FileName, fileStream, parentFolderKey, Constants.Conventions.MediaTypes.Image, userKey); + IMedia mediaFile = await _mediaImportService.ImportAsync(temporaryFile.FileName, fileStream, parentFolderKey, MediaTypeAlias(temporaryFile.FileName), userKey); udi = mediaFile.GetUdi(); } else @@ -230,4 +213,9 @@ public async Task FindAndPersistPastedTempImagesAsync(string html, Guid return htmlDoc.DocumentNode.OuterHtml; } + + private string MediaTypeAlias(string fileName) + => fileName.InvariantEndsWith(".svg") + ? Constants.Conventions.MediaTypes.VectorGraphicsAlias + : Constants.Conventions.MediaTypes.Image; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index 006c273b563b..85b60fcad62b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -222,10 +222,8 @@ public override IEnumerable GetTags(object? value, object? dataTypeConfigu return null; } - var parseAndSaveBase64Images = _pastedImages.FindAndPersistEmbeddedImages( - richTextEditorValue.Markup, mediaParentId, userKey); var parseAndSavedTempImages = _pastedImages - .FindAndPersistPastedTempImagesAsync(parseAndSaveBase64Images, mediaParentId, userKey) + .FindAndPersistPastedTempImagesAsync(richTextEditorValue.Markup, mediaParentId, userKey) .GetAwaiter() .GetResult(); var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImagesTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImagesTests.cs new file mode 100644 index 000000000000..b965ded4968f --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImagesTests.cs @@ -0,0 +1,176 @@ +using System.Text; +using HtmlAgilityPack; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class RichTextEditorPastedImagesTests : UmbracoIntegrationTest +{ + private static readonly Guid GifFileKey = Guid.Parse("E625C7FA-6CA7-4A01-92CD-FB5C6F89973D"); + + private static readonly Guid SvgFileKey = Guid.Parse("0E3A7DFE-DF09-4C3B-881C-E1B815A4502F"); + + protected override void ConfigureTestServices(IServiceCollection services) + { + // mock out the temporary file service so we don't have to read/write files from/to disk + var temporaryFileServiceMock = new Mock(); + temporaryFileServiceMock + .Setup(t => t.GetAsync(GifFileKey)) + .Returns(Task.FromResult(new TemporaryFileModel + { + AvailableUntil = DateTime.UtcNow.AddDays(1), + FileName = "the-pixel.gif", + Key = GifFileKey, + OpenReadStream = () => new MemoryStream(Convert.FromBase64String("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")) + })); + temporaryFileServiceMock + .Setup(t => t.GetAsync(SvgFileKey)) + .Returns(Task.FromResult(new TemporaryFileModel + { + AvailableUntil = DateTime.UtcNow.AddDays(1), + FileName = "the-vector.svg", + Key = SvgFileKey, + OpenReadStream = () => new MemoryStream(Encoding.UTF8.GetBytes(@"")) + })); + + services.AddUnique(temporaryFileServiceMock.Object); + + // the integration tests do not really play nice with published content, so we need to mock a fair bit in order to generate media URLs + var publishedMediaTypeMock = new Mock(); + publishedMediaTypeMock.SetupGet(c => c.ItemType).Returns(PublishedItemType.Media); + + var publishedMediaMock = new Mock(); + publishedMediaMock.SetupGet(m => m.ContentType).Returns(publishedMediaTypeMock.Object); + + var publishedMediaCacheMock = new Mock(); + publishedMediaCacheMock.Setup(mc => mc.GetById(It.IsAny())).Returns(publishedMediaMock.Object); + + var umbracoContextMock = new Mock(); + umbracoContextMock.SetupGet(c => c.Media).Returns(publishedMediaCacheMock.Object); + var umbracoContext = umbracoContextMock.Object; + + var umbracoContextAccessor = new Mock(); + umbracoContextAccessor.Setup(ca => ca.TryGetUmbracoContext(out umbracoContext)).Returns(true); + + services.AddUnique(umbracoContextAccessor.Object); + + var publishedUrlProviderMock = new Mock(); + publishedUrlProviderMock + .Setup(pu => pu.GetMediaUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("the-media-url"); + + services.AddUnique(publishedUrlProviderMock.Object); + } + + [Test] + public async Task Can_Handle_Temp_Gif_Image() + { + var html = $"

"; + var subject = Services.GetRequiredService(); + + var result = await subject.FindAndPersistPastedTempImagesAsync(html, Guid.Empty, Constants.Security.SuperUserKey); + AssertContainsMedia(result, Constants.Conventions.MediaTypes.Image); + } + + [Test] + public async Task Can_Handle_Temp_Svg_Image() + { + var html = $"

"; + var subject = Services.GetRequiredService(); + + var result = await subject.FindAndPersistPastedTempImagesAsync(html, Guid.Empty, Constants.Security.SuperUserKey); + AssertContainsMedia(result, Constants.Conventions.MediaTypes.VectorGraphicsAlias); + } + + [Test] + public async Task Ignores_Non_Existing_Temp_Image() + { + var key = Guid.NewGuid(); + var html = $"

"; + var subject = Services.GetRequiredService(); + + var result = await subject.FindAndPersistPastedTempImagesAsync(html, Guid.Empty, Constants.Security.SuperUserKey); + Assert.AreEqual(html, result); + } + + [Test] + public async Task Can_Handle_Multiple_Temp_Images() + { + var html = $"

"; + var subject = Services.GetRequiredService(); + + var result = await subject.FindAndPersistPastedTempImagesAsync(html, Guid.Empty, Constants.Security.SuperUserKey); + + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(result); + var imageNodes = htmlDoc.DocumentNode.SelectNodes("//img"); + Assert.AreEqual(2, imageNodes.Count); + + var udis = imageNodes.Select(imageNode => UdiParser.Parse(imageNode.Attributes["data-udi"].Value)).OfType().ToArray(); + Assert.AreEqual(2, udis.Length); + Assert.AreNotEqual(udis.First().Guid, udis.Last().Guid); + + var mediaService = Services.GetRequiredService(); + Assert.Multiple(() => + { + Assert.IsNotNull(mediaService.GetById(udis.First().Guid)); + Assert.IsNotNull(mediaService.GetById(udis.Last().Guid)); + }); + } + + [Test] + public async Task Does_Not_Create_Duplicates_Of_The_Same_Temp_Image() + { + var html = $"

"; + var subject = Services.GetRequiredService(); + + var result = await subject.FindAndPersistPastedTempImagesAsync(html, Guid.Empty, Constants.Security.SuperUserKey); + + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(result); + var imageNodes = htmlDoc.DocumentNode.SelectNodes("//img"); + Assert.AreEqual(2, imageNodes.Count); + + var udis = imageNodes.Select(imageNode => UdiParser.Parse(imageNode.Attributes["data-udi"].Value)).OfType().ToArray(); + Assert.AreEqual(2, udis.Length); + Assert.AreEqual(udis.First().Guid, udis.Last().Guid); + + var mediaService = Services.GetRequiredService(); + Assert.IsNotNull(mediaService.GetById(udis.First().Guid)); + } + + private void AssertContainsMedia(string result, string expectedMediaTypeAlias) + { + Assert.IsFalse(result.Contains("data-tmpimg")); + + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(result); + var imageNode = htmlDoc.DocumentNode.SelectNodes("//img").FirstOrDefault(); + Assert.IsNotNull(imageNode); + + Assert.IsTrue(imageNode.Attributes.Contains("src")); + Assert.AreEqual("the-media-url", imageNode.Attributes["src"].Value); + + Assert.IsTrue(imageNode.Attributes.Contains("data-udi")); + Assert.IsTrue(UdiParser.TryParse(imageNode.Attributes["data-udi"].Value, out GuidUdi udi)); + Assert.AreEqual(Constants.UdiEntityType.Media, udi.EntityType); + + var media = Services.GetRequiredService().GetById(udi.Guid); + Assert.IsNotNull(media); + Assert.AreEqual(expectedMediaTypeAlias, media.ContentType.Alias); + } +}