Media: Add protection to restrict access to media in recycle bin (closes #2931) (#20378)

* Add MoveFile it IFileSystem and implement on file systems.

* Rename media file on move to recycle bin.

* Rename file on restore from recycle bin.

* Add configuration to enabled recycle bin media protection.

* Expose backoffice authentication as cookie for non-backoffice usage.
Protected requests for media in recycle bin.

* Display protected image when viewing image cropper in the backoffice media recycle bin.

* Code tidy and comments.

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Introduced helper class to DRY up repeated code between image cropper and file upload notification handlers.

* Reverted client-side and management API updates.

* Moved update of path to media file in recycle bin with deleted suffix to the server.

* Separate integration tests for add and remove.

* Use interpolated strings.

* Renamed variable.

* Move EnableMediaRecycleBinProtection to ContentSettings.

* Tidied up comments.

* Added TODO for 18.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Andy Butland
2025-11-04 08:39:44 +01:00
committed by GitHub
parent b502e29d51
commit 2b8146f72d
24 changed files with 757 additions and 34 deletions

View File

@@ -1,7 +1,6 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.IO;
using System.Text;
using NUnit.Framework;
using Umbraco.Cms.Core.Hosting;
@@ -69,6 +68,55 @@ internal sealed class FileSystemsTests : UmbracoIntegrationTest
Assert.IsTrue(Directory.Exists(physPath));
}
[Test]
public void Can_Add_Suffix_To_Media_Files()
{
var mediaFileManager = GetRequiredService<MediaFileManager>();
var hostingEnvironment = GetRequiredService<IHostingEnvironment>();
CreateMediaFile(mediaFileManager, hostingEnvironment, out string virtualPath, out string physicalPath);
Assert.IsTrue(File.Exists(physicalPath));
mediaFileManager.SuffixMediaFiles([virtualPath], Cms.Core.Constants.Conventions.Media.TrashedMediaSuffix);
Assert.IsFalse(File.Exists(physicalPath));
var virtualPathWithSuffix = virtualPath.Replace("file.txt", $"file{Cms.Core.Constants.Conventions.Media.TrashedMediaSuffix}.txt");
physicalPath = hostingEnvironment.MapPathWebRoot(Path.Combine("media", virtualPathWithSuffix));
Assert.IsTrue(File.Exists(physicalPath));
}
[Test]
public void Can_Remove_Suffix_From_Media_Files()
{
var mediaFileManager = GetRequiredService<MediaFileManager>();
var hostingEnvironment = GetRequiredService<IHostingEnvironment>();
CreateMediaFile(mediaFileManager, hostingEnvironment, out string virtualPath, out string physicalPath);
mediaFileManager.SuffixMediaFiles([virtualPath], Cms.Core.Constants.Conventions.Media.TrashedMediaSuffix);
Assert.IsFalse(File.Exists(physicalPath));
mediaFileManager.RemoveSuffixFromMediaFiles([virtualPath], Cms.Core.Constants.Conventions.Media.TrashedMediaSuffix);
Assert.IsFalse(File.Exists(physicalPath));
var virtualPathWithSuffix = virtualPath.Replace("file.txt", $"file{Cms.Core.Constants.Conventions.Media.TrashedMediaSuffix}.txt");
physicalPath = hostingEnvironment.MapPathWebRoot(Path.Combine("media", virtualPathWithSuffix));
Assert.IsTrue(File.Exists(physicalPath));
}
private static void CreateMediaFile(
MediaFileManager mediaFileManager,
IHostingEnvironment hostingEnvironment,
out string virtualPath,
out string physicalPath)
{
virtualPath = mediaFileManager.GetMediaPath("file.txt", Guid.NewGuid(), Guid.NewGuid());
physicalPath = hostingEnvironment.MapPathWebRoot(Path.Combine("media", virtualPath));
var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes("test"));
mediaFileManager.FileSystem.AddFile(virtualPath, memoryStream);
Assert.IsTrue(File.Exists(physicalPath));
}
// TODO: don't make sense anymore
/*
[Test]

View File

@@ -38,8 +38,7 @@ internal sealed class ShadowFileSystemTests : UmbracoIntegrationTest
{
TestHelper.DeleteDirectory(hostingEnvironment.MapPathContentRoot("FileSysTests"));
TestHelper.DeleteDirectory(
hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempData.EnsureEndsWith('/') +
"ShadowFs"));
hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempData.EnsureEndsWith('/') + "ShadowFs"));
}
private static string NormPath(string path) => path.Replace('\\', Path.AltDirectorySeparatorChar);
@@ -166,6 +165,49 @@ internal sealed class ShadowFileSystemTests : UmbracoIntegrationTest
Assert.IsTrue(files.Contains("f2.txt"));
}
[Test]
public void ShadowMoveFile()
{
var path = HostingEnvironment.MapPathContentRoot("FileSysTests");
Directory.CreateDirectory(path);
Directory.CreateDirectory(path + "/ShadowTests");
Directory.CreateDirectory(path + "/ShadowSystem");
var fs = new PhysicalFileSystem(IOHelper, HostingEnvironment, Logger, path + "/ShadowTests/", "ignore");
var sfs = new PhysicalFileSystem(IOHelper, HostingEnvironment, Logger, path + "/ShadowSystem/", "ignore");
var ss = new ShadowFileSystem(fs, sfs);
File.WriteAllText(path + "/ShadowTests/f1.txt", "foo");
using (var ms = new MemoryStream(Encoding.UTF8.GetBytes("foo")))
{
ss.AddFile("f1.txt", ms);
}
var files = fs.GetFiles(string.Empty);
Assert.AreEqual(1, files.Count());
Assert.IsTrue(files.Contains("f1.txt"));
files = ss.GetFiles(string.Empty);
Assert.AreEqual(1, files.Count());
Assert.IsTrue(files.Contains("f1.txt"));
var dirs = ss.GetDirectories(string.Empty);
Assert.AreEqual(0, dirs.Count());
ss.MoveFile("f1.txt", "f2.txt");
Assert.IsTrue(File.Exists(path + "/ShadowTests/f1.txt"));
Assert.IsFalse(File.Exists(path + "/ShadowTests/f2.txt"));
Assert.IsTrue(fs.FileExists("f1.txt"));
Assert.IsFalse(fs.FileExists("f2.txt"));
Assert.IsFalse(ss.FileExists("f1.txt"));
Assert.IsTrue(ss.FileExists("f2.txt"));
files = ss.GetFiles(string.Empty);
Assert.AreEqual(1, files.Count());
Assert.IsTrue(files.Contains("f2.txt"));
}
[Test]
public void ShadowDeleteFileInDir()
{