* 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:
@@ -31,6 +31,9 @@ public class ContentSettings
|
||||
internal const bool StaticShowDomainWarnings = true;
|
||||
internal const bool StaticShowUnroutableContentWarnings = true;
|
||||
|
||||
// TODO (V18): Consider enabling this by default and documenting as a behavioural breaking change.
|
||||
private const bool StaticEnableMediaRecycleBinProtection = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the content notification settings.
|
||||
/// </summary>
|
||||
@@ -158,4 +161,16 @@ public class ContentSettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticShowUnroutableContentWarnings)]
|
||||
public bool ShowUnroutableContentWarnings { get; set; } = StaticShowUnroutableContentWarnings;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable or disable the recycle bin protection for media.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When set to true, this will:
|
||||
/// - Rename media moved to the recycle bin to have a .deleted suffice (e.g. image.jpg will be renamed to image.deleted.jpg).
|
||||
/// - On restore, the media file will be renamed back to its original name.
|
||||
/// - A middleware component will be enabled to prevent access to media files in the recycle bin unless the user is authenticated with access to the media section.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticEnableMediaRecycleBinProtection)]
|
||||
public bool EnableMediaRecycleBinProtection { get; set; } = StaticEnableMediaRecycleBinProtection;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -100,6 +100,11 @@ public static partial class Constants
|
||||
/// The default height/width of an image file if the size can't be determined from the metadata
|
||||
/// </summary>
|
||||
public const int DefaultSize = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Suffix added to media files when moved to the recycle bin when recycle bin media protection is enabled.
|
||||
/// </summary>
|
||||
public const string TrashedMediaSuffix = ".deleted";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -73,6 +73,17 @@ public static partial class Constants
|
||||
public const string BackOfficeTokenAuthenticationType = "UmbracoBackOfficeToken";
|
||||
public const string BackOfficeTwoFactorAuthenticationType = "UmbracoTwoFactorCookie";
|
||||
public const string BackOfficeTwoFactorRememberMeAuthenticationType = "UmbracoTwoFactorRememberMeCookie";
|
||||
|
||||
/// <summary>
|
||||
/// Authentication type and scheme used for backoffice users when it is exposed out of the backoffice context via a cookie.
|
||||
/// </summary>
|
||||
public const string BackOfficeExposedAuthenticationType = "UmbracoBackOfficeExposed";
|
||||
|
||||
/// <summary>
|
||||
/// Represents the name of the authentication cookie used to expose the backoffice authentication token outside of the backoffice context.
|
||||
/// </summary>
|
||||
public const string BackOfficeExposedCookieName = "UMB_UCONTEXT_EXPOSED";
|
||||
|
||||
public const string EmptyPasswordPrefix = "___UIDEMPTYPWORD__";
|
||||
|
||||
public const string DefaultMemberTypeAlias = "Member";
|
||||
|
||||
@@ -168,10 +168,42 @@ public interface IFileSystem
|
||||
/// <param name="copy">A value indicating whether to move (default) or copy.</param>
|
||||
void AddFile(string path, string physicalPath, bool overrideIfExists = true, bool copy = false);
|
||||
|
||||
/// <summary>
|
||||
/// Moves a file from the specified source path to the specified target path.
|
||||
/// </summary>
|
||||
/// <param name="source">The path of the file or directory to move.</param>
|
||||
/// <param name="target">The destination path where the file or directory will be moved.</param>
|
||||
/// <param name="overrideIfExists">A value indicating what to do if the file already exists.</param>
|
||||
void MoveFile(string source, string target, bool overrideIfExists = true)
|
||||
{
|
||||
// Provide a default implementation for implementations of IFileSystem that do not implement this method.
|
||||
if (FileExists(source) is false)
|
||||
{
|
||||
throw new FileNotFoundException($"File at path '{source}' could not be found.");
|
||||
}
|
||||
|
||||
if (FileExists(target))
|
||||
{
|
||||
if (overrideIfExists)
|
||||
{
|
||||
DeleteFile(target);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new IOException($"A file at path '{target}' already exists.");
|
||||
}
|
||||
}
|
||||
|
||||
using (Stream sourceStream = OpenFile(source))
|
||||
{
|
||||
AddFile(target, sourceStream);
|
||||
}
|
||||
|
||||
DeleteFile(source);
|
||||
}
|
||||
|
||||
// TODO: implement these
|
||||
//
|
||||
// void CreateDirectory(string path);
|
||||
//
|
||||
//// move or rename, directory or file
|
||||
// void Move(string source, string target);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
@@ -40,7 +38,58 @@ public sealed class MediaFileManager
|
||||
/// Delete media files.
|
||||
/// </summary>
|
||||
/// <param name="files">Files to delete (filesystem-relative paths).</param>
|
||||
public void DeleteMediaFiles(IEnumerable<string> files)
|
||||
public void DeleteMediaFiles(IEnumerable<string> files) =>
|
||||
PerformMediaFileOperation(
|
||||
files,
|
||||
file =>
|
||||
{
|
||||
FileSystem.DeleteFile(file);
|
||||
|
||||
var directory = _mediaPathScheme.GetDeleteDirectory(this, file);
|
||||
if (!directory.IsNullOrWhiteSpace())
|
||||
{
|
||||
FileSystem.DeleteDirectory(directory!, true);
|
||||
}
|
||||
},
|
||||
"Failed to delete media file '{File}'.");
|
||||
|
||||
/// <summary>
|
||||
/// Adds a suffix to media files.
|
||||
/// </summary>
|
||||
/// <param name="files">Files to append a suffix to.</param>
|
||||
/// <param name="suffix">The suffix to append.</param>
|
||||
/// <remarks>
|
||||
/// The suffix will be added prior to the file extension, e.g. "image.jpg" with suffix ".deleted" will become "image.deleted.jpg".
|
||||
/// </remarks>
|
||||
public void SuffixMediaFiles(IEnumerable<string> files, string suffix)
|
||||
=> PerformMediaFileOperation(
|
||||
files,
|
||||
file =>
|
||||
{
|
||||
var suffixedFile = Path.ChangeExtension(file, suffix + Path.GetExtension(file));
|
||||
FileSystem.MoveFile(file, suffixedFile);
|
||||
},
|
||||
"Failed to rename media file '{File}'.");
|
||||
|
||||
/// <summary>
|
||||
/// Removes a suffix from media files.
|
||||
/// </summary>
|
||||
/// <param name="files">Files to remove a suffix from.</param>
|
||||
/// <param name="suffix">The suffix to remove.</param>
|
||||
/// <remarks>
|
||||
/// The suffix will be removed prior to the file extension, e.g. "image.deleted.jpg" with suffix ".deleted" will become "image.jpg".
|
||||
/// </remarks>
|
||||
public void RemoveSuffixFromMediaFiles(IEnumerable<string> files, string suffix)
|
||||
=> PerformMediaFileOperation(
|
||||
files,
|
||||
file =>
|
||||
{
|
||||
var fileWithSuffixRemoved = file.Replace(suffix + Path.GetExtension(file), Path.GetExtension(file));
|
||||
FileSystem.MoveFile(file, fileWithSuffixRemoved);
|
||||
},
|
||||
"Failed to rename media file '{File}'.");
|
||||
|
||||
private void PerformMediaFileOperation(IEnumerable<string> files, Action<string> fileOperation, string errorMessage)
|
||||
{
|
||||
files = files.Distinct();
|
||||
|
||||
@@ -61,17 +110,11 @@ public sealed class MediaFileManager
|
||||
return;
|
||||
}
|
||||
|
||||
FileSystem.DeleteFile(file);
|
||||
|
||||
var directory = _mediaPathScheme.GetDeleteDirectory(this, file);
|
||||
if (!directory.IsNullOrWhiteSpace())
|
||||
{
|
||||
FileSystem.DeleteDirectory(directory!, true);
|
||||
}
|
||||
fileOperation(file);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to delete media file '{File}'.", file);
|
||||
_logger.LogError(e, errorMessage, file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -428,13 +428,9 @@ namespace Umbraco.Cms.Core.IO
|
||||
WithRetry(() => File.Delete(fullPath));
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
if (directory == null)
|
||||
{
|
||||
throw new InvalidOperationException("Could not get directory.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(directory); // ensure it exists
|
||||
// Ensure the directory exists.
|
||||
var directory = Path.GetDirectoryName(fullPath) ?? throw new InvalidOperationException("Could not get directory.");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
if (copy)
|
||||
{
|
||||
@@ -446,6 +442,35 @@ namespace Umbraco.Cms.Core.IO
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void MoveFile(string source, string target, bool overrideIfExists = true)
|
||||
{
|
||||
var fullSourcePath = GetFullPath(source);
|
||||
if (File.Exists(fullSourcePath) is false)
|
||||
{
|
||||
throw new FileNotFoundException($"File at path '{source}' could not be found.");
|
||||
}
|
||||
|
||||
var fullTargetPath = GetFullPath(target);
|
||||
if (File.Exists(fullTargetPath))
|
||||
{
|
||||
if (overrideIfExists)
|
||||
{
|
||||
DeleteFile(target);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new IOException($"A file at path '{target}' already exists.");
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the directory exists.
|
||||
var directory = Path.GetDirectoryName(fullTargetPath) ?? throw new InvalidOperationException("Could not get directory.");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
WithRetry(() => File.Move(fullSourcePath, fullTargetPath));
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
protected virtual void EnsureDirectory(string path)
|
||||
|
||||
@@ -91,7 +91,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
var normPath = NormPath(path);
|
||||
if (Nodes.TryGetValue(normPath, out ShadowNode? sf) && sf.IsExist && (sf.IsDir || overrideIfExists == false))
|
||||
{
|
||||
throw new InvalidOperationException(string.Format("A file at path '{0}' already exists", path));
|
||||
throw new InvalidOperationException($"A file at path '{path}' already exists");
|
||||
}
|
||||
|
||||
var parts = normPath.Split(Constants.CharArrays.ForwardSlash);
|
||||
@@ -167,6 +167,60 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
Nodes[NormPath(path)] = new ShadowNode(true, false);
|
||||
}
|
||||
|
||||
public void MoveFile(string source, string target, bool overrideIfExists = true)
|
||||
{
|
||||
var normSource = NormPath(source);
|
||||
var normTarget = NormPath(target);
|
||||
if (Nodes.TryGetValue(normSource, out ShadowNode? sf) == false || sf.IsDir || sf.IsDelete)
|
||||
{
|
||||
if (Inner.FileExists(source) == false)
|
||||
{
|
||||
throw new FileNotFoundException("Source file does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
if (Nodes.TryGetValue(normTarget, out ShadowNode? tf) && tf.IsExist && (tf.IsDir || overrideIfExists == false))
|
||||
{
|
||||
throw new IOException($"A file at path '{target}' already exists");
|
||||
}
|
||||
|
||||
var parts = normTarget.Split(Constants.CharArrays.ForwardSlash);
|
||||
for (var i = 0; i < parts.Length - 1; i++)
|
||||
{
|
||||
var dirPath = string.Join("/", parts.Take(i + 1));
|
||||
if (Nodes.TryGetValue(dirPath, out ShadowNode? sd))
|
||||
{
|
||||
if (sd.IsFile)
|
||||
{
|
||||
throw new InvalidOperationException("Invalid path.");
|
||||
}
|
||||
|
||||
if (sd.IsDelete)
|
||||
{
|
||||
Nodes[dirPath] = new ShadowNode(false, true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Inner.DirectoryExists(dirPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Inner.FileExists(dirPath))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid path.");
|
||||
}
|
||||
|
||||
Nodes[dirPath] = new ShadowNode(false, true);
|
||||
}
|
||||
}
|
||||
|
||||
_sfs.MoveFile(normSource, normTarget, overrideIfExists);
|
||||
Nodes[normSource] = new ShadowNode(true, false);
|
||||
Nodes[normTarget] = new ShadowNode(false, false);
|
||||
}
|
||||
|
||||
public bool FileExists(string path)
|
||||
{
|
||||
if (Nodes.TryGetValue(NormPath(path), out ShadowNode? sf))
|
||||
@@ -241,7 +295,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
var normPath = NormPath(path);
|
||||
if (Nodes.TryGetValue(normPath, out ShadowNode? sf) && sf.IsExist && (sf.IsDir || overrideIfExists == false))
|
||||
{
|
||||
throw new InvalidOperationException(string.Format("A file at path '{0}' already exists", path));
|
||||
throw new InvalidOperationException($"A file at path '{path}' already exists");
|
||||
}
|
||||
|
||||
var parts = normPath.Split(Constants.CharArrays.ForwardSlash);
|
||||
|
||||
@@ -81,6 +81,8 @@ internal sealed class ShadowWrapper : IFileSystem, IFileProviderFactory
|
||||
|
||||
public void DeleteFile(string path) => FileSystem.DeleteFile(path);
|
||||
|
||||
public void MoveFile(string source, string target) => FileSystem.MoveFile(source, target);
|
||||
|
||||
public bool FileExists(string path) => FileSystem.FileExists(path);
|
||||
|
||||
public string GetRelativePath(string fullPathOrUrl) => FileSystem.GetRelativePath(fullPathOrUrl);
|
||||
|
||||
@@ -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 IMediaService when the MoveToRecycleBin method is called in the API, after the media object has been moved to the RecycleBin.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user