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() {