diff --git a/src/Components/Endpoints/src/FormMapping/BrowserFileFromFormFile.cs b/src/Components/Endpoints/src/FormMapping/BrowserFileFromFormFile.cs new file mode 100644 index 000000000000..f0300d75cc4f --- /dev/null +++ b/src/Components/Endpoints/src/FormMapping/BrowserFileFromFormFile.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping; + +internal sealed class BrowserFileFromFormFile(IFormFile formFile) : IBrowserFile +{ + public string Name => formFile.Name; + + public DateTimeOffset LastModified => DateTimeOffset.Parse(formFile.Headers.LastModified.ToString(), CultureInfo.InvariantCulture); + + public long Size => formFile.Length; + + public string ContentType => formFile.ContentType; + + public Stream OpenReadStream(long maxAllowedSize = 512000, CancellationToken cancellationToken = default) + { + if (Size > maxAllowedSize) + { + throw new IOException($"Supplied file with size {Size} bytes exceeds the maximum of {maxAllowedSize} bytes."); + } + + return formFile.OpenReadStream(); + } +} diff --git a/src/Components/Endpoints/src/FormMapping/Converters/FileConverter.cs b/src/Components/Endpoints/src/FormMapping/Converters/FileConverter.cs new file mode 100644 index 000000000000..fb2d1b74eae5 --- /dev/null +++ b/src/Components/Endpoints/src/FormMapping/Converters/FileConverter.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +#if COMPONENTS +using Microsoft.AspNetCore.Components.Forms; +#endif +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping; + +internal sealed class FileConverter : FormDataConverter +{ + [RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)] + internal override bool TryRead(ref FormDataReader reader, Type type, FormDataMapperOptions options, out T? result, out bool found) + { + if (reader.FormFileCollection == null) + { + result = default; + found = false; + return true; + } + +#if COMPONENTS + if (typeof(T) == typeof(IBrowserFile)) + { + var targetFile = reader.FormFileCollection.GetFile(reader.CurrentPrefix.ToString()); + if (targetFile != null) + { + var browserFile = new BrowserFileFromFormFile(targetFile); + result = (T)(IBrowserFile)browserFile; + found = true; + return true; + } + } + + if (typeof(T) == typeof(IReadOnlyList)) + { + var targetFiles = reader.FormFileCollection.GetFiles(reader.CurrentPrefix.ToString()); + var buffer = ReadOnlyCollectionBufferAdapter.CreateBuffer(); + for (var i = 0; i < targetFiles.Count; i++) + { + buffer = ReadOnlyCollectionBufferAdapter.Add(ref buffer, new BrowserFileFromFormFile(targetFiles[i])); + } + result = (T)(IReadOnlyList)ReadOnlyCollectionBufferAdapter.ToResult(buffer); + found = true; + return true; + } +#endif + + if (typeof(T) == typeof(IReadOnlyList)) + { + result = (T)reader.FormFileCollection.GetFiles(reader.CurrentPrefix.ToString()); + found = true; + return true; + } + + if (typeof(T) == typeof(IFormFileCollection)) + { + result = (T)reader.FormFileCollection; + found = true; + return true; + } + + var formFileCollection = reader.FormFileCollection; + if (formFileCollection.Count == 0) + { + result = default; + found = false; + return true; + } + + var file = formFileCollection.GetFile(reader.CurrentPrefix.ToString()); + if (file != null) + { + result = (T)file; + found = true; + return true; + } + + result = default; + found = false; + return true; + } +} diff --git a/src/Components/Endpoints/src/FormMapping/Factories/FileConverterFactory.cs b/src/Components/Endpoints/src/FormMapping/Factories/FileConverterFactory.cs new file mode 100644 index 000000000000..e5a62480d3d0 --- /dev/null +++ b/src/Components/Endpoints/src/FormMapping/Factories/FileConverterFactory.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +#if COMPONENTS +using Microsoft.AspNetCore.Components.Forms; +#endif +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping; + +internal sealed class FileConverterFactory : IFormDataConverterFactory +{ + [RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)] +#if COMPONENTS + public bool CanConvert(Type type, FormDataMapperOptions options) => CanConvertCommon(type) || type == typeof(IBrowserFile) || type == typeof(IReadOnlyList); +#else + public bool CanConvert(Type type, FormDataMapperOptions options) => CanConvertCommon(type); +#endif + + private static bool CanConvertCommon(Type type) => type == typeof(IFormFile) || type == typeof(IFormFileCollection) || type == typeof(IReadOnlyList); + + [RequiresDynamicCode(FormMappingHelpers.RequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(FormMappingHelpers.RequiresUnreferencedCodeMessage)] + public FormDataConverter CreateConverter(Type type, FormDataMapperOptions options) + { + return Activator.CreateInstance(typeof(FileConverter<>).MakeGenericType(type)) as FormDataConverter ?? + throw new InvalidOperationException($"Unable to create converter for '{type.FullName}'."); + } +} diff --git a/src/Components/Endpoints/src/FormMapping/FormDataMapperOptions.cs b/src/Components/Endpoints/src/FormMapping/FormDataMapperOptions.cs index 25cbc47cf2f0..12e626930a5f 100644 --- a/src/Components/Endpoints/src/FormMapping/FormDataMapperOptions.cs +++ b/src/Components/Endpoints/src/FormMapping/FormDataMapperOptions.cs @@ -26,6 +26,7 @@ public FormDataMapperOptions(ILoggerFactory loggerFactory) { _converters = new(WellKnownConverters.Converters); _factories.Add(new ParsableConverterFactory()); + _factories.Add(new FileConverterFactory()); _factories.Add(new EnumConverterFactory()); _factories.Add(new NullableConverterFactory()); _factories.Add(new DictionaryConverterFactory()); diff --git a/src/Components/Endpoints/src/FormMapping/FormDataReader.cs b/src/Components/Endpoints/src/FormMapping/FormDataReader.cs index 1e062dd6bdba..85c65e363e93 100644 --- a/src/Components/Endpoints/src/FormMapping/FormDataReader.cs +++ b/src/Components/Endpoints/src/FormMapping/FormDataReader.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping; @@ -33,9 +34,17 @@ public FormDataReader(IReadOnlyDictionary formCollection, _prefixBuffer = buffer; } + public FormDataReader(IReadOnlyDictionary formCollection, CultureInfo culture, Memory buffer, IFormFileCollection formFileCollection) + : this(formCollection, culture, buffer) + { + FormFileCollection = formFileCollection; + } + internal ReadOnlyMemory CurrentPrefix => _currentPrefixBuffer; - public IFormatProvider Culture { get; internal set; } + public IFormatProvider Culture { get; } + + public IFormFileCollection? FormFileCollection { get; internal set; } public int MaxRecursionDepth { get; set; } = 64; diff --git a/src/Components/Endpoints/src/FormMapping/HttpContextFormDataProvider.cs b/src/Components/Endpoints/src/FormMapping/HttpContextFormDataProvider.cs index 96193f2d3479..812e1a10a64d 100644 --- a/src/Components/Endpoints/src/FormMapping/HttpContextFormDataProvider.cs +++ b/src/Components/Endpoints/src/FormMapping/HttpContextFormDataProvider.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -11,15 +12,19 @@ internal sealed class HttpContextFormDataProvider { private string? _incomingHandlerName; private IReadOnlyDictionary? _entries; + private IFormFileCollection? _formFiles; public string? IncomingHandlerName => _incomingHandlerName; public IReadOnlyDictionary Entries => _entries ?? ReadOnlyDictionary.Empty; - public void SetFormData(string incomingHandlerName, IReadOnlyDictionary form) + public IFormFileCollection FormFiles => _formFiles ?? (IFormFileCollection)FormCollection.Empty; + + public void SetFormData(string incomingHandlerName, IReadOnlyDictionary form, IFormFileCollection formFiles) { _incomingHandlerName = incomingHandlerName; _entries = form; + _formFiles = formFiles; } public bool TryGetIncomingHandlerName([NotNullWhen(true)] out string? incomingHandlerName) diff --git a/src/Components/Endpoints/src/FormMapping/HttpContextFormValueMapper.cs b/src/Components/Endpoints/src/FormMapping/HttpContextFormValueMapper.cs index cac78e732636..db1e9af301cf 100644 --- a/src/Components/Endpoints/src/FormMapping/HttpContextFormValueMapper.cs +++ b/src/Components/Endpoints/src/FormMapping/HttpContextFormValueMapper.cs @@ -8,6 +8,7 @@ using System.Globalization; using Microsoft.AspNetCore.Components.Endpoints.FormMapping; using Microsoft.AspNetCore.Components.Forms.Mapping; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -85,7 +86,7 @@ public void Map(FormValueMappingContext context) var deserializer = _cache.GetOrAdd(context.ValueType, CreateDeserializer); Debug.Assert(deserializer != null); - deserializer.Deserialize(context, _options, _formData.Entries); + deserializer.Deserialize(context, _options, _formData.Entries, _formData.FormFiles); } private FormValueSupplier CreateDeserializer(Type type) => @@ -99,7 +100,8 @@ internal abstract class FormValueSupplier public abstract void Deserialize( FormValueMappingContext context, FormDataMapperOptions options, - IReadOnlyDictionary form); + IReadOnlyDictionary form, + IFormFileCollection formFiles); } internal class FormValueSupplier : FormValueSupplier @@ -109,7 +111,8 @@ internal class FormValueSupplier : FormValueSupplier public override void Deserialize( FormValueMappingContext context, FormDataMapperOptions options, - IReadOnlyDictionary form) + IReadOnlyDictionary form, + IFormFileCollection formFiles) { if (form.Count == 0) { @@ -129,7 +132,8 @@ public override void Deserialize( using var reader = new FormDataReader( dictionary, CultureInfo.InvariantCulture, - buffer.AsMemory(0, options.MaxKeyBufferSize)) + buffer.AsMemory(0, options.MaxKeyBufferSize), + formFiles) { ErrorHandler = context.OnError, AttachInstanceToErrorsHandler = context.MapErrorToContainer, diff --git a/src/Components/Endpoints/src/FormMapping/Metadata/FormDataMetadataFactory.cs b/src/Components/Endpoints/src/FormMapping/Metadata/FormDataMetadataFactory.cs index bba5501e347d..124cdf5ffd05 100644 --- a/src/Components/Endpoints/src/FormMapping/Metadata/FormDataMetadataFactory.cs +++ b/src/Components/Endpoints/src/FormMapping/Metadata/FormDataMetadataFactory.cs @@ -17,6 +17,7 @@ internal partial class FormDataMetadataFactory(List f private readonly FormMetadataContext _context = new(); private readonly ParsableConverterFactory _parsableFactory = factories.OfType().Single(); private readonly DictionaryConverterFactory _dictionaryFactory = factories.OfType().Single(); + private readonly FileConverterFactory _fileConverterFactory = factories.OfType().Single(); private readonly CollectionConverterFactory _collectionFactory = factories.OfType().Single(); private readonly ILogger _logger = loggerFactory.CreateLogger(); @@ -86,6 +87,12 @@ internal partial class FormDataMetadataFactory(List f return result; } + if (_fileConverterFactory.CanConvert(type, options)) + { + result.Kind = FormDataTypeKind.File; + return result; + } + if (_dictionaryFactory.CanConvert(type, options)) { Log.DictionaryType(_logger, type); diff --git a/src/Components/Endpoints/src/FormMapping/Metadata/FormDataTypeKind.cs b/src/Components/Endpoints/src/FormMapping/Metadata/FormDataTypeKind.cs index 011244a7b25a..2b6a28deb5e0 100644 --- a/src/Components/Endpoints/src/FormMapping/Metadata/FormDataTypeKind.cs +++ b/src/Components/Endpoints/src/FormMapping/Metadata/FormDataTypeKind.cs @@ -6,6 +6,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping.Metadata; internal enum FormDataTypeKind { Primitive, + File, Collection, Dictionary, Object, diff --git a/src/Components/Endpoints/src/FormMapping/WellKnownConverters.cs b/src/Components/Endpoints/src/FormMapping/WellKnownConverters.cs index d5b6139f1a8e..dbf01fc6fef7 100644 --- a/src/Components/Endpoints/src/FormMapping/WellKnownConverters.cs +++ b/src/Components/Endpoints/src/FormMapping/WellKnownConverters.cs @@ -1,6 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#if COMPONENTS +using Microsoft.AspNetCore.Components.Forms; +#endif +using Microsoft.AspNetCore.Http; + namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping; internal static class WellKnownConverters @@ -37,7 +42,14 @@ static WellKnownConverters() { typeof(DateTimeOffset), new ParsableConverter() }, { typeof(TimeSpan), new ParsableConverter() }, { typeof(TimeOnly), new ParsableConverter() }, - { typeof(Guid), new ParsableConverter() } + { typeof(Guid), new ParsableConverter() }, + { typeof(IFormFileCollection), new FileConverter() }, + { typeof(IFormFile), new FileConverter() }, + { typeof(IReadOnlyList), new FileConverter>() }, +#if COMPONENTS + { typeof(IBrowserFile), new FileConverter() }, + { typeof(IReadOnlyList), new FileConverter>() } +#endif }; converters.Add(typeof(char?), new NullableConverter((FormDataConverter)converters[typeof(char)])); diff --git a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj index 44101368b937..05eeca0222c1 100644 --- a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj +++ b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj @@ -10,6 +10,7 @@ false Microsoft.Extensions.FileProviders.Embedded.Manifest.xml enable + $(DefineConstants);COMPONENTS diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 0c46325988a1..8031ca9e268a 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -84,7 +84,7 @@ internal static async Task InitializeStandardComponentServicesAsync( if (handler != null && form != null) { httpContext.RequestServices.GetRequiredService() - .SetFormData(handler, new FormCollectionReadOnlyDictionary(form)); + .SetFormData(handler, new FormCollectionReadOnlyDictionary(form), form.Files); } if (httpContext.RequestServices.GetService() is EndpointAntiforgeryStateProvider antiforgery) diff --git a/src/Components/Endpoints/test/Binding/FormDataMapperTests.cs b/src/Components/Endpoints/test/Binding/FormDataMapperTests.cs index e82936aa8e1a..cbc268ca9a30 100644 --- a/src/Components/Endpoints/test/Binding/FormDataMapperTests.cs +++ b/src/Components/Endpoints/test/Binding/FormDataMapperTests.cs @@ -9,6 +9,9 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.Serialization; +using System.Text; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Components.Endpoints.FormMapping; @@ -72,14 +75,16 @@ public void CanDeserialize_NullableEnumTypes(string value, Colors expected) Assert.Equal(expected, result); } - private FormDataReader CreateFormDataReader(Dictionary collection, CultureInfo invariantCulture) + private FormDataReader CreateFormDataReader(Dictionary collection, CultureInfo invariantCulture, IFormFileCollection formFileCollection = null) { var dictionary = new Dictionary(collection.Count); foreach (var kvp in collection) { dictionary.Add(new FormKey(kvp.Key.AsMemory()), kvp.Value); } - return new FormDataReader(dictionary, CultureInfo.InvariantCulture, new char[2048]); + return formFileCollection is null + ? new FormDataReader(dictionary, CultureInfo.InvariantCulture, new char[2048]) + : new FormDataReader(dictionary, CultureInfo.InvariantCulture, new char[2048], formFileCollection); } [Theory] @@ -1849,6 +1854,182 @@ public void CanDeserialize_ComplexType_ThrowsFromConstructor() Assert.Equal("Value cannot be null. (Parameter 'key')", constructorError.Message.ToString(CultureInfo.InvariantCulture)); } + [Fact] + public void CanDeserialize_ComplexType_CanSerializerFormFile() + { + // Arrange + var expected = new FormFile(Stream.Null, 0, 10, "file", "file.txt"); + var formFileCollection = new FormFileCollection { expected }; + var data = new Dictionary(); + var reader = CreateFormDataReader(data, CultureInfo.InvariantCulture, formFileCollection); + var errors = new List(); + reader.ErrorHandler = (key, message, attemptedValue) => + { + errors.Add(new FormDataMappingError(key, message, attemptedValue)); + }; + reader.PushPrefix("file"); + var options = new FormDataMapperOptions(); + + // Act + var result = CallDeserialize(reader, options, typeof(IFormFile)); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void CanDeserialize_ComplexType_CanSerializerIReadOnlyListFormFile() + { + // Arrange + var formFileCollection = new FormFileCollection + { + new FormFile(Stream.Null, 0, 10, "file", "file-1.txt"), + new FormFile(Stream.Null, 0, 20, "file", "file-2.txt"), + new FormFile(Stream.Null, 0, 30, "file", "file-3.txt"), + new FormFile(Stream.Null, 0, 40, "oddOneOutFile", "file-4.txt"), + }; + var data = new Dictionary(); + var reader = CreateFormDataReader(data, CultureInfo.InvariantCulture, formFileCollection); + var errors = new List(); + reader.ErrorHandler = (key, message, attemptedValue) => + { + errors.Add(new FormDataMappingError(key, message, attemptedValue)); + }; + reader.PushPrefix("file"); + var options = new FormDataMapperOptions(); + + // Act + var result = CallDeserialize(reader, options, typeof(IReadOnlyList)); + + // Assert + var formFileResult = Assert.IsAssignableFrom>(result); + Assert.Collection(formFileResult, + element => Assert.Equal("file-1.txt", element.FileName), + element => Assert.Equal("file-2.txt", element.FileName), + element => Assert.Equal("file-3.txt", element.FileName) + ); + } + + [Fact] + public void CanDeserialize_ComplexType_ReturnsFirstFileForMultiples() + { + // Arrange + var formFileCollection = new FormFileCollection + { + new FormFile(Stream.Null, 0, 10, "file", "file-1.txt"), + new FormFile(Stream.Null, 0, 20, "file", "file-2.txt"), + new FormFile(Stream.Null, 0, 30, "file", "file-3.txt"), + new FormFile(Stream.Null, 0, 40, "oddOneOutFile", "file-4.txt"), + }; + var data = new Dictionary(); + var reader = CreateFormDataReader(data, CultureInfo.InvariantCulture, formFileCollection); + var errors = new List(); + reader.ErrorHandler = (key, message, attemptedValue) => + { + errors.Add(new FormDataMappingError(key, message, attemptedValue)); + }; + reader.PushPrefix("file"); + var options = new FormDataMapperOptions(); + + // Act + var result = CallDeserialize(reader, options, typeof(IFormFile)); + + // Assert + Assert.Equal(formFileCollection[0], result); + } + + [Fact] + public void CanDeserialize_ComplexType_CanSerializerFormFileCollection() + { + // Arrange + var expected = new FormFileCollection { new FormFile(Stream.Null, 0, 10, "file", "file.txt") }; + var data = new Dictionary(); + var reader = CreateFormDataReader(data, CultureInfo.InvariantCulture, expected); + var errors = new List(); + reader.ErrorHandler = (key, message, attemptedValue) => + { + errors.Add(new FormDataMappingError(key, message, attemptedValue)); + }; + reader.PushPrefix("file"); + var options = new FormDataMapperOptions(); + + // Act + var result = CallDeserialize(reader, options, typeof(IFormFileCollection)); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void CanDeserialize_ComplexType_CanSerializerBrowserFile() + { + // Arrange + var expectedString = "This is the contents of my text file."; + var expected = new FormFileCollection { new FormFile(new MemoryStream(Encoding.UTF8.GetBytes(expectedString)), 0, expectedString.Length, "file", "file.txt") }; + var data = new Dictionary(); + var reader = CreateFormDataReader(data, CultureInfo.InvariantCulture, expected); + var errors = new List(); + reader.ErrorHandler = (key, message, attemptedValue) => + { + errors.Add(new FormDataMappingError(key, message, attemptedValue)); + }; + reader.PushPrefix("file"); + var options = new FormDataMapperOptions(); + + // Act + var result = CallDeserialize(reader, options, typeof(IBrowserFile)); + + // Assert + var browserFile = Assert.IsAssignableFrom(result); + Assert.Equal("file", browserFile.Name); + Assert.Equal(expectedString.Length, browserFile.Size); + var buffer = new byte[browserFile.Size]; + browserFile.OpenReadStream().Read(buffer); + Assert.Equal(expectedString, Encoding.UTF8.GetString(buffer, 0, buffer.Length)); + } + + [Fact] + public void CanDeserialize_ComplexType_CanSerializerIReadOnlyListBrowserFile() + { + // Arrange + var expectedString1 = "This is the contents of my first text file."; + var expectedString2 = "This is the contents of my second text file."; + var expected = new FormFileCollection + { + new FormFile(new MemoryStream(Encoding.UTF8.GetBytes(expectedString1)), 0, expectedString1.Length, "file", "file1.txt"), + new FormFile(new MemoryStream(Encoding.UTF8.GetBytes(expectedString2)), 0, expectedString2.Length, "file", "file2.txt") + }; + var data = new Dictionary(); + var reader = CreateFormDataReader(data, CultureInfo.InvariantCulture, expected); + var errors = new List(); + reader.ErrorHandler = (key, message, attemptedValue) => + { + errors.Add(new FormDataMappingError(key, message, attemptedValue)); + }; + reader.PushPrefix("file"); + var options = new FormDataMapperOptions(); + + // Act + var result = CallDeserialize(reader, options, typeof(IReadOnlyList)); + + // Assert + var browserFiles = Assert.IsAssignableFrom>(result); + // First file + var browserFile1 = browserFiles[0]; + Assert.Equal("file", browserFile1.Name); + Assert.Equal(expectedString1.Length, browserFile1.Size); + var buffer1 = new byte[browserFile1.Size]; + browserFile1.OpenReadStream().Read(buffer1); + Assert.Equal(expectedString1, Encoding.UTF8.GetString(buffer1, 0, buffer1.Length)); + // Second files + var browserFile2 = browserFiles[0]; + Assert.Equal("file", browserFile2.Name); + Assert.Equal(expectedString1.Length, browserFile2.Size); + var buffer2 = new byte[browserFile2.Size]; + browserFile1.OpenReadStream().Read(buffer2); + Assert.Equal(expectedString1, Encoding.UTF8.GetString(buffer2, 0, buffer2.Length)); + } + [Fact] public void RecursiveTypes_Comparer_SortsValues_Correctly() { diff --git a/src/Components/Endpoints/test/HttpContextFormValueMapperTest.cs b/src/Components/Endpoints/test/HttpContextFormValueMapperTest.cs index 1b139fcf8549..976fe112fe3b 100644 --- a/src/Components/Endpoints/test/HttpContextFormValueMapperTest.cs +++ b/src/Components/Endpoints/test/HttpContextFormValueMapperTest.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -34,7 +35,7 @@ public class HttpContextFormValueMapperTest public void CanMap_MatchesOnScopeAndFormName(bool expectedResult, string incomingFormName, string scopeName, string formNameOrNull) { var formData = new HttpContextFormDataProvider(); - formData.SetFormData(incomingFormName, new Dictionary()); + formData.SetFormData(incomingFormName, new Dictionary(), new FormFileCollection()); var mapper = new HttpContextFormValueMapper(formData, Options.Create(new())); diff --git a/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs index f9164075105b..82110a7b7d64 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Net.Http; +using System.Text; using Components.TestServer.RazorComponents; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; @@ -15,6 +16,8 @@ namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests.FormHand public class FormWithParentBindingContextTest : ServerTestBase>> { + private string _tempDirectory; + public FormWithParentBindingContextTest( BrowserFixture browserFixture, BasicTestAppServerSiteFixture> serverFixture, @@ -24,7 +27,12 @@ public FormWithParentBindingContextTest( } public override Task InitializeAsync() - => InitializeAsync(BrowserFixture.StreamingContext); + { + _tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDirectory); + + return InitializeAsync(BrowserFixture.StreamingContext); + } [Theory] [InlineData(true)] @@ -1216,6 +1224,41 @@ public void PostingFormWithErrorsDoesNotExceedMaximumErrors() DispatchToFormCore(dispatchToForm); } + [Fact] + public void CanBindToFormWithFiles() + { + var profilePicture = TempFile.Create(_tempDirectory, "txt", "This is a profile picture."); + var headerPhoto = TempFile.Create(_tempDirectory, "txt", "This is a header picture."); + var file1 = TempFile.Create(_tempDirectory, "txt", "This is file 1."); + var file2 = TempFile.Create(_tempDirectory, "txt", "This is file 2."); + var file3 = TempFile.Create(_tempDirectory, "txt", "This is file 3."); + var file4 = TempFile.Create(_tempDirectory, "txt", "This is file 4."); + var file5 = TempFile.Create(_tempDirectory, "txt", "This is file 5."); + var dispatchToForm = new DispatchToForm(this) + { + Url = "forms/with-files", + FormCssSelector = "form", + FormIsEnhanced = false, + UpdateFormAction = () => + { + Browser.Exists(By.CssSelector("input[name='Model.ProfilePicture']")).SendKeys(profilePicture.Path); + Browser.Exists(By.CssSelector("input[name='Model.Documents']")).SendKeys(file1.Path); + Browser.Exists(By.CssSelector("input[name='Model.Documents']")).SendKeys(file2.Path); + Browser.Exists(By.CssSelector("input[name='Model.Images']")).SendKeys(file3.Path); + Browser.Exists(By.CssSelector("input[name='Model.Images']")).SendKeys(file4.Path); + Browser.Exists(By.CssSelector("input[name='Model.Images']")).SendKeys(file5.Path); + Browser.Exists(By.CssSelector("input[name='Model.HeaderPhoto']")).SendKeys(headerPhoto.Path); + } + }; + DispatchToFormCore(dispatchToForm); + + Assert.Equal($"Profile Picture: {profilePicture.Name}", Browser.Exists(By.Id("profile-picture")).Text); + Assert.Equal("Documents: 2", Browser.Exists(By.Id("documents")).Text); + Assert.Equal("Images: 3", Browser.Exists(By.Id("images")).Text); + Assert.Equal("Header Photo: Model.HeaderPhoto", Browser.Exists(By.Id("header-photo")).Text); + Assert.Equal("Total: 7", Browser.Exists(By.Id("form-collection")).Text); + } + private void DispatchToFormCore(DispatchToForm dispatch) { SuppressEnhancedNavigation(dispatch.SuppressEnhancedNavigation); @@ -1345,4 +1388,26 @@ private void GoTo(string relativePath) { Navigate($"{ServerPathBase}/{relativePath}"); } + + private struct TempFile + { + public string Name { get; } + public string Path { get; } + public byte[] Contents { get; } + public string Text => Encoding.ASCII.GetString(Contents); + private TempFile(string tempDirectory, string extension, byte[] contents) + { + Name = $"{Guid.NewGuid():N}.{extension}"; + Path = System.IO.Path.Combine(tempDirectory, Name); + Contents = contents; + } + public static TempFile Create(string tempDirectory, string extension, byte[] contents) + { + var file = new TempFile(tempDirectory, extension, contents); + File.WriteAllBytes(file.Path, contents); + return file; + } + public static TempFile Create(string tempDirectory, string extension, string text) + => Create(tempDirectory, extension, Encoding.ASCII.GetBytes(text)); + } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/FormsWithFiles.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/FormsWithFiles.razor new file mode 100644 index 000000000000..eb1874a6a292 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/FormsWithFiles.razor @@ -0,0 +1,66 @@ +@page "/forms/with-files" +@using Microsoft.AspNetCore.Components.Forms + +

