* 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>
283 lines
9.9 KiB
C#
283 lines
9.9 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Umbraco.Cms.Core.Models;
|
|
using Umbraco.Cms.Core.PropertyEditors;
|
|
using Umbraco.Cms.Core.Strings;
|
|
using Umbraco.Extensions;
|
|
|
|
namespace Umbraco.Cms.Core.IO;
|
|
|
|
public sealed class MediaFileManager
|
|
{
|
|
private readonly ILogger<MediaFileManager> _logger;
|
|
private readonly IMediaPathScheme _mediaPathScheme;
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private readonly IShortStringHelper _shortStringHelper;
|
|
private MediaUrlGeneratorCollection? _mediaUrlGenerators;
|
|
|
|
public MediaFileManager(
|
|
IFileSystem fileSystem,
|
|
IMediaPathScheme mediaPathScheme,
|
|
ILogger<MediaFileManager> logger,
|
|
IShortStringHelper shortStringHelper,
|
|
IServiceProvider serviceProvider)
|
|
{
|
|
_mediaPathScheme = mediaPathScheme;
|
|
_logger = logger;
|
|
_shortStringHelper = shortStringHelper;
|
|
_serviceProvider = serviceProvider;
|
|
FileSystem = fileSystem;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the media filesystem.
|
|
/// </summary>
|
|
public IFileSystem FileSystem { get; }
|
|
|
|
/// <summary>
|
|
/// Delete media files.
|
|
/// </summary>
|
|
/// <param name="files">Files to delete (filesystem-relative paths).</param>
|
|
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();
|
|
|
|
// kinda try to keep things under control
|
|
var options = new ParallelOptions { MaxDegreeOfParallelism = 20 };
|
|
|
|
Parallel.ForEach(files, options, file =>
|
|
{
|
|
try
|
|
{
|
|
if (file.IsNullOrWhiteSpace())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (FileSystem.FileExists(file) == false)
|
|
{
|
|
return;
|
|
}
|
|
|
|
fileOperation(file);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogError(e, errorMessage, file);
|
|
}
|
|
});
|
|
}
|
|
|
|
#region Media Path
|
|
|
|
/// <summary>
|
|
/// Gets the file path of a media file.
|
|
/// </summary>
|
|
/// <param name="filename">The file name.</param>
|
|
/// <param name="cuid">The unique identifier of the content/media owning the file.</param>
|
|
/// <param name="puid">The unique identifier of the property type owning the file.</param>
|
|
/// <returns>The filesystem-relative path to the media file.</returns>
|
|
/// <remarks>With the old media path scheme, this CREATES a new media path each time it is invoked.</remarks>
|
|
public string GetMediaPath(string? filename, Guid cuid, Guid puid)
|
|
{
|
|
filename = Path.GetFileName(filename);
|
|
if (filename == null)
|
|
{
|
|
throw new ArgumentException("Cannot become a safe filename.", nameof(filename));
|
|
}
|
|
|
|
filename = _shortStringHelper.CleanStringForSafeFileName(filename.ToLowerInvariant());
|
|
|
|
return _mediaPathScheme.GetFilePath(this, cuid, puid, filename);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Associated Media Files
|
|
|
|
/// <summary>
|
|
/// Returns a stream (file) for a content item (or a null stream if there is no file).
|
|
/// </summary>
|
|
/// <param name="content"></param>
|
|
/// <param name="mediaFilePath">The file path if a file was found</param>
|
|
/// <param name="propertyTypeAlias"></param>
|
|
/// <param name="culture"></param>
|
|
/// <param name="segment"></param>
|
|
/// <returns></returns>
|
|
public Stream GetFile(
|
|
IContentBase content,
|
|
out string? mediaFilePath,
|
|
string propertyTypeAlias = Constants.Conventions.Media.File,
|
|
string? culture = null,
|
|
string? segment = null)
|
|
{
|
|
// TODO: If collections were lazy we could just inject them
|
|
if (_mediaUrlGenerators == null)
|
|
{
|
|
_mediaUrlGenerators = _serviceProvider.GetRequiredService<MediaUrlGeneratorCollection>();
|
|
}
|
|
|
|
if (!content.TryGetMediaPath(propertyTypeAlias, _mediaUrlGenerators!, out mediaFilePath, culture, segment))
|
|
{
|
|
return Stream.Null;
|
|
}
|
|
|
|
return FileSystem.OpenFile(mediaFilePath!);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores a media file associated to a property of a content item.
|
|
/// </summary>
|
|
/// <param name="content">The content item owning the media file.</param>
|
|
/// <param name="propertyType">The property type owning the media file.</param>
|
|
/// <param name="filename">The media file name.</param>
|
|
/// <param name="filestream">A stream containing the media bytes.</param>
|
|
/// <param name="oldpath">An optional filesystem-relative filepath to the previous media file.</param>
|
|
/// <returns>The filesystem-relative filepath to the media file.</returns>
|
|
/// <remarks>
|
|
/// <para>The file is considered "owned" by the content/propertyType.</para>
|
|
/// <para>
|
|
/// If an <paramref name="oldpath" /> is provided then that file (and associated thumbnails if any) is deleted
|
|
/// before the new file is saved, and depending on the media path scheme, the folder may be reused for the new
|
|
/// file.
|
|
/// </para>
|
|
/// </remarks>
|
|
public string StoreFile(IContentBase content, IPropertyType? propertyType, string filename, Stream filestream, string? oldpath)
|
|
{
|
|
if (content == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(content));
|
|
}
|
|
|
|
if (propertyType == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(propertyType));
|
|
}
|
|
|
|
if (filename == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(filename));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(filename))
|
|
{
|
|
throw new ArgumentException(
|
|
"Value can't be empty or consist only of white-space characters.",
|
|
nameof(filename));
|
|
}
|
|
|
|
if (filestream == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(filestream));
|
|
}
|
|
|
|
// clear the old file, if any
|
|
if (string.IsNullOrWhiteSpace(oldpath) == false)
|
|
{
|
|
FileSystem.DeleteFile(oldpath);
|
|
}
|
|
|
|
// get the filepath, store the data
|
|
var filepath = GetMediaPath(filename, content.Key, propertyType.Key);
|
|
FileSystem.AddFile(filepath, filestream);
|
|
return filepath;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies a media file as a new media file, associated to a property of a content item.
|
|
/// </summary>
|
|
/// <param name="content">The content item owning the copy of the media file.</param>
|
|
/// <param name="propertyType">The property type owning the copy of the media file.</param>
|
|
/// <param name="sourcepath">The filesystem-relative path to the source media file.</param>
|
|
/// <returns>The filesystem-relative path to the copy of the media file.</returns>
|
|
public string? CopyFile(IContentBase content, IPropertyType propertyType, string sourcepath)
|
|
{
|
|
if (content == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(content));
|
|
}
|
|
|
|
if (propertyType == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(propertyType));
|
|
}
|
|
|
|
if (sourcepath == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(sourcepath));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(sourcepath))
|
|
{
|
|
throw new ArgumentException(
|
|
"Value can't be empty or consist only of white-space characters.",
|
|
nameof(sourcepath));
|
|
}
|
|
|
|
// ensure we have a file to copy
|
|
if (FileSystem.FileExists(sourcepath) == false)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// get the filepath
|
|
var filename = Path.GetFileName(sourcepath);
|
|
var filepath = GetMediaPath(filename, content.Key, propertyType.Key);
|
|
FileSystem.CopyFile(sourcepath, filepath);
|
|
return filepath;
|
|
}
|
|
|
|
#endregion
|
|
}
|