Files
Umbraco-CMS/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs

216 lines
9.1 KiB
C#

// 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.Scoping;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// The value editor for the file upload property editor.
/// </summary>
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;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadPropertyValueEditor"/> class.
/// </summary>
public FileUploadPropertyValueEditor(
DataEditorAttribute attribute,
MediaFileManager mediaFileManager,
IShortStringHelper shortStringHelper,
IOptionsMonitor<ContentSettings> 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));
}
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
/// <summary>
/// Converts the client model (FileUploadValue) into the value can be stored in the database (the file path).
/// </summary>
/// <param name="editorValue">The value received from the editor.</param>
/// <param name="currentValue">The current value of the property</param>
/// <returns>The converted value.</returns>
/// <remarks>
/// <para>The <paramref name="currentValue" /> is used to re-use the folder, if possible.</para>
/// <para>
/// The <paramref name="editorValue" /> is value passed in from the editor. If the value is empty, we
/// must delete the currently selected file (<paramref name="currentValue" />).
/// </para>
/// </remarks>
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;
}
/// <summary>
/// Provides media path.
/// </summary>
/// <returns>File system relative path</returns>
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);
}
}