diff --git a/src/Umbraco.Cms.Api.Common/Security/ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler.cs b/src/Umbraco.Cms.Api.Common/Security/ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler.cs
new file mode 100644
index 0000000000..4f3444ae31
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Common/Security/ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler.cs
@@ -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;
+
+///
+/// Provides OpenIddict server event handlers to expose the backoffice authentication token via a custom authentication scheme.
+///
+public class ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler : IOpenIddictServerHandler,
+ IOpenIddictServerHandler
+{
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly string[] _claimTypes;
+ private readonly TimeSpan _timeOut;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ExposeBackOfficeAuthenticationOpenIddictServerEventsHandler(
+ IHttpContextAccessor httpContextAccessor,
+ IOptions globalSettings,
+ IOptions 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,
+ ];
+ }
+
+ ///
+ ///
+ /// Event handler for when access tokens are generated (created or refreshed).
+ ///
+ 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());
+ }
+
+ ///
+ ///
+ /// Event handler for when access tokens are revoked.
+ ///
+ 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)
+ };
+}
diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs
index b633f7bdd6..3811587e4b 100644
--- a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs
+++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthBuilderExtensions.cs
@@ -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();
+ builder.Services.Configure(options =>
+ {
+ options.Handlers.Add(
+ OpenIddictServerHandlerDescriptor
+ .CreateBuilder()
+ .UseSingletonHandler()
+ .Build());
+ options.Handlers.Add(
+ OpenIddictServerHandlerDescriptor
+ .CreateBuilder()
+ .UseSingletonHandler()
+ .Build());
+ });
+
builder.Services.AddScoped();
builder.Services.ConfigureOptions();
builder.Services.ConfigureOptions();
diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs
index 5d3f579c58..6ec71e8a5c 100644
--- a/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs
+++ b/src/Umbraco.Cms.Api.Management/Mapping/Media/MediaMapDefinition.cs
@@ -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, IMapDefinition
{
private readonly CommonMapper _commonMapper;
+ private ContentSettings _contentSettings;
public MediaMapDefinition(
PropertyEditorCollection propertyEditorCollection,
CommonMapper commonMapper,
- IDataValueEditorFactory dataValueEditorFactory)
+ IDataValueEditorFactory dataValueEditorFactory,
+ IOptionsMonitor 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>())
+ {
+ }
[Obsolete("Please use the non-obsolete constructor. Scheduled for removal in Umbraco 18.")]
public MediaMapDefinition(
@@ -48,6 +70,39 @@ public class MediaMapDefinition : ContentMapDefinition 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
diff --git a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs
index 6df9b429e9..645d21c567 100644
--- a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs
+++ b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs
@@ -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;
+
///
/// Gets or sets a value for the content notification settings.
///
@@ -158,4 +161,16 @@ public class ContentSettings
///
[DefaultValue(StaticShowUnroutableContentWarnings)]
public bool ShowUnroutableContentWarnings { get; set; } = StaticShowUnroutableContentWarnings;
+
+ ///
+ /// Gets or sets a value indicating whether to enable or disable the recycle bin protection for media.
+ ///
+ ///
+ /// 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.
+ ///
+ [DefaultValue(StaticEnableMediaRecycleBinProtection)]
+ public bool EnableMediaRecycleBinProtection { get; set; } = StaticEnableMediaRecycleBinProtection;
}
diff --git a/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs b/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs
index 32bfeedb51..9227c31585 100644
--- a/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs
+++ b/src/Umbraco.Core/Configuration/Models/ImagingSettings.cs
@@ -1,6 +1,8 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
+using System.ComponentModel;
+
namespace Umbraco.Cms.Core.Configuration.Models;
///
diff --git a/src/Umbraco.Core/Constants-Conventions.cs b/src/Umbraco.Core/Constants-Conventions.cs
index 2d21c544e5..e117b42189 100644
--- a/src/Umbraco.Core/Constants-Conventions.cs
+++ b/src/Umbraco.Core/Constants-Conventions.cs
@@ -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
///
public const int DefaultSize = 200;
+
+ ///
+ /// Suffix added to media files when moved to the recycle bin when recycle bin media protection is enabled.
+ ///
+ public const string TrashedMediaSuffix = ".deleted";
}
///
diff --git a/src/Umbraco.Core/Constants-Security.cs b/src/Umbraco.Core/Constants-Security.cs
index eb77642f1b..6567fc99b3 100644
--- a/src/Umbraco.Core/Constants-Security.cs
+++ b/src/Umbraco.Core/Constants-Security.cs
@@ -73,6 +73,17 @@ public static partial class Constants
public const string BackOfficeTokenAuthenticationType = "UmbracoBackOfficeToken";
public const string BackOfficeTwoFactorAuthenticationType = "UmbracoTwoFactorCookie";
public const string BackOfficeTwoFactorRememberMeAuthenticationType = "UmbracoTwoFactorRememberMeCookie";
+
+ ///
+ /// Authentication type and scheme used for backoffice users when it is exposed out of the backoffice context via a cookie.
+ ///
+ public const string BackOfficeExposedAuthenticationType = "UmbracoBackOfficeExposed";
+
+ ///
+ /// Represents the name of the authentication cookie used to expose the backoffice authentication token outside of the backoffice context.
+ ///
+ public const string BackOfficeExposedCookieName = "UMB_UCONTEXT_EXPOSED";
+
public const string EmptyPasswordPrefix = "___UIDEMPTYPWORD__";
public const string DefaultMemberTypeAlias = "Member";
diff --git a/src/Umbraco.Core/IO/IFileSystem.cs b/src/Umbraco.Core/IO/IFileSystem.cs
index da9dd0b9bb..390556f74e 100644
--- a/src/Umbraco.Core/IO/IFileSystem.cs
+++ b/src/Umbraco.Core/IO/IFileSystem.cs
@@ -168,10 +168,42 @@ public interface IFileSystem
/// A value indicating whether to move (default) or copy.
void AddFile(string path, string physicalPath, bool overrideIfExists = true, bool copy = false);
+ ///
+ /// Moves a file from the specified source path to the specified target path.
+ ///
+ /// The path of the file or directory to move.
+ /// The destination path where the file or directory will be moved.
+ /// A value indicating what to do if the file already exists.
+ 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);
}
diff --git a/src/Umbraco.Core/IO/MediaFileManager.cs b/src/Umbraco.Core/IO/MediaFileManager.cs
index fe9f829567..6b100db532 100644
--- a/src/Umbraco.Core/IO/MediaFileManager.cs
+++ b/src/Umbraco.Core/IO/MediaFileManager.cs
@@ -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.
///
/// Files to delete (filesystem-relative paths).
- public void DeleteMediaFiles(IEnumerable files)
+ public void DeleteMediaFiles(IEnumerable 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}'.");
+
+ ///
+ /// Adds a suffix to media files.
+ ///
+ /// Files to append a suffix to.
+ /// The suffix to append.
+ ///
+ /// The suffix will be added prior to the file extension, e.g. "image.jpg" with suffix ".deleted" will become "image.deleted.jpg".
+ ///
+ public void SuffixMediaFiles(IEnumerable 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}'.");
+
+ ///
+ /// Removes a suffix from media files.
+ ///
+ /// Files to remove a suffix from.
+ /// The suffix to remove.
+ ///
+ /// The suffix will be removed prior to the file extension, e.g. "image.deleted.jpg" with suffix ".deleted" will become "image.jpg".
+ ///
+ public void RemoveSuffixFromMediaFiles(IEnumerable 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 files, Action 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);
}
});
}
diff --git a/src/Umbraco.Core/IO/PhysicalFileSystem.cs b/src/Umbraco.Core/IO/PhysicalFileSystem.cs
index 32f0d0fdab..9c21d056db 100644
--- a/src/Umbraco.Core/IO/PhysicalFileSystem.cs
+++ b/src/Umbraco.Core/IO/PhysicalFileSystem.cs
@@ -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
}
}
+ ///
+ 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)
diff --git a/src/Umbraco.Core/IO/ShadowFileSystem.cs b/src/Umbraco.Core/IO/ShadowFileSystem.cs
index 31e884454f..db39fe74f4 100644
--- a/src/Umbraco.Core/IO/ShadowFileSystem.cs
+++ b/src/Umbraco.Core/IO/ShadowFileSystem.cs
@@ -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);
diff --git a/src/Umbraco.Core/IO/ShadowWrapper.cs b/src/Umbraco.Core/IO/ShadowWrapper.cs
index aa3d7c9b97..9f4abc9160 100644
--- a/src/Umbraco.Core/IO/ShadowWrapper.cs
+++ b/src/Umbraco.Core/IO/ShadowWrapper.cs
@@ -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);
diff --git a/src/Umbraco.Core/Notifications/MediaMovedToRecycleBinNotification.cs b/src/Umbraco.Core/Notifications/MediaMovedToRecycleBinNotification.cs
index 57897587b8..c9e7a19b44 100644
--- a/src/Umbraco.Core/Notifications/MediaMovedToRecycleBinNotification.cs
+++ b/src/Umbraco.Core/Notifications/MediaMovedToRecycleBinNotification.cs
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
+
///
/// 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.
///
diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs
index c9f8db1154..d9a9c025eb 100644
--- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs
+++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs
@@ -370,12 +370,16 @@ public static partial class UmbracoBuilderExtensions
.AddNotificationHandler()
.AddNotificationHandler()
.AddNotificationHandler()
+ .AddNotificationHandler()
+ .AddNotificationHandler()
.AddNotificationHandler()
.AddNotificationHandler()
.AddNotificationHandler()
.AddNotificationHandler()
.AddNotificationHandler()
.AddNotificationHandler()
+ .AddNotificationHandler()
+ .AddNotificationHandler()
.AddNotificationHandler()
.AddNotificationHandler()
.AddNotificationHandler();
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs
index fe978283c4..1af39f7685 100644
--- a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs
+++ b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyEditor.cs
@@ -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, INotificationHandler,
- INotificationHandler, INotificationHandler,
+ INotificationHandler,
+ INotificationHandler,
+ INotificationHandler,
+ INotificationHandler,
+ INotificationHandler,
+ INotificationHandler,
INotificationHandler
{
private readonly UploadAutoFillProperties _autoFillProperties;
@@ -60,6 +67,7 @@ public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator,
_logger = loggerFactory.CreateLogger();
contentSettings.OnChange(x => _contentSettings = x);
+
SupportsReadOnly = true;
}
@@ -119,10 +127,13 @@ public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator,
}
}
+ ///
public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
+ ///
public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
+ ///
public void Handle(MediaSavingNotification notification)
{
foreach (IMedia entity in notification.SavedEntities)
@@ -131,6 +142,34 @@ public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator,
}
}
+ ///
+ public void Handle(MediaMovedToRecycleBinNotification notification)
+ {
+ if (_contentSettings.EnableMediaRecycleBinProtection is false)
+ {
+ return;
+ }
+
+ SuffixContainedFiles(
+ notification.MoveInfoCollection
+ .Select(x => x.Entity));
+ }
+
+ ///
+ 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));
+ }
+
+ ///
public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
///
@@ -233,12 +272,36 @@ public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator,
return relative ? _mediaFileManager.FileSystem.GetRelativePath(source) : source;
}
+ ///
+ /// Deletes all file upload property files contained within a collection of content entities.
+ ///
+ /// Delete media entities.
private void DeleteContainedFiles(IEnumerable deletedEntities)
{
IEnumerable filePathsToDelete = ContainedFilePaths(deletedEntities);
_mediaFileManager.DeleteMediaFiles(filePathsToDelete);
}
+ ///
+ /// Renames all file upload property files contained within a collection of media entities that have been moved to the recycle bin.
+ ///
+ /// Media entities that have been moved to the recycle bin.
+ private void SuffixContainedFiles(IEnumerable trashedMedia)
+ {
+ IEnumerable filePathsToRename = ContainedFilePaths(trashedMedia);
+ RecycleBinMediaProtectionHelper.SuffixContainedFiles(filePathsToRename, _mediaFileManager);
+ }
+
+ ///
+ /// Renames all file upload property files contained within a collection of media entities that have been restore from the recycle bin.
+ ///
+ /// Media entities that have been restored from the recycle bin.
+ private void RemoveSuffixFromContainedFiles(IEnumerable restoredMedia)
+ {
+ IEnumerable filePathsToRename = ContainedFilePaths(restoredMedia);
+ RecycleBinMediaProtectionHelper.RemoveSuffixFromContainedFiles(filePathsToRename, _mediaFileManager);
+ }
+
///
/// Auto-fill properties (or clear).
///
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs
index 4203209b58..1c36bea04d 100644
--- a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs
+++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs
@@ -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;
///
-/// 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.
///
internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadNotificationHandlerBase,
INotificationHandler,
INotificationHandler,
INotificationHandler,
+ INotificationHandler,
+ INotificationHandler,
INotificationHandler
{
private readonly BlockEditorValues _blockListEditorValues;
private readonly BlockEditorValues _blockGridEditorValues;
+ private ContentSettings _contentSettings;
///
/// Initializes a new instance of the class.
@@ -32,11 +38,15 @@ internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadNo
IJsonSerializer jsonSerializer,
MediaFileManager mediaFileManager,
IBlockEditorElementTypeCache elementTypeCache,
- ILogger logger)
+ ILogger logger,
+ IOptionsMonitor 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);
}
///
@@ -48,19 +58,66 @@ internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadNo
///
public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
+ ///
+ public void Handle(MediaMovedToRecycleBinNotification notification)
+ {
+ if (_contentSettings.EnableMediaRecycleBinProtection is false)
+ {
+ return;
+ }
+
+ SuffixContainedFiles(
+ notification.MoveInfoCollection
+ .Select(x => x.Entity));
+ }
+
+ ///
+ 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));
+ }
+
///
public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
///
/// Deletes all file upload property files contained within a collection of content entities.
///
- ///
+ /// Delete media entities.
private void DeleteContainedFiles(IEnumerable deletedEntities)
{
IReadOnlyList filePathsToDelete = ContainedFilePaths(deletedEntities);
MediaFileManager.DeleteMediaFiles(filePathsToDelete);
}
+ ///
+ /// Renames all file upload property files contained within a collection of media entities that have been moved to the recycle bin.
+ ///
+ /// Media entities that have been moved to the recycle bin.
+ private void SuffixContainedFiles(IEnumerable trashedMedia)
+ {
+ IEnumerable filePathsToRename = ContainedFilePaths(trashedMedia);
+ RecycleBinMediaProtectionHelper.SuffixContainedFiles(filePathsToRename, MediaFileManager);
+ }
+
+ ///
+ /// Renames all file upload property files contained within a collection of media entities that have been restored from the recycle bin.
+ ///
+ /// Media entities that have been restored from the recycle bin.
+ private void RemoveSuffixFromContainedFiles(IEnumerable restoredMedia)
+ {
+ IEnumerable filePathsToRename = ContainedFilePaths(restoredMedia);
+ MediaFileManager.RemoveSuffixFromMediaFiles(filePathsToRename, Constants.Conventions.Media.TrashedMediaSuffix);
+ }
+
///
/// Gets the paths to all file upload property files contained within a collection of content entities.
///
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/RecycleBinMediaProtectionHelper.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/RecycleBinMediaProtectionHelper.cs
new file mode 100644
index 0000000000..ca540b9c61
--- /dev/null
+++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/RecycleBinMediaProtectionHelper.cs
@@ -0,0 +1,30 @@
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers;
+
+///
+/// Provides helper methods for multiple notification handlers dealing with protection of media files for media in the recycle bin.
+///
+internal static class RecycleBinMediaProtectionHelper
+{
+ ///
+ /// Renames all file upload property files contained within a collection of media entities that have been moved to the recycle bin.
+ ///
+ /// Media file paths.
+ /// The media file manager.
+ public static void SuffixContainedFiles(IEnumerable filePaths, MediaFileManager mediaFileManager)
+ => mediaFileManager.SuffixMediaFiles(filePaths, Constants.Conventions.Media.TrashedMediaSuffix);
+
+ ///
+ /// Renames all file upload property files contained within a collection of media entities that have been restore from the recycle bin.
+ ///
+ /// Media file paths.
+ /// The media file manager.
+ public static void RemoveSuffixFromContainedFiles(IEnumerable filePaths, MediaFileManager mediaFileManager)
+ {
+ IEnumerable filePathsToRename = filePaths
+ .Select(x => Path.ChangeExtension(x, Constants.Conventions.Media.TrashedMediaSuffix + Path.GetExtension(x)));
+ mediaFileManager.RemoveSuffixFromMediaFiles(filePathsToRename, Constants.Conventions.Media.TrashedMediaSuffix);
+ }
+}
diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs
index b3d31d323b..86bea154c6 100644
--- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs
+++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs
@@ -270,6 +270,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
builder.Services.AddUnique();
builder.Services.AddUnique();
diff --git a/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs b/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs
index ada8f64fdf..76ef786fbf 100644
--- a/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs
+++ b/src/Umbraco.Web.Common/Extensions/ApplicationBuilderExtensions.cs
@@ -104,6 +104,7 @@ public static class ApplicationBuilderExtensions
app.UseMiddleware();
app.UseMiddleware();
app.UseMiddleware();
+ app.UseMiddleware();
}
return app;
diff --git a/src/Umbraco.Web.Common/Extensions/StringExtensions.cs b/src/Umbraco.Web.Common/Extensions/StringExtensions.cs
new file mode 100644
index 0000000000..29cff82f62
--- /dev/null
+++ b/src/Umbraco.Web.Common/Extensions/StringExtensions.cs
@@ -0,0 +1,38 @@
+namespace Umbraco.Cms.Web.Common.Extensions;
+
+internal static class StringExtensions
+{
+ ///
+ /// Provides a robust way to check if a path starts with another path, normalizing multiple slashes.
+ ///
+ 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;
+ }
+}
diff --git a/src/Umbraco.Web.Common/Middleware/ProtectRecycleBinMediaMiddleware.cs b/src/Umbraco.Web.Common/Middleware/ProtectRecycleBinMediaMiddleware.cs
new file mode 100644
index 0000000000..2df70c2de5
--- /dev/null
+++ b/src/Umbraco.Web.Common/Middleware/ProtectRecycleBinMediaMiddleware.cs
@@ -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;
+
+///
+/// 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.
+///
+public class ProtectRecycleBinMediaMiddleware : IMiddleware
+{
+ private ContentSettings _contentSettings;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ProtectRecycleBinMediaMiddleware(
+ IOptionsMonitor contentSettings)
+ {
+ _contentSettings = contentSettings.CurrentValue;
+ contentSettings.OnChange(x => _contentSettings = x);
+ }
+
+ ///
+ 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);
+ }
+}
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/FileSystemsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/FileSystemsTests.cs
index 498e6b1f61..5893333e8a 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/FileSystemsTests.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/FileSystemsTests.cs
@@ -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();
+ var hostingEnvironment = GetRequiredService();
+
+ 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();
+ var hostingEnvironment = GetRequiredService();
+
+ 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]
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/ShadowFileSystemTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/ShadowFileSystemTests.cs
index e994e88866..c3932fabb3 100644
--- a/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/ShadowFileSystemTests.cs
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/IO/ShadowFileSystemTests.cs
@@ -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()
{
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/IO/PhysicalFileSystemTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/IO/PhysicalFileSystemTests.cs
index 396ad94415..fcd922a438 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/IO/PhysicalFileSystemTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/IO/PhysicalFileSystemTests.cs
@@ -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()
{