* 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:
@@ -0,0 +1,84 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenIddict.Server;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Provides OpenIddict server event handlers to expose the backoffice authentication token via a custom authentication scheme.
|
||||
/// </summary>
|
||||
public class ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.GenerateTokenContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ApplyRevocationResponseContext>
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly string[] _claimTypes;
|
||||
private readonly TimeSpan _timeOut;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler"/> class.
|
||||
/// </summary>
|
||||
public ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<GlobalSettings> globalSettings,
|
||||
IOptions<BackOfficeIdentityOptions> backOfficeIdentityOptions)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_timeOut = globalSettings.Value.TimeOut;
|
||||
|
||||
// These are the type identifiers for the claims required by the principal
|
||||
// for the custom authentication scheme.
|
||||
// We make available the ID, user name and allowed applications (sections) claims.
|
||||
_claimTypes =
|
||||
[
|
||||
backOfficeIdentityOptions.Value.ClaimsIdentity.UserIdClaimType,
|
||||
backOfficeIdentityOptions.Value.ClaimsIdentity.UserNameClaimType,
|
||||
Core.Constants.Security.AllowedApplicationsClaimType,
|
||||
];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Event handler for when access tokens are generated (created or refreshed).
|
||||
/// </remarks>
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.GenerateTokenContext context)
|
||||
{
|
||||
// Only proceed if this is a back-office sign-in.
|
||||
if (context.Principal.Identity?.AuthenticationType != Core.Constants.Security.BackOfficeAuthenticationType)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new principal with the claims from the authenticated principal.
|
||||
var principal = new ClaimsPrincipal(
|
||||
new ClaimsIdentity(
|
||||
context.Principal.Claims.Where(claim => _claimTypes.Contains(claim.Type)),
|
||||
Core.Constants.Security.BackOfficeExposedAuthenticationType));
|
||||
|
||||
// Sign-in the new principal for the custom authentication scheme.
|
||||
await _httpContextAccessor
|
||||
.GetRequiredHttpContext()
|
||||
.SignInAsync(Core.Constants.Security.BackOfficeExposedAuthenticationType, principal, GetAuthenticationProperties());
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Event handler for when access tokens are revoked.
|
||||
/// </remarks>
|
||||
public async ValueTask HandleAsync(OpenIddictServerEvents.ApplyRevocationResponseContext context)
|
||||
=> await _httpContextAccessor
|
||||
.GetRequiredHttpContext()
|
||||
.SignOutAsync(Core.Constants.Security.BackOfficeExposedAuthenticationType, GetAuthenticationProperties());
|
||||
|
||||
private AuthenticationProperties GetAuthenticationProperties()
|
||||
=> new()
|
||||
{
|
||||
IsPersistent = true,
|
||||
IssuedUtc = DateTimeOffset.UtcNow,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.Add(_timeOut)
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenIddict.Server;
|
||||
using Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.Configuration;
|
||||
using Umbraco.Cms.Api.Management.Handlers;
|
||||
@@ -50,6 +52,7 @@ public static class BackOfficeAuthBuilderExtensions
|
||||
{
|
||||
builder.Services
|
||||
.AddAuthentication()
|
||||
|
||||
// Add our custom schemes which are cookie handlers
|
||||
.AddCookie(Constants.Security.BackOfficeAuthenticationType)
|
||||
.AddCookie(Constants.Security.BackOfficeExternalAuthenticationType, o =>
|
||||
@@ -58,6 +61,15 @@ public static class BackOfficeAuthBuilderExtensions
|
||||
o.ExpireTimeSpan = TimeSpan.FromMinutes(5);
|
||||
})
|
||||
|
||||
// Add a cookie scheme that can be used for authenticating backoffice users outside the scope of the backoffice.
|
||||
.AddCookie(Constants.Security.BackOfficeExposedAuthenticationType, options =>
|
||||
{
|
||||
options.Cookie.Name = Constants.Security.BackOfficeExposedCookieName;
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
options.SlidingExpiration = true;
|
||||
})
|
||||
|
||||
// Although we don't natively support this, we add it anyways so that if end-users implement the required logic
|
||||
// they don't have to worry about manually adding this scheme or modifying the sign in manager
|
||||
.AddCookie(Constants.Security.BackOfficeTwoFactorAuthenticationType, options =>
|
||||
@@ -71,6 +83,22 @@ public static class BackOfficeAuthBuilderExtensions
|
||||
o.ExpireTimeSpan = TimeSpan.FromMinutes(5);
|
||||
});
|
||||
|
||||
// Add OpnIddict server event handler to refresh the cookie that exposes the backoffice authentication outside the scope of the backoffice.
|
||||
builder.Services.AddSingleton<ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler>();
|
||||
builder.Services.Configure<OpenIddictServerOptions>(options =>
|
||||
{
|
||||
options.Handlers.Add(
|
||||
OpenIddictServerHandlerDescriptor
|
||||
.CreateBuilder<OpenIddictServerEvents.GenerateTokenContext>()
|
||||
.UseSingletonHandler<ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler>()
|
||||
.Build());
|
||||
options.Handlers.Add(
|
||||
OpenIddictServerHandlerDescriptor
|
||||
.CreateBuilder<OpenIddictServerEvents.ApplyRevocationResponseContext>()
|
||||
.UseSingletonHandler<ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler>()
|
||||
.Build());
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<BackOfficeSecurityStampValidator>();
|
||||
builder.Services.ConfigureOptions<ConfigureBackOfficeCookieOptions>();
|
||||
builder.Services.ConfigureOptions<ConfigureBackOfficeSecurityStampValidatorOptions>();
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Api.Management.Mapping.Content;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Media;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Media.Collection;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.MediaType;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Mapping;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Mapping.Media;
|
||||
@@ -15,13 +18,32 @@ namespace Umbraco.Cms.Api.Management.Mapping.Media;
|
||||
public class MediaMapDefinition : ContentMapDefinition<IMedia, MediaValueResponseModel, MediaVariantResponseModel>, IMapDefinition
|
||||
{
|
||||
private readonly CommonMapper _commonMapper;
|
||||
private ContentSettings _contentSettings;
|
||||
|
||||
public MediaMapDefinition(
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
CommonMapper commonMapper,
|
||||
IDataValueEditorFactory dataValueEditorFactory)
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IOptionsMonitor<ContentSettings> contentSettings)
|
||||
: base(propertyEditorCollection, dataValueEditorFactory)
|
||||
=> _commonMapper = commonMapper;
|
||||
{
|
||||
_commonMapper = commonMapper;
|
||||
_contentSettings = contentSettings.CurrentValue;
|
||||
contentSettings.OnChange(x => _contentSettings = x);
|
||||
}
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 18.")]
|
||||
public MediaMapDefinition(
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
CommonMapper commonMapper,
|
||||
IDataValueEditorFactory dataValueEditorFactory)
|
||||
: this(
|
||||
propertyEditorCollection,
|
||||
commonMapper,
|
||||
dataValueEditorFactory,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IOptionsMonitor<ContentSettings>>())
|
||||
{
|
||||
}
|
||||
|
||||
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 18.")]
|
||||
public MediaMapDefinition(
|
||||
@@ -48,6 +70,39 @@ public class MediaMapDefinition : ContentMapDefinition<IMedia, MediaValueRespons
|
||||
target.Values = MapValueViewModels(source.Properties);
|
||||
target.Variants = MapVariantViewModels(source);
|
||||
target.IsTrashed = source.Trashed;
|
||||
|
||||
// If protection for media files in the recycle bin is enabled, and the media item is trashed, amend the value of the file path
|
||||
// to have the `.deleted` suffix that will have been added to the persisted file.
|
||||
if (target.IsTrashed && _contentSettings.EnableMediaRecycleBinProtection)
|
||||
{
|
||||
foreach (MediaValueResponseModel valueModel in target.Values
|
||||
.Where(x => x.EditorAlias.Equals(Core.Constants.PropertyEditors.Aliases.ImageCropper)))
|
||||
{
|
||||
if (valueModel.Value is not null &&
|
||||
valueModel.Value is ImageCropperValue imageCropperValue &&
|
||||
string.IsNullOrWhiteSpace(imageCropperValue.Src) is false)
|
||||
{
|
||||
valueModel.Value = new ImageCropperValue
|
||||
{
|
||||
Crops = imageCropperValue.Crops,
|
||||
FocalPoint = imageCropperValue.FocalPoint,
|
||||
TemporaryFileId = imageCropperValue.TemporaryFileId,
|
||||
Src = SuffixMediaPath(imageCropperValue.Src, Core.Constants.Conventions.Media.TrashedMediaSuffix),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string SuffixMediaPath(string filePath, string suffix)
|
||||
{
|
||||
int lastDotIndex = filePath.LastIndexOf('.');
|
||||
if (lastDotIndex == -1)
|
||||
{
|
||||
return filePath + suffix;
|
||||
}
|
||||
|
||||
return filePath[..lastDotIndex] + suffix + filePath[lastDotIndex..];
|
||||
}
|
||||
|
||||
// Umbraco.Code.MapAll -Flags
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -370,12 +370,16 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddNotificationHandler<ContentDeletedNotification, FileUploadContentDeletedNotificationHandler>()
|
||||
.AddNotificationHandler<ContentDeletedBlueprintNotification, FileUploadContentDeletedNotificationHandler>()
|
||||
.AddNotificationHandler<MediaDeletedNotification, FileUploadContentDeletedNotificationHandler>()
|
||||
.AddNotificationHandler<MediaMovedToRecycleBinNotification, FileUploadContentDeletedNotificationHandler>()
|
||||
.AddNotificationHandler<MediaMovedNotification, FileUploadContentDeletedNotificationHandler>()
|
||||
.AddNotificationHandler<MemberDeletedNotification, FileUploadContentDeletedNotificationHandler>()
|
||||
.AddNotificationHandler<MediaSavingNotification, FileUploadMediaSavingNotificationHandler>()
|
||||
.AddNotificationHandler<ContentCopiedNotification, ImageCropperPropertyEditor>()
|
||||
.AddNotificationHandler<ContentDeletedNotification, ImageCropperPropertyEditor>()
|
||||
.AddNotificationHandler<MediaDeletedNotification, ImageCropperPropertyEditor>()
|
||||
.AddNotificationHandler<MediaSavingNotification, ImageCropperPropertyEditor>()
|
||||
.AddNotificationHandler<MediaMovedToRecycleBinNotification, ImageCropperPropertyEditor>()
|
||||
.AddNotificationHandler<MediaMovedNotification, ImageCropperPropertyEditor>()
|
||||
.AddNotificationHandler<MemberDeletedNotification, ImageCropperPropertyEditor>()
|
||||
.AddNotificationHandler<ContentTypeCacheRefresherNotification, ConstructorCacheClearNotificationHandler>()
|
||||
.AddNotificationHandler<DataTypeCacheRefresherNotification, ConstructorCacheClearNotificationHandler>();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Media;
|
||||
@@ -12,6 +14,7 @@ using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
@@ -24,8 +27,12 @@ namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
ValueType = ValueTypes.Json,
|
||||
ValueEditorIsReusable = true)]
|
||||
public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator,
|
||||
INotificationHandler<ContentCopiedNotification>, INotificationHandler<ContentDeletedNotification>,
|
||||
INotificationHandler<MediaDeletedNotification>, INotificationHandler<MediaSavingNotification>,
|
||||
INotificationHandler<ContentCopiedNotification>,
|
||||
INotificationHandler<ContentDeletedNotification>,
|
||||
INotificationHandler<MediaDeletedNotification>,
|
||||
INotificationHandler<MediaSavingNotification>,
|
||||
INotificationHandler<MediaMovedToRecycleBinNotification>,
|
||||
INotificationHandler<MediaMovedNotification>,
|
||||
INotificationHandler<MemberDeletedNotification>
|
||||
{
|
||||
private readonly UploadAutoFillProperties _autoFillProperties;
|
||||
@@ -60,6 +67,7 @@ public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator,
|
||||
_logger = loggerFactory.CreateLogger<ImageCropperPropertyEditor>();
|
||||
|
||||
contentSettings.OnChange(x => _contentSettings = x);
|
||||
|
||||
SupportsReadOnly = true;
|
||||
}
|
||||
|
||||
@@ -119,10 +127,13 @@ public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator,
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MediaSavingNotification notification)
|
||||
{
|
||||
foreach (IMedia entity in notification.SavedEntities)
|
||||
@@ -131,6 +142,34 @@ public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator,
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MediaMovedToRecycleBinNotification notification)
|
||||
{
|
||||
if (_contentSettings.EnableMediaRecycleBinProtection is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SuffixContainedFiles(
|
||||
notification.MoveInfoCollection
|
||||
.Select(x => x.Entity));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MediaMovedNotification notification)
|
||||
{
|
||||
if (_contentSettings.EnableMediaRecycleBinProtection is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveSuffixFromContainedFiles(
|
||||
notification.MoveInfoCollection
|
||||
.Where(x => x.OriginalPath.StartsWith($"{Constants.System.RootString},{Constants.System.RecycleBinMediaString}"))
|
||||
.Select(x => x.Entity));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
|
||||
|
||||
/// <summary>
|
||||
@@ -233,12 +272,36 @@ public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator,
|
||||
return relative ? _mediaFileManager.FileSystem.GetRelativePath(source) : source;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all file upload property files contained within a collection of content entities.
|
||||
/// </summary>
|
||||
/// <param name="deletedEntities">Delete media entities.</param>
|
||||
private void DeleteContainedFiles(IEnumerable<IContentBase> deletedEntities)
|
||||
{
|
||||
IEnumerable<string> filePathsToDelete = ContainedFilePaths(deletedEntities);
|
||||
_mediaFileManager.DeleteMediaFiles(filePathsToDelete);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames all file upload property files contained within a collection of media entities that have been moved to the recycle bin.
|
||||
/// </summary>
|
||||
/// <param name="trashedMedia">Media entities that have been moved to the recycle bin.</param>
|
||||
private void SuffixContainedFiles(IEnumerable<IMedia> trashedMedia)
|
||||
{
|
||||
IEnumerable<string> filePathsToRename = ContainedFilePaths(trashedMedia);
|
||||
RecycleBinMediaProtectionHelper.SuffixContainedFiles(filePathsToRename, _mediaFileManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames all file upload property files contained within a collection of media entities that have been restore from the recycle bin.
|
||||
/// </summary>
|
||||
/// <param name="restoredMedia">Media entities that have been restored from the recycle bin.</param>
|
||||
private void RemoveSuffixFromContainedFiles(IEnumerable<IMedia> restoredMedia)
|
||||
{
|
||||
IEnumerable<string> filePathsToRename = ContainedFilePaths(restoredMedia);
|
||||
RecycleBinMediaProtectionHelper.RemoveSuffixFromContainedFiles(filePathsToRename, _mediaFileManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-fill properties (or clear).
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
@@ -14,16 +16,20 @@ using Umbraco.Cms.Infrastructure.Extensions;
|
||||
namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides base class for notification handler that processes file uploads when a content entity is deleted, removing associated files.
|
||||
/// Provides base class for notification handler that processes file uploads when a content entity is deleted or media
|
||||
/// operations are carried out, processing the associated files.
|
||||
/// </summary>
|
||||
internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadNotificationHandlerBase,
|
||||
INotificationHandler<ContentDeletedNotification>,
|
||||
INotificationHandler<ContentDeletedBlueprintNotification>,
|
||||
INotificationHandler<MediaDeletedNotification>,
|
||||
INotificationHandler<MediaMovedToRecycleBinNotification>,
|
||||
INotificationHandler<MediaMovedNotification>,
|
||||
INotificationHandler<MemberDeletedNotification>
|
||||
{
|
||||
private readonly BlockEditorValues<BlockListValue, BlockListLayoutItem> _blockListEditorValues;
|
||||
private readonly BlockEditorValues<BlockGridValue, BlockGridLayoutItem> _blockGridEditorValues;
|
||||
private ContentSettings _contentSettings;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileUploadContentDeletedNotificationHandler"/> class.
|
||||
@@ -32,11 +38,15 @@ internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadNo
|
||||
IJsonSerializer jsonSerializer,
|
||||
MediaFileManager mediaFileManager,
|
||||
IBlockEditorElementTypeCache elementTypeCache,
|
||||
ILogger<FileUploadContentDeletedNotificationHandler> logger)
|
||||
ILogger<FileUploadContentDeletedNotificationHandler> logger,
|
||||
IOptionsMonitor<ContentSettings> contentSettngs)
|
||||
: base(jsonSerializer, mediaFileManager, elementTypeCache)
|
||||
{
|
||||
_blockListEditorValues = new(new BlockListEditorDataConverter(jsonSerializer), elementTypeCache, logger);
|
||||
_blockGridEditorValues = new(new BlockGridEditorDataConverter(jsonSerializer), elementTypeCache, logger);
|
||||
|
||||
_contentSettings = contentSettngs.CurrentValue;
|
||||
contentSettngs.OnChange(x => _contentSettings = x);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -48,19 +58,66 @@ internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadNo
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MediaMovedToRecycleBinNotification notification)
|
||||
{
|
||||
if (_contentSettings.EnableMediaRecycleBinProtection is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SuffixContainedFiles(
|
||||
notification.MoveInfoCollection
|
||||
.Select(x => x.Entity));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MediaMovedNotification notification)
|
||||
{
|
||||
if (_contentSettings.EnableMediaRecycleBinProtection is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RemoveSuffixFromContainedFiles(
|
||||
notification.MoveInfoCollection
|
||||
.Where(x => x.OriginalPath.StartsWith($"{Constants.System.RootString},{Constants.System.RecycleBinMediaString}"))
|
||||
.Select(x => x.Entity));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all file upload property files contained within a collection of content entities.
|
||||
/// </summary>
|
||||
/// <param name="deletedEntities"></param>
|
||||
/// <param name="deletedEntities">Delete media entities.</param>
|
||||
private void DeleteContainedFiles(IEnumerable<IContentBase> deletedEntities)
|
||||
{
|
||||
IReadOnlyList<string> filePathsToDelete = ContainedFilePaths(deletedEntities);
|
||||
MediaFileManager.DeleteMediaFiles(filePathsToDelete);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames all file upload property files contained within a collection of media entities that have been moved to the recycle bin.
|
||||
/// </summary>
|
||||
/// <param name="trashedMedia">Media entities that have been moved to the recycle bin.</param>
|
||||
private void SuffixContainedFiles(IEnumerable<IMedia> trashedMedia)
|
||||
{
|
||||
IEnumerable<string> filePathsToRename = ContainedFilePaths(trashedMedia);
|
||||
RecycleBinMediaProtectionHelper.SuffixContainedFiles(filePathsToRename, MediaFileManager);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames all file upload property files contained within a collection of media entities that have been restored from the recycle bin.
|
||||
/// </summary>
|
||||
/// <param name="restoredMedia">Media entities that have been restored from the recycle bin.</param>
|
||||
private void RemoveSuffixFromContainedFiles(IEnumerable<IMedia> restoredMedia)
|
||||
{
|
||||
IEnumerable<string> filePathsToRename = ContainedFilePaths(restoredMedia);
|
||||
MediaFileManager.RemoveSuffixFromMediaFiles(filePathsToRename, Constants.Conventions.Media.TrashedMediaSuffix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the paths to all file upload property files contained within a collection of content entities.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for multiple notification handlers dealing with protection of media files for media in the recycle bin.
|
||||
/// </summary>
|
||||
internal static class RecycleBinMediaProtectionHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Renames all file upload property files contained within a collection of media entities that have been moved to the recycle bin.
|
||||
/// </summary>
|
||||
/// <param name="filePaths">Media file paths.</param>
|
||||
/// <param name="mediaFileManager">The media file manager.</param>
|
||||
public static void SuffixContainedFiles(IEnumerable<string> filePaths, MediaFileManager mediaFileManager)
|
||||
=> mediaFileManager.SuffixMediaFiles(filePaths, Constants.Conventions.Media.TrashedMediaSuffix);
|
||||
|
||||
/// <summary>
|
||||
/// Renames all file upload property files contained within a collection of media entities that have been restore from the recycle bin.
|
||||
/// </summary>
|
||||
/// <param name="filePaths">Media file paths.</param>
|
||||
/// <param name="mediaFileManager">The media file manager.</param>
|
||||
public static void RemoveSuffixFromContainedFiles(IEnumerable<string> filePaths, MediaFileManager mediaFileManager)
|
||||
{
|
||||
IEnumerable<string> filePathsToRename = filePaths
|
||||
.Select(x => Path.ChangeExtension(x, Constants.Conventions.Media.TrashedMediaSuffix + Path.GetExtension(x)));
|
||||
mediaFileManager.RemoveSuffixFromMediaFiles(filePathsToRename, Constants.Conventions.Media.TrashedMediaSuffix);
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<PreviewAuthenticationMiddleware>();
|
||||
builder.Services.AddSingleton<UmbracoRequestMiddleware>();
|
||||
builder.Services.AddSingleton<BootFailedMiddleware>();
|
||||
builder.Services.AddSingleton<ProtectRecycleBinMediaMiddleware>();
|
||||
|
||||
builder.Services.AddUnique<ITemplateRenderer, TemplateRenderer>();
|
||||
builder.Services.AddUnique<IPublicAccessChecker, PublicAccessChecker>();
|
||||
|
||||
@@ -104,6 +104,7 @@ public static class ApplicationBuilderExtensions
|
||||
app.UseMiddleware<PreviewAuthenticationMiddleware>();
|
||||
app.UseMiddleware<UmbracoRequestMiddleware>();
|
||||
app.UseMiddleware<MiniProfilerMiddleware>();
|
||||
app.UseMiddleware<ProtectRecycleBinMediaMiddleware>();
|
||||
}
|
||||
|
||||
return app;
|
||||
|
||||
38
src/Umbraco.Web.Common/Extensions/StringExtensions.cs
Normal file
38
src/Umbraco.Web.Common/Extensions/StringExtensions.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace Umbraco.Cms.Web.Common.Extensions;
|
||||
|
||||
internal static class StringExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides a robust way to check if a path starts with another path, normalizing multiple slashes.
|
||||
/// </summary>
|
||||
internal static bool StartsWithNormalizedPath(this string path, string other, StringComparison comparisonType = StringComparison.Ordinal)
|
||||
{
|
||||
// First check without normalizing.
|
||||
if (path.StartsWith(other, comparisonType))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normalize paths by splitting them into segments, removing multiple slashes.
|
||||
var otherSegments = other.Split(Core.Constants.CharArrays.ForwardSlash, StringSplitOptions.RemoveEmptyEntries);
|
||||
var pathSegments = path.Split(Core.Constants.CharArrays.ForwardSlash, otherSegments.Length + 1, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// The path should have at least as many segments as the other path
|
||||
if (otherSegments.Length > pathSegments.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each segment.
|
||||
for (int i = otherSegments.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (!string.Equals(otherSegments[i], pathSegments[i], comparisonType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// All segments match.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Web.Common.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that requests to the media in the recycle bin are authorized and only authenticated back-office users
|
||||
/// with permissions for the media have access.
|
||||
/// </summary>
|
||||
public class ProtectRecycleBinMediaMiddleware : IMiddleware
|
||||
{
|
||||
private ContentSettings _contentSettings;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProtectRecycleBinMediaMiddleware"/> class.
|
||||
/// </summary>
|
||||
public ProtectRecycleBinMediaMiddleware(
|
||||
IOptionsMonitor<ContentSettings> contentSettings)
|
||||
{
|
||||
_contentSettings = contentSettings.CurrentValue;
|
||||
contentSettings.OnChange(x => _contentSettings = x);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
if (_contentSettings.EnableMediaRecycleBinProtection is false)
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
string? requestPath = context.Request.Path.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(requestPath) ||
|
||||
requestPath.StartsWithNormalizedPath($"/media/", StringComparison.OrdinalIgnoreCase) is false ||
|
||||
requestPath.Contains(Core.Constants.Conventions.Media.TrashedMediaSuffix + ".") is false)
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
AuthenticateResult authenticateResult = await context.AuthenticateAsync(Core.Constants.Security.BackOfficeExposedAuthenticationType);
|
||||
if (authenticateResult.Succeeded is false)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
Claim? mediaSectionClaim = authenticateResult.Principal.Claims
|
||||
.FirstOrDefault(x => x.Type == Core.Constants.Security.AllowedApplicationsClaimType && x.Value == Core.Constants.Applications.Media);
|
||||
|
||||
if (mediaSectionClaim is null)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
@@ -82,6 +81,24 @@ public class PhysicalFileSystemTests : AbstractFileSystemTests
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MoveFileTest()
|
||||
{
|
||||
var basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FileSysTests");
|
||||
|
||||
using (var ms = new MemoryStream(Encoding.UTF8.GetBytes("foo")))
|
||||
{
|
||||
_fileSystem.AddFile("sub/f3.txt", ms);
|
||||
}
|
||||
|
||||
Assert.IsTrue(File.Exists(Path.Combine(basePath, "sub/f3.txt")));
|
||||
|
||||
_fileSystem.MoveFile("sub/f3.txt", "sub2/f4.txt");
|
||||
|
||||
Assert.IsFalse(File.Exists(Path.Combine(basePath, "sub/f3.txt")));
|
||||
Assert.IsTrue(File.Exists(Path.Combine(basePath, "sub2/f4.txt")));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetFullPathTest()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user