// Copyright (c) Umbraco. // See LICENSE for more details. using Microsoft.Extensions.Options; 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.Security; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Infrastructure.PropertyEditors; using Umbraco.Cms.Infrastructure.PropertyEditors.Validators; using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; /// /// The value editor for the file upload property editor. /// internal sealed class FileUploadPropertyValueEditor : DataValueEditor { private readonly MediaFileManager _mediaFileManager; private readonly ITemporaryFileService _temporaryFileService; private readonly IScopeProvider _scopeProvider; private readonly IFileStreamSecurityValidator _fileStreamSecurityValidator; private readonly FileUploadValueParser _valueParser; private ContentSettings _contentSettings; /// /// Initializes a new instance of the class. /// public FileUploadPropertyValueEditor( DataEditorAttribute attribute, MediaFileManager mediaFileManager, IShortStringHelper shortStringHelper, IOptionsMonitor contentSettings, IJsonSerializer jsonSerializer, IIOHelper ioHelper, ITemporaryFileService temporaryFileService, IScopeProvider scopeProvider, IFileStreamSecurityValidator fileStreamSecurityValidator) : base(shortStringHelper, jsonSerializer, ioHelper, attribute) { _mediaFileManager = mediaFileManager ?? throw new ArgumentNullException(nameof(mediaFileManager)); _temporaryFileService = temporaryFileService; _scopeProvider = scopeProvider; _fileStreamSecurityValidator = fileStreamSecurityValidator; _valueParser = new FileUploadValueParser(jsonSerializer); _contentSettings = contentSettings.CurrentValue ?? throw new ArgumentNullException(nameof(contentSettings)); contentSettings.OnChange(x => _contentSettings = x); Validators.Add(new TemporaryFileUploadValidator( () => _contentSettings, TryParseTemporaryFileKey, TryGetTemporaryFile, IsAllowedInDataTypeConfiguration)); } /// public override IValueRequiredValidator RequiredValidator => new FileUploadValueRequiredValidator(); /// public override object? ToEditor(IProperty property, string? culture = null, string? segment = null) { // the stored property value (if any) is the path to the file; convert it to the client model (FileUploadValue) var propertyValue = property.GetValue(culture, segment); return propertyValue is string stringValue ? new FileUploadValue { Src = stringValue, } : null; } /// /// /// Converts the client model (FileUploadValue) into the value can be stored in the database (the file path). /// /// The value received from the editor. /// The current value of the property /// The converted value. /// /// The is used to re-use the folder, if possible. /// /// The is value passed in from the editor. If the value is empty, we /// must delete the currently selected file (). /// /// public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) { FileUploadValue? editorModelValue = _valueParser.Parse(editorValue.Value); // No change or created from blueprint. if (editorModelValue?.TemporaryFileId.HasValue is not true && string.IsNullOrEmpty(editorModelValue?.Src) is false) { return editorModelValue.Src; } // the current editor value (if any) is the path to the file var currentPath = currentValue is string currentStringValue && currentStringValue.IsNullOrWhiteSpace() is false ? _mediaFileManager.FileSystem.GetRelativePath(currentStringValue) : null; // resetting the current value? if (string.IsNullOrEmpty(editorModelValue?.Src) && currentPath.IsNullOrWhiteSpace() is false) { // delete the current file and clear the value of this property _mediaFileManager.FileSystem.DeleteFile(currentPath); return null; } Guid? temporaryFileKey = editorModelValue?.TemporaryFileId; TemporaryFileModel? file = temporaryFileKey is null ? null : TryGetTemporaryFile(temporaryFileKey.Value); if (file is 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!.Value, _scopeProvider); // ensure we have the required guids Guid contentKey = editorValue.ContentKey; if (contentKey == Guid.Empty) { throw new Exception("Invalid content key."); } Guid propertyTypeKey = editorValue.PropertyTypeKey; if (propertyTypeKey == Guid.Empty) { throw new Exception("Invalid property type key."); } // process the file var filepath = ProcessFile(file, editorValue.DataTypeConfiguration, contentKey, propertyTypeKey); // remove current file if replaced if (currentPath != filepath && currentPath.IsNullOrWhiteSpace() is false) { _mediaFileManager.FileSystem.DeleteFile(currentPath); } scope.Complete(); return filepath is null ? null : _mediaFileManager.FileSystem.GetUrl(filepath); } private Guid? TryParseTemporaryFileKey(object? editorValue) => _valueParser.Parse(editorValue)?.TemporaryFileId; private TemporaryFileModel? TryGetTemporaryFile(Guid temporaryFileKey) => _temporaryFileService.GetAsync(temporaryFileKey).GetAwaiter().GetResult(); private static 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.Any() is false || fileUploadConfiguration.FileExtensions.Contains(extension); } return false; } private string? ProcessFile(TemporaryFileModel file, object? dataTypeConfiguration, Guid contentKey, Guid propertyTypeKey) { // process the file // no file, invalid file, reject change // 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 string filepath = GetMediaPath(file, dataTypeConfiguration, contentKey, propertyTypeKey); using (Stream filestream = file.OpenReadStream()) { if (_fileStreamSecurityValidator.IsConsideredSafe(filestream) == false) { return null; } // 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 _mediaFileManager.FileSystem.AddFile(filepath, filestream, overrideIfExists: true); // must overwrite! } return filepath; } /// /// Provides media path. /// /// File system relative path private string GetMediaPath(TemporaryFileModel file, object? dataTypeConfiguration, Guid contentKey, Guid propertyTypeKey) { // in case we are using the old path scheme, try to re-use numbers (bah...) return _mediaFileManager.GetMediaPath(file.FileName, contentKey, propertyTypeKey); } }