Forms With Files

+ + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +@if(_shouldDisplaySuccess) +{ +

Profile Picture: @Model.ProfilePicture.FileName

+

Documents: @Model.Documents.Count()

+

Images: @Model.Images.Count()

+

Header Photo: @Model.HeaderPhoto.Name

+

Total: @Model.FormFiles.Count

+} + +@code +{ + bool _shouldDisplaySuccess = false; + + public void DisplaySuccess() => _shouldDisplaySuccess = true; + + [SupplyParameterFromForm] public FileContainer Model { get; set; } + + protected override void OnInitialized() => Model ??= new FileContainer(); + + public class FileContainer + { + public IFormFile ProfilePicture { get; set; } + public IReadOnlyList Documents { get; set; } + public IReadOnlyList Images { get; set; } + public IBrowserFile HeaderPhoto { get; set; } + public IFormFileCollection FormFiles { get; set; } + } +} diff --git a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj index 381353c1903b..0f8c4b3a3bf4 100644 --- a/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj +++ b/src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj @@ -34,6 +34,7 @@ + diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index 81c6d430f8b4..82a882f7025a 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -120,7 +120,7 @@ public static partial class RequestDelegateFactory private static readonly MemberExpression FilterContextHttpContextStatusCodeExpr = Expression.Property(FilterContextHttpContextResponseExpr, typeof(HttpResponse).GetProperty(nameof(HttpResponse.StatusCode))!); private static readonly ParameterExpression InvokedFilterContextExpr = Expression.Parameter(typeof(EndpointFilterInvocationContext), "filterContext"); - private static readonly ConstructorInfo FormDataReaderConstructor = typeof(FormDataReader).GetConstructor(new[] { typeof(IReadOnlyDictionary), typeof(CultureInfo), typeof(Memory) })!; + private static readonly ConstructorInfo FormDataReaderConstructor = typeof(FormDataReader).GetConstructor(new[] { typeof(IReadOnlyDictionary), typeof(CultureInfo), typeof(Memory), typeof(IFormFileCollection) })!; private static readonly MethodInfo ProcessFormMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ProcessForm), BindingFlags.Static | BindingFlags.NonPublic)!; private static readonly MethodInfo FormDataMapperMapMethod = typeof(FormDataMapper).GetMethod(nameof(FormDataMapper.Map))!; private static readonly MethodInfo AsMemoryMethod = new Func>(MemoryExtensions.AsMemory).Method; @@ -276,6 +276,7 @@ private static RequestDelegateFactoryContext CreateFactoryContext(RequestDelegat var serviceProvider = options?.ServiceProvider ?? options?.EndpointBuilder?.ApplicationServices ?? EmptyServiceProvider.Instance; var endpointBuilder = options?.EndpointBuilder ?? new RdfEndpointBuilder(serviceProvider); var jsonSerializerOptions = serviceProvider.GetService>()?.Value.SerializerOptions ?? JsonOptions.DefaultSerializerOptions; + var formDataMapperOptions = new FormDataMapperOptions();; var factoryContext = new RequestDelegateFactoryContext { @@ -288,6 +289,7 @@ private static RequestDelegateFactoryContext CreateFactoryContext(RequestDelegat EndpointBuilder = endpointBuilder, MetadataAlreadyInferred = metadataResult is not null, JsonSerializerOptions = jsonSerializerOptions, + FormDataMapperOptions = formDataMapperOptions }; return factoryContext; @@ -2054,7 +2056,7 @@ private static Expression BindComplexParameterFromFormItem( return formArgument; } - var formDataMapperOptions = new FormDataMapperOptions(); + var formDataMapperOptions = factoryContext.FormDataMapperOptions; var formMappingOptionsMetadatas = factoryContext.EndpointBuilder.Metadata.OfType(); foreach (var formMappingOptionsMetadata in formMappingOptionsMetadatas) { @@ -2073,13 +2075,14 @@ private static Expression BindComplexParameterFromFormItem( // ProcessForm(context.Request.Form, form_dict, form_buffer); var processFormExpr = Expression.Call(ProcessFormMethod, FormExpr, Expression.Constant(formDataMapperOptions.MaxKeyBufferSize), formDict, formBuffer); - // name_reader = new FormDataReader(form_dict, CultureInfo.InvariantCulture, form_buffer.AsMemory(0, FormDataMapperOptions.MaxKeyBufferSize)); + // name_reader = new FormDataReader(form_dict, CultureInfo.InvariantCulture, form_buffer.AsMemory(0, formDataMapperOptions.MaxKeyBufferSize), httpContext.Request.Form.Files); var initializeReaderExpr = Expression.Assign( formReader, Expression.New(FormDataReaderConstructor, formDict, Expression.Constant(CultureInfo.InvariantCulture), - Expression.Call(AsMemoryMethod, formBuffer, Expression.Constant(0), Expression.Constant(formDataMapperOptions.MaxKeyBufferSize)))); + Expression.Call(AsMemoryMethod, formBuffer, Expression.Constant(0), Expression.Constant(formDataMapperOptions.MaxKeyBufferSize)), + FormFilesExpr)); // name_reader.MaxRecursionDepth = formDataMapperOptions.MaxRecursionDepth; var setMaxRecursionDepthExpr = Expression.Assign( Expression.Property(formReader, nameof(FormDataReader.MaxRecursionDepth)), diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs b/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs index d3c793fbe642..ff3010a68131 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Text.Json; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.Endpoints.FormMapping; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Http; @@ -45,6 +46,9 @@ internal sealed class RequestDelegateFactoryContext public NullabilityInfoContext NullabilityContext { get; } = new(); + // Used to invoke TryResolveFormAsync once per handler so that we can + // avoid the blocking code-path that occurs when `httpContext.Request.Form` + // is called. public bool ReadForm { get; set; } public bool ReadFormFile { get; set; } public ParameterInfo? FirstFormRequestBodyParameter { get; set; } @@ -59,4 +63,6 @@ internal sealed class RequestDelegateFactoryContext // Grab these options upfront to avoid the per request DI scope that would be made otherwise to get the options when writing Json public required JsonSerializerOptions JsonSerializerOptions { get; set; } + + public required FormDataMapperOptions FormDataMapperOptions { get; set; } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.FormMapping.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.FormMapping.cs index b1c740251adf..414173a55806 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.FormMapping.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.FormMapping.cs @@ -5,6 +5,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; @@ -278,7 +280,30 @@ public async Task SupportsRecursivePropertiesWithRecursionLimit() var exception = await Assert.ThrowsAsync(async () => await requestDelegate(httpContext)); Assert.Equal("The maximum recursion depth of '3' was exceeded for 'Manager.Manager.Manager.Name'.", exception.Message); + } + + [Fact] + public async Task SupportsFormFileSourcesInDto() + { + FormFileDto capturedArgument = default; + void TestAction([FromForm] FormFileDto args) { capturedArgument = args; }; + var httpContext = CreateHttpContext(); + var formFiles = new FormFileCollection + { + new FormFile(Stream.Null, 0, 10, "file", "file.txt"), + new FormFile(Stream.Null, 0, 10, "formFiles", "file-1.txt"), + new FormFile(Stream.Null, 0, 10, "formFiles", "file-2.txt"), + }; + httpContext.Request.Form = new FormCollection(new() { { "Description", "A test file" } }, formFiles); + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + Assert.Equal("A test file", capturedArgument.Description); + Assert.Equal(formFiles["file"], capturedArgument.File); + Assert.Equal(formFiles.GetFiles("formFiles"), capturedArgument.FormFiles); + Assert.Equal(formFiles, capturedArgument.FormFileCollection); } private record TodoRecord(int Id, string Name, bool IsCompleted); @@ -288,4 +313,13 @@ private class Employee public string Name { get; set; } public Employee Manager { get; set; } } +#nullable enable + + private class FormFileDto + { + public string Description { get; set; } = String.Empty; + public IFormFile? File { get; set; } + public IReadOnlyList? FormFiles { get; set; } + public IFormFileCollection? FormFileCollection { get; set; } + } }