diff --git a/src/Umbraco.Cms.Api.Management/Factories/MediaUrlFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/MediaUrlFactory.cs index b1e9406289db..d565ecc3d5e3 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/MediaUrlFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/MediaUrlFactory.cs @@ -1,16 +1,10 @@ -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Routing; -using Umbraco.Cms.Api.Management.ViewModels.Content; -using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Api.Management.ViewModels.Media; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors; -using Umbraco.Cms.Core.Routing; -using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Web; using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Factories; @@ -21,7 +15,6 @@ public class MediaUrlFactory : IMediaUrlFactory private readonly MediaUrlGeneratorCollection _mediaUrlGenerators; private readonly IAbsoluteUrlBuilder _absoluteUrlBuilder; - public MediaUrlFactory( IOptions contentSettings, MediaUrlGeneratorCollection mediaUrlGenerators, @@ -39,10 +32,26 @@ public IEnumerable CreateUrls(IMedia media) => .Select(mediaUrl => new MediaUrlInfo { Culture = null, - Url = _absoluteUrlBuilder.ToAbsoluteUrl(mediaUrl).ToString(), + Url = CreateMediaUrl(mediaUrl), }) .ToArray(); + private string CreateMediaUrl(string mediaUrl) + { + var url = _absoluteUrlBuilder.ToAbsoluteUrl(mediaUrl).ToString(); + + if (_contentSettings.EnableMediaRecycleBinProtection is false) + { + return url; + } + + return _contentSettings.EnableMediaRecycleBinProtection + ? AddProtectedSuffixToMediaUrl(url) + : url; + } + + private static string AddProtectedSuffixToMediaUrl(string url) => Path.ChangeExtension(url, Constants.Conventions.Media.TrashedMediaSuffix + Path.GetExtension(url)); + public IEnumerable CreateUrlSets(IEnumerable mediaItems) => mediaItems.Select(media => new MediaUrlInfoResponseModel(media.Key, CreateUrls(media))).ToArray(); } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs index a0cc832bb943..742bf343d106 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs @@ -130,7 +130,19 @@ public void Handle(ContentCopiedNotification notification) public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); /// - public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); + public void Handle(MediaDeletedNotification notification) + { + if (_contentSettings.EnableMediaRecycleBinProtection) + { + RecycleBinMediaProtectionHelper.DeleteContainedFilesWithProtection( + notification.DeletedEntities, + ContainedFilePaths, + _mediaFileManager); + return; + } + + DeleteContainedFiles(notification.DeletedEntities); + } /// public void Handle(MediaSavingNotification notification) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs index 1c36bea04df8..1364df1e9ddf 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs @@ -56,7 +56,19 @@ public FileUploadContentDeletedNotificationHandler( public void Handle(ContentDeletedBlueprintNotification notification) => DeleteContainedFiles(notification.DeletedBlueprints); /// - public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); + public void Handle(MediaDeletedNotification notification) + { + if (_contentSettings.EnableMediaRecycleBinProtection) + { + RecycleBinMediaProtectionHelper.DeleteContainedFilesWithProtection( + notification.DeletedEntities, + ContainedFilePaths, + MediaFileManager); + return; + } + + DeleteContainedFiles(notification.DeletedEntities); + } /// public void Handle(MediaMovedToRecycleBinNotification notification) @@ -115,7 +127,7 @@ private void SuffixContainedFiles(IEnumerable trashedMedia) private void RemoveSuffixFromContainedFiles(IEnumerable restoredMedia) { IEnumerable filePathsToRename = ContainedFilePaths(restoredMedia); - MediaFileManager.RemoveSuffixFromMediaFiles(filePathsToRename, Constants.Conventions.Media.TrashedMediaSuffix); + RecycleBinMediaProtectionHelper.RemoveSuffixFromContainedFiles(filePathsToRename, MediaFileManager); } /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/RecycleBinMediaProtectionHelper.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/RecycleBinMediaProtectionHelper.cs index ca540b9c6153..736eff33ecd5 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/RecycleBinMediaProtectionHelper.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/RecycleBinMediaProtectionHelper.cs @@ -1,5 +1,6 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; @@ -27,4 +28,36 @@ public static void RemoveSuffixFromContainedFiles(IEnumerable filePaths, .Select(x => Path.ChangeExtension(x, Constants.Conventions.Media.TrashedMediaSuffix + Path.GetExtension(x))); mediaFileManager.RemoveSuffixFromMediaFiles(filePathsToRename, Constants.Conventions.Media.TrashedMediaSuffix); } + + /// + /// Deletes all media files, accounting for recycle bin protection (trashed files have .deleted suffix on disk). + /// + /// Deleted media entities. + /// Function to extract file paths from media entities. + /// The media file manager. + public static void DeleteContainedFilesWithProtection( + IEnumerable deletedMedia, + Func, IEnumerable> containedFilePaths, + MediaFileManager mediaFileManager) + { + // Typically all deleted media will have Trashed == true since they come from the recycle bin. + // However, media can be force-deleted programmatically bypassing the recycle bin, in which + // case the files won't have the .deleted suffix on disk. We handle both cases here. + var trashedMedia = deletedMedia.Where(m => m.Trashed).ToList(); + var nonTrashedMedia = deletedMedia.Where(m => !m.Trashed).ToList(); + + // Delete trashed media files (with .deleted suffix on disk). + if (trashedMedia.Count > 0) + { + IEnumerable trashedPaths = containedFilePaths(trashedMedia) + .Select(x => Path.ChangeExtension(x, Constants.Conventions.Media.TrashedMediaSuffix + Path.GetExtension(x))); + mediaFileManager.DeleteMediaFiles(trashedPaths); + } + + // Delete non-trashed media files (original paths). + if (nonTrashedMedia.Count > 0) + { + mediaFileManager.DeleteMediaFiles(containedFilePaths(nonTrashedMedia)); + } + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/MediaUrlFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/MediaUrlFactoryTests.cs new file mode 100644 index 000000000000..a3bdafefbbf7 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/MediaUrlFactoryTests.cs @@ -0,0 +1,246 @@ +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Factories; + +[TestFixture] +public class MediaUrlFactoryTests +{ + private const string BaseUrl = "https://example.com"; + private const string MediaPath = "/media/image.jpg"; + private const string PropertyAlias = Constants.Conventions.Media.File; + + [Test] + public void CreateUrls_WithRecycleBinProtectionDisabled_ReturnsUrlWithoutDeletedSuffix() + { + // Arrange + var factory = CreateFactory(enableMediaRecycleBinProtection: false); + var media = CreateMediaWithUrl(MediaPath); + + // Act + var result = factory.CreateUrls(media).ToList(); + + // Assert + Assert.AreEqual(1, result.Count); + Assert.AreEqual($"{BaseUrl}{MediaPath}", result[0].Url); + Assert.IsNull(result[0].Culture); + } + + [Test] + public void CreateUrls_WithRecycleBinProtectionEnabled_ReturnsUrlWithDeletedSuffix() + { + // Arrange + var factory = CreateFactory(enableMediaRecycleBinProtection: true); + var media = CreateMediaWithUrl(MediaPath); + + // Act + var result = factory.CreateUrls(media).ToList(); + + // Assert + Assert.AreEqual(1, result.Count); + Assert.AreEqual($"{BaseUrl}/media/image{Constants.Conventions.Media.TrashedMediaSuffix}.jpg", result[0].Url); + Assert.IsNull(result[0].Culture); + } + + [Test] + public void CreateUrls_WithNoUrls_ReturnsEmptyCollection() + { + // Arrange + var factory = CreateFactory(enableMediaRecycleBinProtection: false); + var media = CreateMediaWithUrl(null); + + // Act + var result = factory.CreateUrls(media).ToList(); + + // Assert + Assert.IsEmpty(result); + } + + [Test] + public void CreateUrls_WithMultipleUrls_ReturnsAllUrls() + { + // Arrange + const string secondPropertyAlias = "secondImage"; + const string secondMediaPath = "/media/second.png"; + + var contentSettings = CreateContentSettings(false, PropertyAlias, secondPropertyAlias); + var mediaUrlGenerators = CreateMediaUrlGeneratorCollection(); + var absoluteUrlBuilder = CreateAbsoluteUrlBuilder(); + + var factory = new MediaUrlFactory( + Options.Create(contentSettings), + mediaUrlGenerators, + absoluteUrlBuilder); + + var media = CreateMediaWithMultipleUrls( + (PropertyAlias, MediaPath), + (secondPropertyAlias, secondMediaPath)); + + // Act + var result = factory.CreateUrls(media).ToList(); + + // Assert + Assert.AreEqual(2, result.Count); + Assert.AreEqual($"{BaseUrl}{MediaPath}", result[0].Url); + Assert.AreEqual($"{BaseUrl}{secondMediaPath}", result[1].Url); + } + + [Test] + public void CreateUrlSets_WithSingleMedia_ReturnsCorrectResponseModel() + { + // Arrange + var factory = CreateFactory(enableMediaRecycleBinProtection: false); + var mediaKey = Guid.NewGuid(); + var media = CreateMediaWithUrl(MediaPath, mediaKey); + + // Act + var result = factory.CreateUrlSets([media]).ToList(); + + // Assert + Assert.AreEqual(1, result.Count); + Assert.AreEqual(mediaKey, result[0].Id); + Assert.AreEqual(1, result[0].UrlInfos.Count()); + Assert.AreEqual($"{BaseUrl}{MediaPath}", result[0].UrlInfos.First().Url); + } + + [Test] + public void CreateUrlSets_WithMultipleMedia_ReturnsAllMediaUrlInfos() + { + // Arrange + var factory = CreateFactory(enableMediaRecycleBinProtection: false); + var mediaKey1 = Guid.NewGuid(); + var mediaKey2 = Guid.NewGuid(); + const string mediaPath1 = "/media/image1.jpg"; + const string mediaPath2 = "/media/image2.png"; + + var media1 = CreateMediaWithUrl(mediaPath1, mediaKey1); + var media2 = CreateMediaWithUrl(mediaPath2, mediaKey2); + + // Act + var result = factory.CreateUrlSets([media1, media2]).ToList(); + + // Assert + Assert.AreEqual(2, result.Count); + + Assert.AreEqual(mediaKey1, result[0].Id); + Assert.AreEqual($"{BaseUrl}{mediaPath1}", result[0].UrlInfos.First().Url); + + Assert.AreEqual(mediaKey2, result[1].Id); + Assert.AreEqual($"{BaseUrl}{mediaPath2}", result[1].UrlInfos.First().Url); + } + + private static MediaUrlFactory CreateFactory(bool enableMediaRecycleBinProtection) + { + var contentSettings = CreateContentSettings(enableMediaRecycleBinProtection, PropertyAlias); + var mediaUrlGenerators = CreateMediaUrlGeneratorCollection(); + var absoluteUrlBuilder = CreateAbsoluteUrlBuilder(); + + return new MediaUrlFactory( + Options.Create(contentSettings), + mediaUrlGenerators, + absoluteUrlBuilder); + } + + private static ContentSettings CreateContentSettings(bool enableMediaRecycleBinProtection, params string[] propertyAliases) + { + var autoFillProperties = propertyAliases + .Select(alias => new ImagingAutoFillUploadField { Alias = alias }) + .ToHashSet(); + + return new ContentSettings + { + EnableMediaRecycleBinProtection = enableMediaRecycleBinProtection, + Imaging = new ContentImagingSettings + { + AutoFillImageProperties = autoFillProperties, + }, + }; + } + + private static MediaUrlGeneratorCollection CreateMediaUrlGeneratorCollection() + { + var generators = new List + { + new StubMediaUrlGenerator(), + }; + + return new MediaUrlGeneratorCollection(() => generators); + } + + private static IAbsoluteUrlBuilder CreateAbsoluteUrlBuilder() + { + var mock = new Mock(); + mock + .Setup(x => x.ToAbsoluteUrl(It.IsAny())) + .Returns(url => new Uri($"{BaseUrl}{url}")); + + return mock.Object; + } + + private static IMedia CreateMediaWithUrl(string? mediaPath, Guid? key = null) + { + var urlMappings = mediaPath != null + ? [(PropertyAlias, mediaPath)] + : Array.Empty<(string, string)>(); + + return CreateMedia(key ?? Guid.NewGuid(), urlMappings); + } + + private static IMedia CreateMediaWithMultipleUrls(params (string Alias, string Path)[] urlMappings) + => CreateMedia(Guid.NewGuid(), urlMappings); + + private static IMedia CreateMedia(Guid key, (string Alias, string Path)[] urlMappings) + { + var mediaMock = new Mock(); + mediaMock.SetupGet(m => m.Key).Returns(key); + + var propertiesMock = new Mock(); + + foreach (var (alias, path) in urlMappings) + { + IProperty? outProperty = CreatePropertyMock(alias, path); + propertiesMock + .Setup(p => p.TryGetValue(alias, out outProperty)) + .Returns(true); + } + + mediaMock.SetupGet(m => m.Properties).Returns(propertiesMock.Object); + + return mediaMock.Object; + } + + private static IProperty CreatePropertyMock(string alias, string path) + { + var propertyTypeMock = new Mock(); + propertyTypeMock.SetupGet(pt => pt.PropertyEditorAlias).Returns(Constants.PropertyEditors.Aliases.UploadField); + + var propertyMock = new Mock(); + propertyMock.SetupGet(p => p.Alias).Returns(alias); + propertyMock.SetupGet(p => p.PropertyType).Returns(propertyTypeMock.Object); + propertyMock.Setup(p => p.GetValue(It.IsAny(), It.IsAny(), It.IsAny())).Returns(path); + + return propertyMock.Object; + } + + private class StubMediaUrlGenerator : IMediaUrlGenerator + { + public bool TryGetMediaPath(string? propertyEditorAlias, object? value, out string? mediaPath) + { + if (value is string stringValue && !string.IsNullOrEmpty(stringValue)) + { + mediaPath = stringValue; + return true; + } + + mediaPath = null; + return false; + } + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/RecycleBinMediaProtectionHelperTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/RecycleBinMediaProtectionHelperTests.cs new file mode 100644 index 000000000000..a75a1862f7ea --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/RecycleBinMediaProtectionHelperTests.cs @@ -0,0 +1,193 @@ +using System.Data; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors; + +[TestFixture] +public class RecycleBinMediaProtectionHelperTests +{ + [Test] + public void DeleteContainedFilesWithProtection_WithTrashedMedia_DeletesFilesWithSuffix() + { + // Arrange + var deletedFiles = new List(); + var fileSystemMock = new Mock(); + fileSystemMock.Setup(fs => fs.FileExists(It.IsAny())).Returns(true); + fileSystemMock + .Setup(fs => fs.DeleteFile(It.IsAny())) + .Callback(deletedFiles.Add); + + var mediaFileManager = CreateMediaFileManager(fileSystemMock.Object); + + var trashedMedia = new List { CreateMedia(trashed: true) }; + static IEnumerable ContainedFilePaths(IEnumerable _) => ["media/test/image.jpg"]; + + // Act + RecycleBinMediaProtectionHelper.DeleteContainedFilesWithProtection( + trashedMedia, + ContainedFilePaths, + mediaFileManager); + + // Assert + Assert.That(deletedFiles, Has.Count.EqualTo(1)); + Assert.That(deletedFiles[0], Is.EqualTo("media/test/image.deleted.jpg")); + } + + [Test] + public void DeleteContainedFilesWithProtection_WithNonTrashedMedia_DeletesFilesWithoutSuffix() + { + // Arrange + var deletedFiles = new List(); + var fileSystemMock = new Mock(); + fileSystemMock.Setup(fs => fs.FileExists(It.IsAny())).Returns(true); + fileSystemMock + .Setup(fs => fs.DeleteFile(It.IsAny())) + .Callback(deletedFiles.Add); + + var mediaFileManager = CreateMediaFileManager(fileSystemMock.Object); + + var nonTrashedMedia = new List { CreateMedia(trashed: false) }; + static IEnumerable ContainedFilePaths(IEnumerable _) => ["media/test/image.jpg"]; + + // Act + RecycleBinMediaProtectionHelper.DeleteContainedFilesWithProtection( + nonTrashedMedia, + ContainedFilePaths, + mediaFileManager); + + // Assert + Assert.That(deletedFiles, Has.Count.EqualTo(1)); + Assert.That(deletedFiles[0], Is.EqualTo("media/test/image.jpg")); + } + + [Test] + public void DeleteContainedFilesWithProtection_WithTrashedAndNonTrashedMedia_DeletesBothCorrectly() + { + // Arrange + var deletedFiles = new List(); + var fileSystemMock = new Mock(); + fileSystemMock.Setup(fs => fs.FileExists(It.IsAny())).Returns(true); + fileSystemMock + .Setup(fs => fs.DeleteFile(It.IsAny())) + .Callback(deletedFiles.Add); + + var mediaFileManager = CreateMediaFileManager(fileSystemMock.Object); + + var trashedMedia = CreateMedia(trashed: true); + var nonTrashedMedia = CreateMedia(trashed: false); + var media = new List { trashedMedia, nonTrashedMedia }; + + // Return different paths based on the media items passed + static IEnumerable ContainedFilePaths(IEnumerable media) + { + foreach (var m in media) + { + yield return m.Trashed ? "media/trashed/image.jpg" : "media/normal/image.jpg"; + } + } + + // Act + RecycleBinMediaProtectionHelper.DeleteContainedFilesWithProtection( + media, + ContainedFilePaths, + mediaFileManager); + + // Assert + Assert.That(deletedFiles, Has.Count.EqualTo(2)); + Assert.That(deletedFiles, Does.Contain("media/trashed/image.deleted.jpg")); + Assert.That(deletedFiles, Does.Contain("media/normal/image.jpg")); + } + + [Test] + public void DeleteContainedFilesWithProtection_WithEmptyCollection_DoesNotCallDelete() + { + // Arrange + var fileSystemMock = new Mock(); + var mediaFileManager = CreateMediaFileManager(fileSystemMock.Object); + + var emptyMedia = new List(); + static IEnumerable ContainedFilePaths(IEnumerable _) => Array.Empty(); + + // Act + RecycleBinMediaProtectionHelper.DeleteContainedFilesWithProtection( + emptyMedia, + ContainedFilePaths, + mediaFileManager); + + // Assert + fileSystemMock.Verify(fs => fs.DeleteFile(It.IsAny()), Times.Never); + } + + [Test] + public void DeleteContainedFilesWithProtection_WithMultipleFiles_DeletesAllWithCorrectSuffix() + { + // Arrange + var deletedFiles = new List(); + var fileSystemMock = new Mock(); + fileSystemMock.Setup(fs => fs.FileExists(It.IsAny())).Returns(true); + fileSystemMock + .Setup(fs => fs.DeleteFile(It.IsAny())) + .Callback(deletedFiles.Add); + + var mediaFileManager = CreateMediaFileManager(fileSystemMock.Object); + + var trashedMedia = new List { CreateMedia(trashed: true) }; + static IEnumerable ContainedFilePaths(IEnumerable _) => + [ + "media/test/photo.jpg", + "media/test/document.pdf", + "media/test/video.mp4" + ]; + + // Act + RecycleBinMediaProtectionHelper.DeleteContainedFilesWithProtection( + trashedMedia, + ContainedFilePaths, + mediaFileManager); + + // Assert + Assert.That(deletedFiles, Has.Count.EqualTo(3)); + Assert.That(deletedFiles, Does.Contain("media/test/photo.deleted.jpg")); + Assert.That(deletedFiles, Does.Contain("media/test/document.deleted.pdf")); + Assert.That(deletedFiles, Does.Contain("media/test/video.deleted.mp4")); + } + + private static MediaFileManager CreateMediaFileManager(IFileSystem fileSystem) + { + var scopeMock = new Mock(); + var scopeProviderMock = new Mock(); + scopeProviderMock + .Setup(sp => sp.CreateCoreScope( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(scopeMock.Object); + + return new MediaFileManager( + fileSystem, + Mock.Of(), + NullLogger.Instance, + Mock.Of(), + Mock.Of(), + new Lazy(() => scopeProviderMock.Object)); + } + + private static IMedia CreateMedia(bool trashed) + { + var mediaMock = new Mock(); + mediaMock.SetupGet(m => m.Trashed).Returns(trashed); + return mediaMock.Object; + } +}