diff --git a/src/Umbraco.Core/CompatibilitySuppressions.xml b/src/Umbraco.Core/CompatibilitySuppressions.xml index 444b4e69c5..3d26736938 100644 --- a/src/Umbraco.Core/CompatibilitySuppressions.xml +++ b/src/Umbraco.Core/CompatibilitySuppressions.xml @@ -85,6 +85,27 @@ lib/net7.0/Umbraco.Core.dll true + + CP0001 + T:Umbraco.Cms.Core.Models.ContentEditing.IHaveUploadedFiles + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0001 + T:Umbraco.Cms.Core.Models.ContentEditing.PostedFiles + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0001 + T:Umbraco.Cms.Core.Models.Editors.ContentPropertyFile + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0002 M:Umbraco.Cms.Core.Configuration.Grid.GridConfig.#ctor(Umbraco.Cms.Core.Cache.AppCaches,Umbraco.Cms.Core.Manifest.IManifestParser,Umbraco.Cms.Core.Serialization.IJsonSerializer,Umbraco.Cms.Core.Hosting.IHostingEnvironment,Microsoft.Extensions.Logging.ILoggerFactory,Umbraco.Cms.Core.IO.IGridEditorsConfigFileProviderFactory) @@ -316,6 +337,20 @@ lib/net7.0/Umbraco.Core.dll true + + CP0002 + M:Umbraco.Cms.Core.Models.ContentEditing.ContentBaseSave`1.get_UploadedFiles + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.ContentEditing.ContentItemSave.get_UploadedFiles + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0002 M:Umbraco.Cms.Core.Models.ContentEditing.ContentPropertyDisplay.get_ConfigNullable @@ -435,6 +470,20 @@ lib/net7.0/Umbraco.Core.dll true + + CP0002 + M:Umbraco.Cms.Core.Models.Editors.ContentPropertyData.get_Files + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.Editors.ContentPropertyData.set_Files(Umbraco.Cms.Core.Models.Editors.ContentPropertyFile[]) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0002 M:Umbraco.Cms.Core.Models.IDataType.get_Configuration @@ -1506,4 +1555,39 @@ lib/net7.0/Umbraco.Core.dll true - \ No newline at end of file + + CP0008 + T:Umbraco.Cms.Core.Models.ContentEditing.ContentBaseSave`1 + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0008 + T:Umbraco.Cms.Core.Models.ContentEditing.ContentItemSave + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0008 + T:Umbraco.Cms.Core.Models.ContentEditing.IContentSave`1 + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0008 + T:Umbraco.Cms.Core.Models.ContentEditing.MediaItemSave + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0008 + T:Umbraco.Cms.Core.Models.ContentEditing.MemberSave + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 48c474794e..956eaf003f 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -345,6 +345,7 @@ namespace Umbraco.Cms.Core.DependencyInjection Services.AddUnique(provider => new CultureImpactFactory(provider.GetRequiredService>())); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); } } } diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentBaseSave.cs b/src/Umbraco.Core/Models/ContentEditing/ContentBaseSave.cs index 241cde46b4..a9a95da70a 100644 --- a/src/Umbraco.Core/Models/ContentEditing/ContentBaseSave.cs +++ b/src/Umbraco.Core/Models/ContentEditing/ContentBaseSave.cs @@ -11,8 +11,6 @@ namespace Umbraco.Cms.Core.Models.ContentEditing; public abstract class ContentBaseSave : ContentItemBasic, IContentSave where TPersisted : IContentBase { - protected ContentBaseSave() => UploadedFiles = new List(); - #region IContentSave /// @@ -27,9 +25,6 @@ public abstract class ContentBaseSave : ContentItemBasic base.Properties = value; } - [IgnoreDataMember] - public List UploadedFiles { get; } - // These need explicit implementation because we are using internal models /// diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentItemSave.cs b/src/Umbraco.Core/Models/ContentEditing/ContentItemSave.cs index 400436421b..c1df9480cb 100644 --- a/src/Umbraco.Core/Models/ContentEditing/ContentItemSave.cs +++ b/src/Umbraco.Core/Models/ContentEditing/ContentItemSave.cs @@ -12,7 +12,6 @@ public class ContentItemSave : IContentSave { public ContentItemSave() { - UploadedFiles = new List(); Variants = new List(); } @@ -43,9 +42,6 @@ public class ContentItemSave : IContentSave [Required] public ContentSaveAction Action { get; set; } - [IgnoreDataMember] - public List UploadedFiles { get; } - // These need explicit implementation because we are using internal models /// diff --git a/src/Umbraco.Core/Models/ContentEditing/IContentSave.cs b/src/Umbraco.Core/Models/ContentEditing/IContentSave.cs index effccf95fa..d0dee2931d 100644 --- a/src/Umbraco.Core/Models/ContentEditing/IContentSave.cs +++ b/src/Umbraco.Core/Models/ContentEditing/IContentSave.cs @@ -5,7 +5,7 @@ namespace Umbraco.Cms.Core.Models.ContentEditing; /// logic /// /// -public interface IContentSave : IHaveUploadedFiles +public interface IContentSave where TPersisted : IContentBase { /// diff --git a/src/Umbraco.Core/Models/ContentEditing/IHaveUploadedFiles.cs b/src/Umbraco.Core/Models/ContentEditing/IHaveUploadedFiles.cs deleted file mode 100644 index 7e467ff124..0000000000 --- a/src/Umbraco.Core/Models/ContentEditing/IHaveUploadedFiles.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Umbraco.Cms.Core.Models.Editors; - -namespace Umbraco.Cms.Core.Models.ContentEditing; - -public interface IHaveUploadedFiles -{ - List UploadedFiles { get; } -} diff --git a/src/Umbraco.Core/Models/ContentEditing/PostedFiles.cs b/src/Umbraco.Core/Models/ContentEditing/PostedFiles.cs deleted file mode 100644 index 5d71369141..0000000000 --- a/src/Umbraco.Core/Models/ContentEditing/PostedFiles.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Runtime.Serialization; -using Umbraco.Cms.Core.Models.Editors; - -namespace Umbraco.Cms.Core.Models.ContentEditing; - -/// -/// This is used for the response of PostAddFile so that we can analyze the response in a filter and remove the -/// temporary files that were created. -/// -[DataContract] -public class PostedFiles : IHaveUploadedFiles, INotificationModel -{ - public PostedFiles() - { - UploadedFiles = new List(); - Notifications = new List(); - } - - public List UploadedFiles { get; } - - [DataMember(Name = "notifications")] - public List Notifications { get; private set; } -} diff --git a/src/Umbraco.Core/Models/Editors/ContentPropertyData.cs b/src/Umbraco.Core/Models/Editors/ContentPropertyData.cs index ac19eef0c8..51910815b5 100644 --- a/src/Umbraco.Core/Models/Editors/ContentPropertyData.cs +++ b/src/Umbraco.Core/Models/Editors/ContentPropertyData.cs @@ -34,9 +34,4 @@ public class ContentPropertyData /// Gets or sets the unique identifier of the property type. /// public Guid PropertyTypeKey { get; set; } - - /// - /// Gets or sets the uploaded files. - /// - public ContentPropertyFile[]? Files { get; set; } } diff --git a/src/Umbraco.Core/Models/Editors/ContentPropertyFile.cs b/src/Umbraco.Core/Models/Editors/ContentPropertyFile.cs deleted file mode 100644 index 9bb098697c..0000000000 --- a/src/Umbraco.Core/Models/Editors/ContentPropertyFile.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace Umbraco.Cms.Core.Models.Editors; - -/// -/// Represents an uploaded file for a property. -/// -public class ContentPropertyFile -{ - /// - /// Gets or sets the property alias. - /// - public string? PropertyAlias { get; set; } - - /// - /// When dealing with content variants, this is the culture for the variant - /// - public string? Culture { get; set; } - - /// - /// When dealing with content variants, this is the segment for the variant - /// - public string? Segment { get; set; } - - /// - /// An array of metadata that is parsed out from the file info posted to the server which is set on the client. - /// - /// - /// This can be used for property types like Nested Content that need to have special unique identifiers for each file - /// since there might be multiple files - /// per property. - /// - public string[]? Metadata { get; set; } - - /// - /// Gets or sets the name of the file. - /// - public string? FileName { get; set; } - - /// - /// Gets or sets the temporary path where the file has been uploaded. - /// - public string TempFilePath { get; set; } = string.Empty; -} diff --git a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs index 2ff9232cb5..a218ed3260 100644 --- a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs @@ -377,9 +377,7 @@ public abstract class ContentEditingServiceBase() + PropertyTypeKey = propertyType.Key }; var currentValue = content.GetValue(propertyType.Alias, culture, segment); diff --git a/src/Umbraco.Core/Services/IMediaImportService.cs b/src/Umbraco.Core/Services/IMediaImportService.cs new file mode 100644 index 0000000000..0b2c90022e --- /dev/null +++ b/src/Umbraco.Core/Services/IMediaImportService.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +public interface IMediaImportService +{ + public Task ImportAsync(string fileName, Stream fileStream, Guid? parentId, string? mediaTypeAlias, Guid userKey); +} diff --git a/src/Umbraco.Core/Services/ITemporaryMediaService.cs b/src/Umbraco.Core/Services/ITemporaryMediaService.cs index 9c3c07acaf..2b4f00ab78 100644 --- a/src/Umbraco.Core/Services/ITemporaryMediaService.cs +++ b/src/Umbraco.Core/Services/ITemporaryMediaService.cs @@ -2,6 +2,7 @@ namespace Umbraco.Cms.Core.Services; +[Obsolete($"This service has been superseded by {nameof(IMediaImportService)}. Will be removed in V16.")] public interface ITemporaryMediaService { public IMedia Save(string temporaryLocation, Guid? startNode, string? mediaTypeAlias); diff --git a/src/Umbraco.Core/Services/MediaImportService.cs b/src/Umbraco.Core/Services/MediaImportService.cs new file mode 100644 index 0000000000..8c73bbc89f --- /dev/null +++ b/src/Umbraco.Core/Services/MediaImportService.cs @@ -0,0 +1,74 @@ +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Services; + +internal sealed class MediaImportService : IMediaImportService +{ + private readonly IShortStringHelper _shortStringHelper; + private readonly MediaFileManager _mediaFileManager; + private readonly IMediaService _mediaService; + private readonly MediaUrlGeneratorCollection _mediaUrlGenerators; + private readonly IContentTypeBaseServiceProvider _contentTypeBaseServiceProvider; + private readonly IEntityService _entityService; + private readonly AppCaches _appCaches; + private readonly IUserService _userService; + + public MediaImportService( + IShortStringHelper shortStringHelper, + MediaFileManager mediaFileManager, + IMediaService mediaService, + MediaUrlGeneratorCollection mediaUrlGenerators, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + IEntityService entityService, + AppCaches appCaches, + IUserService userService) + { + _shortStringHelper = shortStringHelper; + _mediaFileManager = mediaFileManager; + _mediaService = mediaService; + _mediaUrlGenerators = mediaUrlGenerators; + _contentTypeBaseServiceProvider = contentTypeBaseServiceProvider; + _entityService = entityService; + _appCaches = appCaches; + _userService = userService; + } + + public async Task ImportAsync(string fileName, Stream fileStream, Guid? parentId, string? mediaTypeAlias, Guid userKey) + { + if (fileStream.CanRead == false) + { + throw new InvalidOperationException("Could not read from file stream, please ensure it is open and readable"); + } + + IUser user = await _userService.GetAsync(userKey) + ?? throw new ArgumentException($"Could not find a user with the specified user key ({userKey})", nameof(userKey)); + + var safeFileName = fileName.ToSafeFileName(_shortStringHelper); + var mediaItemName = safeFileName.ToFriendlyName(); + + IMedia mediaFile; + + if (parentId is null) + { + int[]? userStartNodes = user.CalculateMediaStartNodeIds(_entityService, _appCaches); + + mediaFile = _mediaService.CreateMedia(mediaItemName, userStartNodes != null && userStartNodes.Any() ? userStartNodes[0] : Constants.System.Root, mediaTypeAlias ?? Constants.Conventions.MediaTypes.File, user.Id); + } + else + { + mediaFile = _mediaService.CreateMedia(mediaItemName, parentId.Value, mediaTypeAlias ?? Constants.Conventions.MediaTypes.File, user.Id); + } + + mediaFile.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper, _contentTypeBaseServiceProvider, Constants.Conventions.Media.File, safeFileName, fileStream); + + _mediaService.Save(mediaFile, user.Id); + + return mediaFile; + } +} diff --git a/src/Umbraco.Core/Services/TemporaryMediaService.cs b/src/Umbraco.Core/Services/TemporaryMediaService.cs index acd8e08fc9..41f45e0a69 100644 --- a/src/Umbraco.Core/Services/TemporaryMediaService.cs +++ b/src/Umbraco.Core/Services/TemporaryMediaService.cs @@ -12,6 +12,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services; +[Obsolete($"This service has been superseded by {nameof(IMediaImportService)}. Will be removed in V16.")] public class TemporaryMediaService : ITemporaryMediaService { private readonly IShortStringHelper _shortStringHelper; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs index 580209c48f..f1b6c482c4 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs @@ -149,11 +149,7 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator, /// /// The corresponding property value editor. protected override IDataValueEditor CreateValueEditor() - { - FileUploadPropertyValueEditor editor = DataValueEditorFactory.Create(Attribute!); - editor.Validators.Add(new UploadFileTypeValidator(_localizedTextService, _contentSettings)); - return editor; - } + => DataValueEditorFactory.Create(Attribute!); /// /// Gets a value indicating whether a property is an upload field. diff --git a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs index 5a14a1afc1..db2315d5c9 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs @@ -5,9 +5,11 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Models.TemporaryFile; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; @@ -18,6 +20,8 @@ namespace Umbraco.Cms.Core.PropertyEditors; internal class FileUploadPropertyValueEditor : DataValueEditor { private readonly MediaFileManager _mediaFileManager; + private readonly ITemporaryFileService _temporaryFileService; + private readonly IScopeProvider _scopeProvider; private ContentSettings _contentSettings; public FileUploadPropertyValueEditor( @@ -27,12 +31,22 @@ internal class FileUploadPropertyValueEditor : DataValueEditor IShortStringHelper shortStringHelper, IOptionsMonitor contentSettings, IJsonSerializer jsonSerializer, - IIOHelper ioHelper) + IIOHelper ioHelper, + ITemporaryFileService temporaryFileService, + IScopeProvider scopeProvider) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _mediaFileManager = mediaFileManager ?? throw new ArgumentNullException(nameof(mediaFileManager)); + _temporaryFileService = temporaryFileService; + _scopeProvider = scopeProvider; _contentSettings = contentSettings.CurrentValue ?? throw new ArgumentNullException(nameof(contentSettings)); contentSettings.OnChange(x => _contentSettings = x); + + Validators.Add(new TemporaryFileUploadValidator( + () => _contentSettings, + TryParseTemporaryFileKey, + TryGetTemporaryFile, + IsAllowedInDataTypeConfiguration)); } /// @@ -44,108 +58,125 @@ internal class FileUploadPropertyValueEditor : DataValueEditor /// /// The is used to re-use the folder, if possible. /// - /// The is value passed in from the editor. We normally don't care what - /// the editorValue.Value is set to because we are more interested in the files collection associated with it, - /// however we do care about the value if we are clearing files. By default the editorValue.Value will just - /// be set to the name of the file - but again, we just ignore this and deal with the file collection in - /// editorValue.AdditionalData.ContainsKey("files") - /// - /// - /// We only process ONE file. We understand that the current value may contain more than one file, - /// and that more than one file may be uploaded, so we take care of them all, but we only store ONE file. - /// Other places (FileUploadPropertyEditor...) do NOT deal with multiple files, and our logic for reusing - /// folders would NOT work, etc. + /// The is value passed in from the editor. If the value is empty, we + /// must delete the currently selected file (). If the value is not empty, + /// it is assumed to contain a temporary file key, and we will attempt to replace the currently selected + /// file with the corresponding temporary file. /// /// public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) { - var currentPath = currentValue as string; - if (!currentPath.IsNullOrWhiteSpace()) + var currentStringValue = currentValue as string; + currentStringValue = currentStringValue.NullOrWhiteSpaceAsNull(); + + var editorStringValue = editorValue.Value as string; + editorStringValue = editorStringValue.NullOrWhiteSpaceAsNull(); + + // no change? + if (editorStringValue == currentStringValue) { - currentPath = _mediaFileManager.FileSystem.GetRelativePath(currentPath!); + return currentValue; } - string? editorFile = null; - if (editorValue.Value != null) + var currentPath = currentStringValue; + if (currentPath.IsNullOrWhiteSpace() == false) { - editorFile = editorValue.Value as string; + currentPath = _mediaFileManager.FileSystem.GetRelativePath(currentPath); } + // resetting the current value? + if (editorStringValue is null && currentPath.IsNullOrWhiteSpace() is false) + { + // delete the current file and clear the value of this property + _mediaFileManager.FileSystem.DeleteFile(currentPath); + return null; + } + + // uploading a file? + if (Guid.TryParse(editorStringValue, out Guid temporaryFileKey) == false) + { + return editorStringValue; + } + + TemporaryFileModel? file = TryGetTemporaryFile(temporaryFileKey); + if (file == null) + { + // at this point the temporary file *should* have been validated by TemporaryFileUploadValidator, so we + // should never end up here. In case we do, let's attempt to at least be non-destructive by returning + // the current value + return currentValue; + } + + // schedule temporary file for deletion + using IScope scope = _scopeProvider.CreateScope(); + _temporaryFileService.EnlistDeleteIfScopeCompletes(temporaryFileKey, _scopeProvider); + // ensure we have the required guids - Guid cuid = editorValue.ContentKey; - if (cuid == Guid.Empty) + Guid contentKey = editorValue.ContentKey; + if (contentKey == Guid.Empty) { throw new Exception("Invalid content key."); } - Guid puid = editorValue.PropertyTypeKey; - if (puid == Guid.Empty) + Guid propertyTypeKey = editorValue.PropertyTypeKey; + if (propertyTypeKey == Guid.Empty) { throw new Exception("Invalid property type key."); } - ContentPropertyFile[]? uploads = editorValue.Files; - if (uploads == null) - { - throw new Exception("Invalid files."); - } - - ContentPropertyFile? file = uploads.Length > 0 ? uploads[0] : null; - - // not uploading a file - if (file == null) - { - // if editorFile is empty then either there was nothing to begin with, - // or it has been cleared and we need to remove the file - else the - // value is unchanged. - if (string.IsNullOrWhiteSpace(editorFile) && string.IsNullOrWhiteSpace(currentPath) == false) - { - _mediaFileManager.FileSystem.DeleteFile(currentPath); - return null; // clear - } - - return currentValue; // unchanged - } - // process the file - var filepath = editorFile == null ? null : ProcessFile(file, editorValue.DataTypeConfiguration, cuid, puid); - - // remove all temp files - foreach (ContentPropertyFile f in uploads) - { - File.Delete(f.TempFilePath); - } + var filepath = ProcessFile(file, editorValue.DataTypeConfiguration, contentKey, propertyTypeKey); // remove current file if replaced - if (currentPath != filepath && string.IsNullOrWhiteSpace(currentPath) == false) + if (currentPath != filepath && currentPath.IsNullOrWhiteSpace() is false) { _mediaFileManager.FileSystem.DeleteFile(currentPath); } - // update json and return - if (editorFile == null) - { - return null; - } + scope.Complete(); - return filepath == null ? string.Empty : _mediaFileManager.FileSystem.GetUrl(filepath); + return filepath == null ? null : _mediaFileManager.FileSystem.GetUrl(filepath); } - private string? ProcessFile(ContentPropertyFile file, object? dataTypeConfiguration, Guid cuid, Guid puid) + private Guid? TryParseTemporaryFileKey(object? editorValue) + => editorValue is string stringValue && Guid.TryParse(stringValue, out Guid temporaryFileKey) + ? temporaryFileKey + : null; + + private TemporaryFileModel? TryGetTemporaryFile(Guid temporaryFileKey) + => _temporaryFileService.GetAsync(temporaryFileKey).GetAwaiter().GetResult(); + + private bool IsAllowedInDataTypeConfiguration(string extension, object? dataTypeConfiguration) + { + if (dataTypeConfiguration is FileUploadConfiguration fileUploadConfiguration) + { + // If FileExtensions is empty and no allowed extensions have been specified, we allow everything. + // If there are any extensions specified, we need to check that the uploaded extension is one of them. + return fileUploadConfiguration.FileExtensions.IsCollectionEmpty() || + fileUploadConfiguration.FileExtensions.Any(x => x.Value?.InvariantEquals(extension) ?? false); + } + + return false; + } + + private string? ProcessFile(TemporaryFileModel file, object? dataTypeConfiguration, Guid contentKey, Guid propertyTypeKey) { // process the file // no file, invalid file, reject change - if (UploadFileTypeValidator.IsValidFileExtension(file.FileName, _contentSettings) is false || - UploadFileTypeValidator.IsAllowedInDataTypeConfiguration(file.FileName, dataTypeConfiguration) is false) + // this check is somewhat redundant as the file validity has already been checked by TemporaryFileUploadValidator, + // but we'll retain it here as a last measure in case someone accidentally breaks the validator + var extension = Path.GetExtension(file.FileName).TrimStart('.'); + if (_contentSettings.IsFileAllowedForUpload(extension) is false || + IsAllowedInDataTypeConfiguration(extension, dataTypeConfiguration) is false) { return null; } // get the filepath // in case we are using the old path scheme, try to re-use numbers (bah...) - var filepath = _mediaFileManager.GetMediaPath(file.FileName, cuid, puid); // fs-relative path + var filepath = _mediaFileManager.GetMediaPath(file.FileName, contentKey, propertyTypeKey); // fs-relative path - using (FileStream filestream = File.OpenRead(file.TempFilePath)) + using (Stream filestream = file.OpenReadStream()) { // TODO: Here it would make sense to do the auto-fill properties stuff but the API doesn't allow us to do that right // since we'd need to be able to return values for other properties from these methods diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs index c668e6c3fb..a163c485dd 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ImageCropperPropertyValueEditor.cs @@ -8,12 +8,13 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Models.TemporaryFile; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; -using File = System.IO.File; namespace Umbraco.Cms.Core.PropertyEditors; @@ -27,6 +28,8 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v private readonly MediaFileManager _mediaFileManager; private readonly IJsonSerializer _jsonSerializer; private ContentSettings _contentSettings; + private readonly ITemporaryFileService _temporaryFileService; + private readonly IScopeProvider _scopeProvider; public ImageCropperPropertyValueEditor( DataEditorAttribute attribute, @@ -37,7 +40,9 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v IOptionsMonitor contentSettings, IJsonSerializer jsonSerializer, IIOHelper ioHelper, - IDataTypeService dataTypeService) + IDataTypeService dataTypeService, + ITemporaryFileService temporaryFileService, + IScopeProvider scopeProvider) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -45,7 +50,11 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v _jsonSerializer = jsonSerializer; _contentSettings = contentSettings.CurrentValue; _dataTypeService = dataTypeService; + _temporaryFileService = temporaryFileService; + _scopeProvider = scopeProvider; contentSettings.OnChange(x => _contentSettings = x); + + Validators.Add(new TemporaryFileUploadValidator(() => _contentSettings, TryParseTemporaryFileKey, TryGetTemporaryFile)); } /// @@ -88,8 +97,10 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v /// /// The is used to re-use the folder, if possible. /// - /// editorValue.Value is used to figure out editorFile and, if it has been cleared, remove the old file - but - /// it is editorValue.AdditionalData["files"] that is used to determine the actual file that has been uploaded. + /// editorValue.Value is used to figure out editorFile and, if it has been cleared, remove the old file. + /// If editorValue.Value deserializes as and the + /// value is a GUID, it is assumed to contain a temporary file key, and we will attempt to replace the currently + /// selected file with the corresponding temporary file. /// /// public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) @@ -115,46 +126,30 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v currentPath = _mediaFileManager.FileSystem.GetRelativePath(currentPath); } - ImageCropperValue? editorImageCropperValue = null; - - // FIXME: consider creating an object deserialization method on IJsonSerializer instead of relying on deserializing serialized JSON here (and likely other places as well) - if (editorValue.Value is JsonObject jsonObject) - { - try - { - editorImageCropperValue = _jsonSerializer.Deserialize(jsonObject.ToJsonString()); - editorImageCropperValue?.Prune(); - } - catch (Exception ex) - { - // For some reason the value is invalid - log error and continue as if no value was saved - _logger.LogWarning(ex, "Could not parse editor value to an ImageCropperValue object."); - } - } + ImageCropperValue? editorImageCropperValue = TryParseImageCropperValue(editorValue.Value); // ensure we have the required guids - Guid cuid = editorValue.ContentKey; - if (cuid == Guid.Empty) + Guid contentKey = editorValue.ContentKey; + if (contentKey == Guid.Empty) { throw new Exception("Invalid content key."); } - Guid puid = editorValue.PropertyTypeKey; - if (puid == Guid.Empty) + Guid propertyTypeKey = editorValue.PropertyTypeKey; + if (propertyTypeKey == Guid.Empty) { throw new Exception("Invalid property type key."); } - // editorFile is empty whenever a new file is being uploaded - // or when the file is cleared (in which case editorJson is null) - // else editorFile contains the unchanged value - ContentPropertyFile[]? uploads = editorValue.Files; - if (uploads == null) - { - throw new Exception("Invalid files."); - } + using IScope scope = _scopeProvider.CreateScope(); - ContentPropertyFile? file = uploads.Length > 0 ? uploads[0] : null; + TemporaryFileModel? file = null; + Guid? temporaryFileKey = TryParseTemporaryFileKey(editorImageCropperValue); + if (temporaryFileKey.HasValue) + { + file = TryGetTemporaryFile(temporaryFileKey.Value); + _temporaryFileService.EnlistDeleteIfScopeCompletes(temporaryFileKey.Value, _scopeProvider); + } if (file == null) // not uploading a file { @@ -171,13 +166,7 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v } // process the file - var filepath = editorImageCropperValue == null ? null : ProcessFile(file, cuid, puid); - - // remove all temp files - foreach (ContentPropertyFile f in uploads) - { - File.Delete(f.TempFilePath); - } + var filepath = editorImageCropperValue == null ? null : ProcessFile(file, contentKey, propertyTypeKey); // remove current file if replaced if (currentPath != filepath && string.IsNullOrWhiteSpace(currentPath) == false) @@ -185,6 +174,8 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v _mediaFileManager.FileSystem.DeleteFile(currentPath); } + scope.Complete(); + // update json and return if (editorImageCropperValue == null) { @@ -217,20 +208,60 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v return _jsonSerializer.Serialize(new { src = val, crops }); } - private string? ProcessFile(ContentPropertyFile file, Guid cuid, Guid puid) + private ImageCropperValue? TryParseImageCropperValue(object? editorValue) + { + // FIXME: consider creating an object deserialization method on IJsonSerializer instead of relying on deserializing serialized JSON here (and likely other places as well) + if (editorValue is JsonObject jsonObject) + { + try + { + ImageCropperValue? imageCropperValue = _jsonSerializer.Deserialize(jsonObject.ToJsonString()); + imageCropperValue?.Prune(); + return imageCropperValue; + } + catch (Exception ex) + { + // For some reason the value is invalid - log error and continue as if no value was saved + _logger.LogWarning(ex, "Could not parse editor value to an ImageCropperValue object."); + } + } + + return null; + } + + private Guid? TryParseTemporaryFileKey(object? editorValue) + { + ImageCropperValue? imageCropperValue = TryParseImageCropperValue(editorValue); + return imageCropperValue != null + ? TryParseTemporaryFileKey(imageCropperValue) + : null; + } + + private Guid? TryParseTemporaryFileKey(ImageCropperValue? editorValue) + => Guid.TryParse(editorValue?.Src, out Guid temporaryFileKey) + ? temporaryFileKey + : null; + + private TemporaryFileModel? TryGetTemporaryFile(Guid temporaryFileKey) + => _temporaryFileService.GetAsync(temporaryFileKey).GetAwaiter().GetResult(); + + private string? ProcessFile(TemporaryFileModel file, Guid contentKey, Guid propertyTypeKey) { // process the file // no file, invalid file, reject change - if (UploadFileTypeValidator.IsValidFileExtension(file.FileName, _contentSettings) == false) + // this check is somewhat redundant as the file validity has already been checked by TemporaryFileUploadValidator, + // but we'll retain it here as a last measure in case someone accidentally breaks the validator + var extension = Path.GetExtension(file.FileName).TrimStart('.'); + if (_contentSettings.IsFileAllowedForUpload(extension) is false) { return null; } // get the filepath // in case we are using the old path scheme, try to re-use numbers (bah...) - var filepath = _mediaFileManager.GetMediaPath(file.FileName, cuid, puid); // fs-relative path + var filepath = _mediaFileManager.GetMediaPath(file.FileName, contentKey, propertyTypeKey); // fs-relative path - using (FileStream filestream = File.OpenRead(file.TempFilePath)) + using (Stream filestream = file.OpenReadStream()) { // TODO: Here it would make sense to do the auto-fill properties stuff but the API doesn't allow us to do that right // since we'd need to be able to return values for other properties from these methods diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index b4ab07d8b0..146e4b8949 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -4,10 +4,13 @@ using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Editors; +using Umbraco.Cms.Core.Models.TemporaryFile; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; @@ -71,8 +74,11 @@ public class MediaPicker3PropertyEditor : DataEditor { private readonly IDataTypeService _dataTypeService; private readonly IJsonSerializer _jsonSerializer; - private readonly ITemporaryMediaService _temporaryMediaService; - + private readonly IMediaImportService _mediaImportService; + private readonly IMediaService _mediaService; + private readonly ITemporaryFileService _temporaryFileService; + private readonly IScopeProvider _scopeProvider; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; public MediaPicker3PropertyValueEditor( ILocalizedTextService localizedTextService, @@ -81,12 +87,20 @@ public class MediaPicker3PropertyEditor : DataEditor IIOHelper ioHelper, DataEditorAttribute attribute, IDataTypeService dataTypeService, - ITemporaryMediaService temporaryMediaService) + IMediaImportService mediaImportService, + IMediaService mediaService, + ITemporaryFileService temporaryFileService, + IScopeProvider scopeProvider, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _jsonSerializer = jsonSerializer; _dataTypeService = dataTypeService; - _temporaryMediaService = temporaryMediaService; + _mediaImportService = mediaImportService; + _mediaService = mediaService; + _temporaryFileService = temporaryFileService; + _scopeProvider = scopeProvider; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; } /// @@ -106,6 +120,7 @@ public class MediaPicker3PropertyEditor : DataEditor var value = property.GetValue(culture, segment); var dtos = Deserialize(_jsonSerializer, value).ToList(); + dtos = UpdateMediaTypeAliases(dtos); IDataType? dataType = _dataTypeService.GetDataType(property.PropertyType.DataTypeId); if (dataType?.ConfigurationObject != null) @@ -137,7 +152,8 @@ public class MediaPicker3PropertyEditor : DataEditor if (editorValue.DataTypeConfiguration is MediaPicker3Configuration configuration) { - // FIXME: handle temp files here once we implement file uploads (see old implementation "PersistTempMedia" in the commented out code below) + // handle temporary media uploads + mediaWithCropsDtos = HandleTemporaryMediaUploads(mediaWithCropsDtos, configuration); } foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos) @@ -188,48 +204,63 @@ public class MediaPicker3PropertyEditor : DataEditor } } - // private JArray PersistTempMedia(JArray jArray, MediaPicker3Configuration mediaPicker3Configuration) - // { - // var result = new JArray(); - // foreach (JObject? dto in jArray.Values()) - // { - // if (dto is null) - // { - // continue; - // } - // - // if (!dto.TryGetValue("tmpLocation", out JToken? temporaryLocation)) - // { - // // If it does not have a temporary path, it can be an already saved image or not-yet uploaded temp-image, check for media-key - // if (dto.TryGetValue("mediaKey", out _)) - // { - // result.Add(dto); - // } - // - // continue; - // } - // - // var temporaryLocationString = temporaryLocation.Value(); - // if (temporaryLocationString is null) - // { - // continue; - // } - // - // GuidUdi? startNodeGuid = mediaPicker3Configuration.StartNodeId as GuidUdi ?? null; - // JToken? mediaTypeAlias = dto.GetValue("mediaTypeAlias"); - // IMedia mediaFile = _temporaryMediaService.Save(temporaryLocationString, startNodeGuid?.Guid, mediaTypeAlias?.Value()); - // MediaWithCropsDto? mediaDto = _jsonSerializer.Deserialize(dto.ToString()); - // if (mediaDto is null) - // { - // continue; - // } - // - // mediaDto.MediaKey = mediaFile.GetUdi().Guid; - // result.Add(JObject.Parse(_jsonSerializer.Serialize(mediaDto))); - // } - // - // return result; - // } + private List UpdateMediaTypeAliases(List mediaWithCropsDtos) + { + const string unknownMediaType = "UNKNOWN"; + + foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos) + { + IMedia? media = _mediaService.GetById(mediaWithCropsDto.MediaKey); + mediaWithCropsDto.MediaTypeAlias = media?.ContentType.Alias ?? unknownMediaType; + } + + return mediaWithCropsDtos.Where(m => m.MediaTypeAlias != unknownMediaType).ToList(); + } + + private List HandleTemporaryMediaUploads(List mediaWithCropsDtos, MediaPicker3Configuration configuration) + { + Guid userKey = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key + ?? throw new InvalidOperationException("Could not obtain the current backoffice user"); + + var invalidDtos = new List(); + + foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos) + { + // if the media already exist, don't bother with it + if (_mediaService.GetById(mediaWithCropsDto.MediaKey) != null) + { + continue; + } + + // we'll assume that the media key is the key of a temporary file + TemporaryFileModel? temporaryFile = _temporaryFileService.GetAsync(mediaWithCropsDto.MediaKey).GetAwaiter().GetResult(); + if (temporaryFile == null) + { + // the temporary file is missing, don't process this item any further + invalidDtos.Add(mediaWithCropsDto); + continue; + } + + GuidUdi? startNodeGuid = configuration.StartNodeId as GuidUdi ?? null; + + // make sure we'll clean up the temporary file if the scope completes + using IScope scope = _scopeProvider.CreateScope(); + _temporaryFileService.EnlistDeleteIfScopeCompletes(temporaryFile.Key, _scopeProvider); + + // create a new media using the temporary file - the media type is passed from the client, in case + // there are multiple allowed media types matching the file extension + using Stream fileStream = temporaryFile.OpenReadStream(); + IMedia mediaFile = _mediaImportService + .ImportAsync(temporaryFile.FileName, fileStream, startNodeGuid?.Guid, mediaWithCropsDto.MediaTypeAlias, userKey) + .GetAwaiter() + .GetResult(); + + mediaWithCropsDto.MediaKey = mediaFile.Key; + scope.Complete(); + } + + return mediaWithCropsDtos.Except(invalidDtos).ToList(); + } /// /// Model/DTO that represents the JSON that the MediaPicker3 stores. @@ -240,6 +271,8 @@ public class MediaPicker3PropertyEditor : DataEditor public Guid MediaKey { get; set; } + public string MediaTypeAlias { get; set; } = string.Empty; + public IEnumerable? Crops { get; set; } public ImageCropperValue.ImageCropperFocalPoint? FocalPoint { get; set; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs index 5044a5b13e..264602897b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs @@ -2,17 +2,22 @@ // See LICENSE for more details. using HtmlAgilityPack; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Models.TemporaryFile; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; @@ -20,17 +25,14 @@ namespace Umbraco.Cms.Core.PropertyEditors; public sealed class RichTextEditorPastedImages { private const string TemporaryImageDataAttribute = "data-tmpimg"; - private readonly IContentTypeBaseServiceProvider _contentTypeBaseServiceProvider; - private readonly IHostingEnvironment _hostingEnvironment; - private readonly ILogger _logger; - private readonly MediaFileManager _mediaFileManager; - private readonly IMediaService _mediaService; - private readonly MediaUrlGeneratorCollection _mediaUrlGenerators; private readonly IPublishedUrlProvider _publishedUrlProvider; - private readonly IShortStringHelper _shortStringHelper; private readonly IUmbracoContextAccessor _umbracoContextAccessor; - private readonly string _tempFolderAbsolutePath; + private readonly ITemporaryFileService _temporaryFileService; + private readonly IScopeProvider _scopeProvider; + private readonly IMediaImportService _mediaImportService; + private readonly IUserService _userService; + [Obsolete("Please use the non-obsolete constructor. Will be removed in V16.")] public RichTextEditorPastedImages( IUmbracoContextAccessor umbracoContextAccessor, ILogger logger, @@ -41,27 +43,71 @@ public sealed class RichTextEditorPastedImages MediaUrlGeneratorCollection mediaUrlGenerators, IShortStringHelper shortStringHelper, IPublishedUrlProvider publishedUrlProvider) + : this( + umbracoContextAccessor, + logger, + hostingEnvironment, + mediaService, + contentTypeBaseServiceProvider, + mediaFileManager, + mediaUrlGenerators, + shortStringHelper, + publishedUrlProvider, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [Obsolete("Please use the non-obsolete constructor. Will be removed in V16.")] + public RichTextEditorPastedImages( + IUmbracoContextAccessor umbracoContextAccessor, + ILogger logger, + IHostingEnvironment hostingEnvironment, + IMediaService mediaService, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + MediaFileManager mediaFileManager, + MediaUrlGeneratorCollection mediaUrlGenerators, + IShortStringHelper shortStringHelper, + IPublishedUrlProvider publishedUrlProvider, + ITemporaryFileService temporaryFileService, + IScopeProvider scopeProvider, + IMediaImportService mediaImportService) + : this(umbracoContextAccessor, publishedUrlProvider, temporaryFileService, scopeProvider, mediaImportService) + { + } + + public RichTextEditorPastedImages( + IUmbracoContextAccessor umbracoContextAccessor, + IPublishedUrlProvider publishedUrlProvider, + ITemporaryFileService temporaryFileService, + IScopeProvider scopeProvider, + IMediaImportService mediaImportService) { _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _hostingEnvironment = hostingEnvironment; - _mediaService = mediaService ?? throw new ArgumentNullException(nameof(mediaService)); - _contentTypeBaseServiceProvider = contentTypeBaseServiceProvider ?? - throw new ArgumentNullException(nameof(contentTypeBaseServiceProvider)); - _mediaFileManager = mediaFileManager; - _mediaUrlGenerators = mediaUrlGenerators; - _shortStringHelper = shortStringHelper; _publishedUrlProvider = publishedUrlProvider; + _temporaryFileService = temporaryFileService; + _scopeProvider = scopeProvider; + _mediaImportService = mediaImportService; - _tempFolderAbsolutePath = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempImageUploads); + // this obviously is not correct. however, we only use IUserService in an obsolete method, + // so this is better than having even more obsolete constructors for V16 + _userService = StaticServiceProvider.Instance.GetRequiredService(); + } + [Obsolete($"Please use {nameof(FindAndPersistPastedTempImagesAsync)}. Will be removed in V16.")] + public string FindAndPersistPastedTempImages(string html, Guid mediaParentFolder, int userId, IImageUrlGenerator imageUrlGenerator) + { + IUser user = _userService.GetUserById(userId) + ?? throw new ArgumentException($"Could not find a user with the specified user key ({userId})", nameof(userId)); + return FindAndPersistPastedTempImagesAsync(html, mediaParentFolder, user.Key, imageUrlGenerator).GetAwaiter().GetResult(); } /// /// Used by the RTE (and grid RTE) for drag/drop/persisting images /// - public string FindAndPersistPastedTempImages(string html, Guid mediaParentFolder, int userId, IImageUrlGenerator imageUrlGenerator) + public async Task FindAndPersistPastedTempImagesAsync(string html, Guid mediaParentFolder, Guid userKey, IImageUrlGenerator imageUrlGenerator) { // Find all img's that has data-tmpimg attribute // Use HTML Agility Pack - https://html-agility-pack.net @@ -76,66 +122,43 @@ public sealed class RichTextEditorPastedImages // An array to contain a list of URLs that // we have already processed to avoid dupes - var uploadedImages = new Dictionary(); - + var uploadedImages = new Dictionary(); foreach (HtmlNode? img in tmpImages) { - // The data attribute contains the path to the tmp img to persist as a media item - var tmpImgPath = img.GetAttributeValue(TemporaryImageDataAttribute, string.Empty); - - if (string.IsNullOrEmpty(tmpImgPath)) + // The data attribute contains the key of the temporary file + var tmpImgKey = img.GetAttributeValue(TemporaryImageDataAttribute, string.Empty); + if (Guid.TryParse(tmpImgKey, out Guid temporaryFileKey) is false) { continue; } - - var absoluteTempImagePath = Path.GetFullPath(_hostingEnvironment.MapPathContentRoot(tmpImgPath)); - - if (IsValidPath(absoluteTempImagePath) == false) + TemporaryFileModel? temporaryFile = _temporaryFileService.GetAsync(temporaryFileKey).GetAwaiter().GetResult(); + if (temporaryFile is null) { continue; } - var fileName = Path.GetFileName(absoluteTempImagePath); - var safeFileName = fileName.ToSafeFileName(_shortStringHelper); - - var mediaItemName = safeFileName.ToFriendlyName(); - IMedia mediaFile; GuidUdi udi; - if (uploadedImages.ContainsKey(tmpImgPath) == false) + using (IScope scope = _scopeProvider.CreateScope()) { - if (mediaParentFolder == Guid.Empty) + _temporaryFileService.EnlistDeleteIfScopeCompletes(temporaryFileKey, _scopeProvider); + + if (uploadedImages.ContainsKey(temporaryFileKey) == false) { - mediaFile = _mediaService.CreateMedia(mediaItemName, Constants.System.Root, Constants.Conventions.MediaTypes.Image, userId); + using Stream fileStream = temporaryFile.OpenReadStream(); + Guid? parentFolderKey = mediaParentFolder == Guid.Empty ? Constants.System.RootKey : mediaParentFolder; + IMedia mediaFile = await _mediaImportService.ImportAsync(temporaryFile.FileName, fileStream, parentFolderKey, Constants.Conventions.MediaTypes.Image, userKey); + udi = mediaFile.GetUdi(); } else { - mediaFile = _mediaService.CreateMedia(mediaItemName, mediaParentFolder, Constants.Conventions.MediaTypes.Image, userId); + // Already been uploaded & we have it's UDI + udi = uploadedImages[temporaryFileKey]; } - var fileInfo = new FileInfo(absoluteTempImagePath); - - FileStream? fileStream = fileInfo.OpenReadWithRetry(); - if (fileStream == null) - { - throw new InvalidOperationException("Could not acquire file stream"); - } - - using (fileStream) - { - mediaFile.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper, _contentTypeBaseServiceProvider, Constants.Conventions.Media.File, safeFileName, fileStream); - } - - _mediaService.Save(mediaFile, userId); - - udi = mediaFile.GetUdi(); - } - else - { - // Already been uploaded & we have it's UDI - udi = uploadedImages[tmpImgPath]; + scope.Complete(); } // Add the UDI to the img element as new data attribute @@ -172,33 +195,9 @@ public sealed class RichTextEditorPastedImages img.Attributes.Remove(TemporaryImageDataAttribute); // Add to the dictionary to avoid dupes - if (uploadedImages.ContainsKey(tmpImgPath) == false) - { - uploadedImages.Add(tmpImgPath, udi); - - // Delete folder & image now its saved in media - // The folder should contain one image - as a unique guid folder created - // for each image uploaded from TinyMceController - var folderName = Path.GetDirectoryName(absoluteTempImagePath); - try - { - if (folderName is not null) - { - Directory.Delete(folderName, true); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not delete temp file or folder {FileName}", absoluteTempImagePath); - } - } + uploadedImages.TryAdd(temporaryFileKey, udi); } return htmlDoc.DocumentNode.OuterHtml; } - - private bool IsValidPath(string imagePath) - { - return imagePath.StartsWith(_tempFolderAbsolutePath); - } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index fee03896f9..207ea92b16 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -281,8 +281,8 @@ public class RichTextPropertyEditor : DataEditor return null; } - var userId = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? - Constants.Security.SuperUserId; + Guid userKey = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Key ?? + Constants.Security.SuperUserKey; var config = editorValue.DataTypeConfiguration as RichTextConfiguration; GuidUdi? mediaParent = config?.MediaParentId; @@ -293,8 +293,10 @@ public class RichTextPropertyEditor : DataEditor return null; } - var parseAndSavedTempImages = - _pastedImages.FindAndPersistPastedTempImages(editorValue.Value.ToString()!, mediaParentId, userId, _imageUrlGenerator); + var parseAndSavedTempImages = _pastedImages + .FindAndPersistPastedTempImagesAsync(editorValue.Value.ToString()!, mediaParentId, userKey, _imageUrlGenerator) + .GetAwaiter() + .GetResult(); var editorValueWithMediaUrlsRemoved = _imageSourceParser.RemoveImageSources(parseAndSavedTempImages); var parsed = MacroTagParser.FormatRichTextContentForPersistence(editorValueWithMediaUrlsRemoved); var sanitized = _htmlSanitizer.Sanitize(parsed); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TemporaryFileUploadValidator.cs b/src/Umbraco.Infrastructure/PropertyEditors/TemporaryFileUploadValidator.cs new file mode 100644 index 0000000000..4aa7d1a211 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/TemporaryFileUploadValidator.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models.TemporaryFile; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors; + +internal class TemporaryFileUploadValidator : IValueValidator +{ + private readonly GetContentSettings _getContentSettings; + private readonly ParseTemporaryFileKey _parseTemporaryFileKey; + private readonly GetTemporaryFileModel _getTemporaryFileModel; + private readonly ValidateFileType? _validateFileType; + + internal delegate ContentSettings GetContentSettings(); + + internal delegate Guid? ParseTemporaryFileKey(object? editorValue); + + internal delegate TemporaryFileModel? GetTemporaryFileModel(Guid temporaryFileKey); + + internal delegate bool ValidateFileType(string extension, object? dataTypeConfiguration); + + public TemporaryFileUploadValidator( + GetContentSettings getContentSettings, + ParseTemporaryFileKey parseTemporaryFileKey, + GetTemporaryFileModel getTemporaryFileModel, + ValidateFileType? validateFileType = null) + { + _getContentSettings = getContentSettings; + _parseTemporaryFileKey = parseTemporaryFileKey; + _getTemporaryFileModel = getTemporaryFileModel; + _validateFileType = validateFileType; + } + + public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration) + { + Guid? temporaryFileKey = _parseTemporaryFileKey(value); + if (temporaryFileKey.HasValue == false) + { + yield break; + } + + TemporaryFileModel? temporaryFile = _getTemporaryFileModel(temporaryFileKey.Value); + if (temporaryFile == null) + { + yield return new ValidationResult( + $"No temporary file was found for the key: {temporaryFileKey.Value}", + new[] { "value" }); + } + else + { + var extension = Path.GetExtension(temporaryFile.FileName).TrimStart('.'); + if (extension.IsNullOrWhiteSpace()) + { + yield break; + } + + ContentSettings contentSettings = _getContentSettings(); + if (contentSettings.IsFileAllowedForUpload(extension) || (_validateFileType != null && _validateFileType(extension, dataTypeConfiguration) == false)) + { + yield return new ValidationResult( + $"The file type for file name \"{temporaryFile.FileName}\" is not valid for upload", + new[] { "value" }); + } + } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/UploadFileTypeValidator.cs b/src/Umbraco.Infrastructure/PropertyEditors/UploadFileTypeValidator.cs deleted file mode 100644 index 847ac9cff5..0000000000 --- a/src/Umbraco.Infrastructure/PropertyEditors/UploadFileTypeValidator.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Umbraco. -// See LICENSE for more details. - -using System.ComponentModel.DataAnnotations; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Extensions.Options; -using Newtonsoft.Json.Linq; -using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Services; -using Umbraco.Extensions; - -namespace Umbraco.Cms.Core.PropertyEditors; - -internal class UploadFileTypeValidator : IValueValidator -{ - private readonly ILocalizedTextService _localizedTextService; - private ContentSettings _contentSettings; - - public UploadFileTypeValidator( - ILocalizedTextService localizedTextService, - IOptionsMonitor contentSettings) - { - _localizedTextService = localizedTextService; - _contentSettings = contentSettings.CurrentValue; - - contentSettings.OnChange(x => _contentSettings = x); - } - - public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration) - { - string? selectedFiles = null; - if (value is JObject jobject && jobject["selectedFiles"] is JToken jToken) - { - selectedFiles = jToken.ToString(); - } - else if (valueType?.InvariantEquals(ValueTypes.String) == true) - { - selectedFiles = value as string; - - if (string.IsNullOrWhiteSpace(selectedFiles)) - { - yield break; - } - } - - var fileNames = selectedFiles?.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); - - if (fileNames == null || !fileNames.Any()) - { - yield break; - } - - foreach (var filename in fileNames) - { - if (IsValidFileExtension(filename, _contentSettings) is false || - IsAllowedInDataTypeConfiguration(filename, dataTypeConfiguration) is false) - { - // we only store a single value for this editor so the 'member' or 'field' - // we'll associate this error with will simply be called 'value' - yield return new ValidationResult( - _localizedTextService.Localize("errors", "dissallowedMediaType"), - new[] { "value" }); - } - } - } - - internal static bool IsValidFileExtension(string? fileName, ContentSettings contentSettings) - { - if (TryGetFileExtension(fileName, out var extension) is false) - { - return false; - } - - return contentSettings.IsFileAllowedForUpload(extension); - } - - internal static bool IsAllowedInDataTypeConfiguration(string? filename, object? dataTypeConfiguration) - { - if (TryGetFileExtension(filename, out var extension) is false) - { - return false; - } - - if (dataTypeConfiguration is FileUploadConfiguration fileUploadConfiguration) - { - // If FileExtensions is empty and no allowed extensions have been specified, we allow everything. - // If there are any extensions specified, we need to check that the uploaded extension is one of them. - return fileUploadConfiguration.FileExtensions.IsCollectionEmpty() || - fileUploadConfiguration.FileExtensions.Any(x => x.Value?.InvariantEquals(extension) ?? false); - } - - return false; - } - - internal static bool TryGetFileExtension(string? fileName, [MaybeNullWhen(false)] out string extension) - { - extension = null; - if (fileName is null || fileName.IndexOf('.') <= 0) - { - return false; - } - - extension = fileName.GetFileExtension().TrimStart("."); - return true; - } -} diff --git a/src/Umbraco.Infrastructure/Serialization/JsonObjectConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonObjectConverter.cs index 502450d714..2ec118dc97 100644 --- a/src/Umbraco.Infrastructure/Serialization/JsonObjectConverter.cs +++ b/src/Umbraco.Infrastructure/Serialization/JsonObjectConverter.cs @@ -68,7 +68,7 @@ public class JsonObjectConverter : JsonConverter return items.ToList(); } - if (firstType == typeof(JsonNode)) + if (firstType.IsAssignableTo(typeof(JsonNode))) { // if we only have JSON nodes in the items collection, return them in a JSON array return new JsonArray(items.OfType().ToArray()); diff --git a/src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml b/src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml index e60e5c2b68..6802ead0ff 100644 --- a/src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml +++ b/src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml @@ -1,6 +1,13 @@  + + CP0001 + T:Umbraco.Cms.Web.BackOffice.Filters.FileUploadCleanupFilterAttribute + lib/net7.0/Umbraco.Web.BackOffice.dll + lib/net7.0/Umbraco.Web.BackOffice.dll + true + CP0002 M:Umbraco.Cms.Web.BackOffice.Controllers.BackOfficeController.#ctor(Umbraco.Cms.Core.Security.IBackOfficeUserManager,Umbraco.Cms.Core.Services.IRuntimeState,Umbraco.Cms.Core.WebAssets.IRuntimeMinifier,Microsoft.Extensions.Options.IOptionsSnapshot{Umbraco.Cms.Core.Configuration.Models.GlobalSettings},Umbraco.Cms.Core.Hosting.IHostingEnvironment,Umbraco.Cms.Core.Services.ILocalizedTextService,Umbraco.Cms.Core.Configuration.Grid.IGridConfig,Umbraco.Cms.Web.BackOffice.Controllers.BackOfficeServerVariables,Umbraco.Cms.Core.Cache.AppCaches,Umbraco.Cms.Web.BackOffice.Security.IBackOfficeSignInManager,Umbraco.Cms.Core.Security.IBackOfficeSecurityAccessor,Microsoft.Extensions.Logging.ILogger{Umbraco.Cms.Web.BackOffice.Controllers.BackOfficeController},Umbraco.Cms.Core.Serialization.IJsonSerializer,Umbraco.Cms.Web.BackOffice.Security.IBackOfficeExternalLoginProviders,Microsoft.AspNetCore.Http.IHttpContextAccessor,Umbraco.Cms.Web.BackOffice.Security.IBackOfficeTwoFactorOptions,Umbraco.Cms.Core.Manifest.IManifestParser,Umbraco.Cms.Infrastructure.WebAssets.ServerVariablesParser,Microsoft.Extensions.Options.IOptions{Umbraco.Cms.Core.Configuration.Models.SecuritySettings}) @@ -15,6 +22,13 @@ lib/net7.0/Umbraco.Web.BackOffice.dll true + + CP0002 + M:Umbraco.Cms.Web.BackOffice.Controllers.MediaController.PostAddFile(System.String,System.String,System.String,System.Collections.Generic.List{Microsoft.AspNetCore.Http.IFormFile}) + lib/net7.0/Umbraco.Web.BackOffice.dll + lib/net7.0/Umbraco.Web.BackOffice.dll + true + CP0002 M:Umbraco.Cms.Web.BackOffice.Controllers.TemplateController.#ctor(Umbraco.Cms.Core.Services.IFileService,Umbraco.Cms.Core.Mapping.IUmbracoMapper,Umbraco.Cms.Core.Strings.IShortStringHelper,Umbraco.Cms.Core.IO.IDefaultViewContentProvider) diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs index 2ded8b6b17..6f3cc8ebea 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs @@ -832,7 +832,6 @@ public class ContentController : ContentControllerBase /// /// Saves content /// - [FileUploadCleanupFilter] [ContentSaveValidation] public async Task?>?> PostSaveBlueprint( [ModelBinder(typeof(BlueprintItemBinder))] ContentItemSave contentItem) @@ -868,7 +867,6 @@ public class ContentController : ContentControllerBase /// /// Saves content /// - [FileUploadCleanupFilter] [ContentSaveValidation] [OutgoingEditorModelEvent] public async Task?>> PostSave( @@ -888,17 +886,6 @@ public class ContentController : ContentControllerBase Func?> mapToDisplay) where TVariant : ContentVariantDisplay { - // Recent versions of IE/Edge may send in the full client side file path instead of just the file name. - // To ensure similar behavior across all browsers no matter what they do - we strip the FileName property of all - // uploaded files to being *only* the actual file name (as it should be). - if (contentItem.UploadedFiles != null && contentItem.UploadedFiles.Any()) - { - foreach (ContentPropertyFile file in contentItem.UploadedFiles) - { - file.FileName = Path.GetFileName(file.FileName); - } - } - // If we've reached here it means: // * Our model has been bound // * and validated diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs index affd543363..08f02d0674 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs @@ -121,24 +121,11 @@ public abstract class ContentControllerBase : BackOfficeNotificationsController // get the property IProperty property = contentItem.PersistedContent.Properties[propertyDto.Alias]!; - // prepare files, if any matching property and culture - ContentPropertyFile[] files = contentItem.UploadedFiles - .Where(x => x.PropertyAlias == propertyDto.Alias && x.Culture == propertyDto.Culture && - x.Segment == propertyDto.Segment) - .ToArray(); - - foreach (ContentPropertyFile file in files) - { - file.FileName = file.FileName?.ToSafeFileName(ShortStringHelper); - } - - // create the property data for the property editor var data = new ContentPropertyData(propertyDto.Value, propertyDto.DataType?.ConfigurationObject) { ContentKey = contentItem.PersistedContent!.Key, - PropertyTypeKey = property.PropertyType.Key, - Files = files + PropertyTypeKey = property.PropertyType.Key }; // let the editor convert the value that was received, deal with files, etc diff --git a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs index 8875a15339..2cf2392700 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs @@ -49,7 +49,6 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers; [ParameterSwapControllerActionSelector(nameof(GetChildren), "id", typeof(int), typeof(Guid), typeof(Udi))] public class MediaController : ContentControllerBase { - private static readonly Semaphore _postAddFileSemaphore = new(1, 1); private readonly AppCaches _appCaches; private readonly IAuthorizationService _authorizationService; private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; @@ -376,23 +375,11 @@ public class MediaController : ContentControllerBase /// Saves content /// /// - [FileUploadCleanupFilter] [MediaItemSaveValidation] [OutgoingEditorModelEvent] public ActionResult? PostSave( [ModelBinder(typeof(MediaItemBinder))] MediaItemSave contentItem) { - //Recent versions of IE/Edge may send in the full client side file path instead of just the file name. - //To ensure similar behavior across all browsers no matter what they do - we strip the FileName property of all - //uploaded files to being *only* the actual file name (as it should be). - if (contentItem.UploadedFiles != null && contentItem.UploadedFiles.Any()) - { - foreach (ContentPropertyFile file in contentItem.UploadedFiles) - { - file.FileName = Path.GetFileName(file.FileName); - } - } - //If we've reached here it means: // * Our model has been bound // * and validated @@ -565,265 +552,6 @@ public class MediaController : ContentControllerBase return _umbracoMapper.Map(f); } - /// - /// Used to submit a media file - /// - /// - /// - /// We cannot validate this request with attributes (nicely) due to the nature of the multi-part for data. - /// - public async Task PostAddFile([FromForm] string path, [FromForm] string currentFolder, - [FromForm] string contentTypeAlias, List file) - { - await _postAddFileSemaphore.WaitOneAsync(); - var root = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); - //ensure it exists - Directory.CreateDirectory(root); - - //must have a file - if (file.Count == 0) - { - _postAddFileSemaphore.Release(); - return NotFound(); - } - - //get the string json from the request - ActionResult? parentIdResult = await GetParentIdAsIntAsync(currentFolder, true); - if (!(parentIdResult?.Result is null)) - { - _postAddFileSemaphore.Release(); - return parentIdResult.Result; - } - - var parentId = parentIdResult?.Value; - if (!parentId.HasValue) - { - _postAddFileSemaphore.Release(); - return NotFound("The passed id doesn't exist"); - } - - var tempFiles = new PostedFiles(); - - //in case we pass a path with a folder in it, we will create it and upload media to it. - if (!string.IsNullOrEmpty(path)) - { - if (!IsFolderCreationAllowedHere(parentId.Value)) - { - AddCancelMessage(tempFiles, _localizedTextService.Localize("speechBubbles", "folderUploadNotAllowed")); - _postAddFileSemaphore.Release(); - return Ok(tempFiles); - } - - var folders = path.Split(Constants.CharArrays.ForwardSlash); - - for (var i = 0; i < folders.Length - 1; i++) - { - var folderName = folders[i]; - IMedia? folderMediaItem; - - //if uploading directly to media root and not a subfolder - if (parentId == Constants.System.Root) - { - //look for matching folder - folderMediaItem = - _mediaService.GetRootMedia()?.FirstOrDefault(x => - x.Name == folderName && x.ContentType.Alias == Constants.Conventions.MediaTypes.Folder); - if (folderMediaItem == null) - { - //if null, create a folder - folderMediaItem = - _mediaService.CreateMedia(folderName, -1, Constants.Conventions.MediaTypes.Folder); - _mediaService.Save(folderMediaItem); - } - } - else - { - //get current parent - IMedia? mediaRoot = _mediaService.GetById(parentId.Value); - - //if the media root is null, something went wrong, we'll abort - if (mediaRoot == null) - { - _postAddFileSemaphore.Release(); - return Problem( - "The folder: " + folderName + " could not be used for storing images, its ID: " + parentId + - " returned null"); - } - - //look for matching folder - folderMediaItem = FindInChildren(mediaRoot.Id, folderName, Constants.Conventions.MediaTypes.Folder); - - if (folderMediaItem == null) - { - //if null, create a folder - folderMediaItem = _mediaService.CreateMedia(folderName, mediaRoot, - Constants.Conventions.MediaTypes.Folder); - _mediaService.Save(folderMediaItem); - } - } - - //set the media root to the folder id so uploaded files will end there. - parentId = folderMediaItem.Id; - } - } - - var mediaTypeAlias = string.Empty; - var allMediaTypes = _mediaTypeService.GetAll().ToList(); - var allowedContentTypes = new HashSet(); - - if (parentId != Constants.System.Root) - { - IMedia? mediaFolderItem = _mediaService.GetById(parentId.Value); - IMediaType? mediaFolderType = - allMediaTypes.FirstOrDefault(x => x.Alias == mediaFolderItem?.ContentType.Alias); - - if (mediaFolderType != null) - { - IMediaType? mediaTypeItem = null; - - if (mediaFolderType.AllowedContentTypes is not null) - { - foreach (ContentTypeSort allowedContentType in mediaFolderType.AllowedContentTypes) - { - IMediaType? checkMediaTypeItem = - allMediaTypes.FirstOrDefault(x => x.Id == allowedContentType.Id.Value); - if (checkMediaTypeItem is not null) - { - allowedContentTypes.Add(checkMediaTypeItem); - } - - IPropertyType? fileProperty = - checkMediaTypeItem?.CompositionPropertyTypes.FirstOrDefault(x => - x.Alias == Constants.Conventions.Media.File); - if (fileProperty != null) - { - mediaTypeItem = checkMediaTypeItem; - } - } - } - - //Only set the permission-based mediaType if we only allow 1 specific file under this parent. - if (allowedContentTypes.Count == 1 && mediaTypeItem != null) - { - mediaTypeAlias = mediaTypeItem.Alias; - } - } - } - else - { - var typesAllowedAtRoot = allMediaTypes.Where(x => x.AllowedAsRoot).ToList(); - allowedContentTypes.UnionWith(typesAllowedAtRoot); - } - - //get the files - foreach (IFormFile formFile in file) - { - var fileName = formFile.FileName.Trim(Constants.CharArrays.DoubleQuote).TrimEnd(); - var safeFileName = fileName.ToSafeFileName(ShortStringHelper); - var ext = safeFileName[(safeFileName.LastIndexOf('.') + 1)..].ToLowerInvariant(); - - if (!_contentSettings.IsFileAllowedForUpload(ext)) - { - tempFiles.Notifications.Add(new BackOfficeNotification( - _localizedTextService.Localize("speechBubbles", "operationFailedHeader"), - _localizedTextService.Localize("media", "disallowedFileType"), - NotificationStyle.Warning)); - continue; - } - - if (string.IsNullOrEmpty(mediaTypeAlias)) - { - mediaTypeAlias = Constants.Conventions.MediaTypes.File; - - if (contentTypeAlias == Constants.Conventions.MediaTypes.AutoSelect) - { - // Look up MediaTypes - foreach (IMediaType mediaTypeItem in allMediaTypes) - { - IPropertyType? fileProperty = - mediaTypeItem.CompositionPropertyTypes.FirstOrDefault(x => - x.Alias == Constants.Conventions.Media.File); - if (fileProperty == null) - { - continue; - } - - Guid dataTypeKey = fileProperty.DataTypeKey; - IDataType? dataType = _dataTypeService.GetDataType(dataTypeKey); - - if (dataType == null || - dataType.ConfigurationObject is not IFileExtensionsConfig fileExtensionsConfig) - { - continue; - } - - List? fileExtensions = fileExtensionsConfig.FileExtensions; - if (fileExtensions == null || fileExtensions.All(x => x.Value != ext)) - { - continue; - } - - mediaTypeAlias = mediaTypeItem.Alias; - break; - } - - // If media type is still File then let's check if it's an image. - if (mediaTypeAlias == Constants.Conventions.MediaTypes.File && - _imageUrlGenerator.IsSupportedImageFormat(ext)) - { - mediaTypeAlias = Constants.Conventions.MediaTypes.Image; - } - } - else - { - mediaTypeAlias = contentTypeAlias; - } - } - - if (allowedContentTypes.Any(x => x.Alias == mediaTypeAlias) == false) - { - tempFiles.Notifications.Add(new BackOfficeNotification( - _localizedTextService.Localize("speechBubbles", "operationFailedHeader"), - _localizedTextService.Localize("media", "disallowedMediaType", new[] { mediaTypeAlias }), - NotificationStyle.Warning)); - continue; - } - - var mediaItemName = fileName.ToFriendlyName(); - - IMedia createdMediaItem = _mediaService.CreateMedia(mediaItemName, parentId.Value, mediaTypeAlias, - _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - - await using (Stream stream = formFile.OpenReadStream()) - { - createdMediaItem.SetValue(_mediaFileManager, _mediaUrlGenerators, _shortStringHelper, - _contentTypeBaseServiceProvider, Constants.Conventions.Media.File, fileName, stream); - } - - Attempt saveResult = _mediaService.Save(createdMediaItem, - _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); - if (saveResult == false) - { - AddCancelMessage(tempFiles, - _localizedTextService.Localize("speechBubbles", "operationCancelledText") + " -- " + mediaItemName); - } - } - - //Different response if this is a 'blueimp' request - if (HttpContext.Request.Query.Any(x => x.Key == "origin")) - { - KeyValuePair origin = HttpContext.Request.Query.First(x => x.Key == "origin"); - if (origin.Value == "blueimp") - { - _postAddFileSemaphore.Release(); - return new JsonResult(tempFiles); //Don't output the angular xsrf stuff, blue imp doesn't like that - } - } - - _postAddFileSemaphore.Release(); - return Ok(tempFiles); - } - private bool IsFolderCreationAllowedHere(int parentId) { var allMediaTypes = _mediaTypeService.GetAll().ToList(); diff --git a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs index d619653740..09d129b2d2 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs @@ -276,7 +276,6 @@ public class MemberController : ContentControllerBase /// /// The content item to save as a member /// The resulting member display object - [FileUploadCleanupFilter] [OutgoingEditorModelEvent] [MemberSaveValidation] public async Task> PostSave([ModelBinder(typeof(MemberBinder))] MemberSave contentItem) diff --git a/src/Umbraco.Web.BackOffice/Filters/FileUploadCleanupFilterAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/FileUploadCleanupFilterAttribute.cs deleted file mode 100644 index 474b1ef581..0000000000 --- a/src/Umbraco.Web.BackOffice/Filters/FileUploadCleanupFilterAttribute.cs +++ /dev/null @@ -1,142 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Logging; -using Umbraco.Cms.Core.Models.ContentEditing; -using Umbraco.Cms.Core.Models.Editors; -using Umbraco.Extensions; - -namespace Umbraco.Cms.Web.BackOffice.Filters; - -/// -/// Checks if the parameter is IHaveUploadedFiles and then deletes any temporary saved files from file uploads -/// associated with the request -/// -public sealed class FileUploadCleanupFilterAttribute : TypeFilterAttribute -{ - /// - /// Constructor specifies if the filter should analyze the incoming or outgoing model - /// - /// - public FileUploadCleanupFilterAttribute(bool incomingModel = true) : base(typeof(FileUploadCleanupFilter)) => - Arguments = new object[] { incomingModel }; - - // We need to use IAsyncActionFilter even that we dont have any async because we need access to - // context.ActionArguments, and this is only available on ActionExecutingContext and not on - // ActionExecutedContext - - private class FileUploadCleanupFilter : IAsyncActionFilter - { - private readonly bool _incomingModel; - private readonly ILogger _logger; - - public FileUploadCleanupFilter(ILogger logger, bool incomingModel) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _incomingModel = incomingModel; - } - - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - ActionExecutedContext resultContext = await next(); // We only to do stuff after the action is executed - - var tempFolders = new List(); - - if (_incomingModel) - { - if (context.ActionArguments.Any()) - { - if (context.ActionArguments.First().Value is IHaveUploadedFiles contentItem) - { - //cleanup any files associated - foreach (ContentPropertyFile f in contentItem.UploadedFiles) - { - //track all temp folders so we can remove old files afterwards - var dir = Path.GetDirectoryName(f.TempFilePath); - if (dir is not null && tempFolders.Contains(dir) == false) - { - tempFolders.Add(dir); - } - - try - { - File.Delete(f.TempFilePath); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not delete temp file {FileName}", f.TempFilePath); - } - } - } - } - } - else - { - if (resultContext == null) - { - _logger.LogWarning("The result context is null."); - return; - } - - if (resultContext.Result == null) - { - _logger.LogWarning("The result context's result is null"); - return; - } - - if (!(resultContext.Result is ObjectResult objectResult)) - { - _logger.LogWarning("Could not acquire the result context's result as ObjectResult"); - return; - } - - if (objectResult.Value is IHaveUploadedFiles uploadedFiles) - { - if (uploadedFiles.UploadedFiles != null) - { - //cleanup any files associated - foreach (ContentPropertyFile f in uploadedFiles.UploadedFiles) - { - if (f.TempFilePath.IsNullOrWhiteSpace() == false) - { - //track all temp folders so we can remove old files afterwards - var dir = Path.GetDirectoryName(f.TempFilePath); - if (dir is not null && tempFolders.Contains(dir) == false) - { - tempFolders.Add(dir); - } - - _logger.LogDebug("Removing temp file {FileName}", f.TempFilePath); - - try - { - File.Delete(f.TempFilePath); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not delete temp file {FileName}", f.TempFilePath); - } - - //clear out the temp path so it's not returned in the response - f.TempFilePath = string.Empty; - } - else - { - _logger.LogWarning("The f.TempFilePath is null or whitespace!!??"); - } - } - } - else - { - _logger.LogWarning("The uploadedFiles.UploadedFiles is null!!??"); - } - } - else - { - _logger.LogWarning( - "The actionExecutedContext.Request.Content.Value is not IHaveUploadedFiles, it is {ObjectType}", - objectResult.Value?.GetType()); - } - } - } - } -} diff --git a/src/Umbraco.Web.BackOffice/ModelBinders/ContentModelBinderHelper.cs b/src/Umbraco.Web.BackOffice/ModelBinders/ContentModelBinderHelper.cs index 3632b42d8e..2904164b12 100644 --- a/src/Umbraco.Web.BackOffice/ModelBinders/ContentModelBinderHelper.cs +++ b/src/Umbraco.Web.BackOffice/ModelBinders/ContentModelBinderHelper.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Models.ContentEditing; -using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.Serialization; using Umbraco.Extensions; @@ -18,7 +17,7 @@ internal class ContentModelBinderHelper IJsonSerializer jsonSerializer, IHostingEnvironment hostingEnvironment, ModelBindingContext bindingContext) - where T : class, IHaveUploadedFiles + where T : class { var modelName = bindingContext.ModelName; @@ -101,15 +100,6 @@ internal class ContentModelBinderHelper { await formFile.CopyToAsync(stream); } - - model.UploadedFiles.Add(new ContentPropertyFile - { - TempFilePath = tempFilePath, - PropertyAlias = propAlias, - Culture = culture, - Segment = segment, - FileName = fileName - }); } return model;