I’ve been working on creating a page for for the uploading of files to either a SQL database or Azure Blob storage which turned out to be much more complicated than initially expected.

I managed to eventually piece together how to do this and the resulting demo project can be found here which will hopefully help me (or anyone else) avoid some of the same problems in future.

Currently there’s a size limit on uploads for both IIS and Kestrel servers of ~28.6 MiB which I’m unable to get around using the methods in the article. Documentation detailing how to upload large files using ASP.NET Core 2.0 is apparently in progress though so hopefully it will be working soon.

This is a standard MVC ASP.NET Core Web Application created using .NET Core 2.0. As my solution is intended to upload files with an unknown structure I have altered the code from the linked sources to save the incoming file to a temporary text file rather than attempting to load it into a model which is then validated.

Layout

I wanted something slightly less ugly than the standard file input button so based my input on this article which provides a good guide to creating a file input in Bootstrap.

I replaced the code in my Index.cshtml file with that below and added two buttons to my input to demonstrate uploading files in one go as well as streaming them in parts.

@{
    ViewData["Title"] = "Home Page";
}

<div class="modal-dialog">
    <div class="modal-content">
        <form asp-controller="Home" asp-action="UploadSmallFile" enctype="multipart/form-data" id="BlobUploadForm" method="post" class="form-label-left" role="form">
            <div class="modal-footer">
                <div class="form-group">
                    <div class="input-group">
                        <label class="input-group-btn">
                            <span class="btn btn-primary">
                                Browse… <input type="file" style="display: none;" name="file" id="FileInput">
                            </span>
                        </label>
                        <input type="text" class="form-control" readonly="" id="BrowseInput">
                    </div>
                </div>
                <div class="form-group">
                    <div class="input-group">
                        <button type="submit" value="Upload Small File" class="btn btn-default" id="UploadSmallFile">Upload Small File</button>
                        <button type="button" value="Upload Streaming File" class="btn btn-default" id="UploadStreamingFile" onclick="uploadStreamingFile()">Upload Streaming File</button>
                    </div>
                </div>
            </div>
        </form>
    </div>
</div>

@section Scripts {
    <script type="text/javascript">
        $(document).on('change', ':file', function () {
            var input = $(this)
            var label = $('#BrowseInput').val(input.val().replace(/\\/g, '/').replace(/.*\//, ''));
        });
    </script>
}

Uploading Small Files

Small files can be uploaded easily enough by posting them directly to the controller from the form, the form needs to specify an enctype of multipart/form-data and the name of the file input must match the name of the IFormFile variable expected by the controller method. This code is based on the article here.

[HttpPost]
public async Task<IActionResult> UploadSmallFile(IFormFile file)
{
	// full path to file in temp location
	var filePath = Path.GetTempFileName();

	if (file.Length > 0)
	{
		using (var stream = new FileStream(filePath, FileMode.Create))
		{
			await file.CopyToAsync(stream);
		}
	}

	// process uploaded files
	// Don't rely on or trust the FileName property without validation.

	return Ok();
}

Streaming File Upload

The size or frequency of file uploads can cause resource problems so in order to process files more efficiently they can be streamed rather than buffered as with the small files though this is quite a bit more complicated to implement.

The below code is based on that here which itself is based on the Microsoft guide linked to above.

[HttpPost]
[ValidateAntiForgeryToken]
[DisableFormValueModelBinding]
public async Task<IActionResult> UploadStreamingFile()
{
	// full path to file in temp location
	var filePath = Path.GetTempFileName();

	using (var stream = new FileStream(filePath, FileMode.Create))
	{
		await Request.StreamFile(stream);
	}

	// process uploaded files
	// Don't rely on or trust the FileName property without validation.

	return Ok();
}

The UploadStreamingFile method is decorated with the attributes ValidateAntiForgeryToken and DisableFormValueModelBinding. ValidateAntiForgeryToken is there to prevent cross site request forgeries and DisableFormValueModelBinding is a custom filter to prevent the method from trying to bind and validate the input automatically which would preemptively read the input stream.

public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
{
	public void OnResourceExecuting(ResourceExecutingContext context)
	{
		var formValueProviderFactory = context.ValueProviderFactories
			.OfType<FormValueProviderFactory>()
			.FirstOrDefault();
		if (formValueProviderFactory != null)
		{
			context.ValueProviderFactories.Remove(formValueProviderFactory);
		}

		var jqueryFormValueProviderFactory = context.ValueProviderFactories
			.OfType<JQueryFormValueProviderFactory>()
			.FirstOrDefault();
		if (jqueryFormValueProviderFactory != null)
		{
			context.ValueProviderFactories.Remove(jqueryFormValueProviderFactory);
		}
	}

	public void OnResourceExecuted(ResourceExecutedContext context)
	{
	}
}
@{
    ViewData["Title"] = "Home Page";
}

<div class="modal-dialog">
    <div class="modal-content">
        <form asp-controller="Home" asp-action="UploadSmallFile" enctype="multipart/form-data" id="BlobUploadForm" method="post" class="form-label-left" role="form">
            <div class="modal-footer">
                <div class="form-group">
                    <div class="input-group">
                        <label class="input-group-btn">
                            <span class="btn btn-primary">
                                Browse… <input type="file" style="display: none;" name="file" id="FileInput">
                            </span>
                        </label>
                        <input type="text" class="form-control" readonly="" id="BrowseInput">
                    </div>
                </div>
                <div class="form-group">
                    <div class="input-group">
                        <button type="submit" value="Upload Small File" class="btn btn-default" id="UploadSmallFile">Upload Small File</button>
                        <button type="button" value="Upload Streaming File" class="btn btn-default" id="UploadStreamingFile" onclick="uploadStreamingFile()">Upload Streaming File</button>
                    </div>
                </div>
            </div>
        </form>
    </div>
</div>

@section Scripts {
    <script type="text/javascript">
        $(document).on('change', ':file', function () {
            var input = $(this)
            var label = $('#BrowseInput').val(input.val().replace(/\\/g, '/').replace(/.*\//, ''));
        });
    </script>
    <script type="text/javascript">
        function getAntiForgeryToken() {
            token = $('input[name=__RequestVerificationToken]').val();
            return token;
        };
    </script>
    <script type="text/javascript">
        function uploadStreamingFile() {
            var data = new FormData();
            $.each($('#FileInput')[0].files, function (i, file) {
                data.append('file-' + i, file);
            });

            $.ajax({
                url: '@Url.Action("UploadStreamingFile", "Home")',
                data: data,
                cache: false,
                contentType: false,
                processData: false,
                method: 'POST',
                headers: { 'RequestVerificationToken': getAntiForgeryToken({ }) },
                success: function (returned) {

                },
                error: function (returned) {

                }
            });
        }
    </script>
}

If you are checking for the antiforgery token in your controller method then you’ll need to post the files to your controller method manually as the Razor submit method posts the token in the body of the request and when this is read by the filter it causes the request stream to be read which produces the rather unhelpful error Unexpected end of Stream, the content may have already been read by another component. You can get around this by either removing the ValidateAntiForgeryToken filter from your controller method or sending the token in the header which is read before attempting to find it in the body, the Razor submit method doesn’t allow setting header values, hence the custom javascript to send the file.

The antiforgery token can be added as a hidden input to the webpage using @Html.AntiForgeryToken(), I place this at the top of the body in _Layout.cshtml.

The below helpers are added to help tidy up the code a bit.

public static class FileStreamingHelper
{
	private static readonly FormOptions _defaultFormOptions = new FormOptions();

	public static async Task<FormValueProvider> StreamFile(this HttpRequest request, Stream targetStream)
	{
		if (!MultipartRequestHelper.IsMultipartContentType(request.ContentType))
		{
			throw new Exception($"Expected a multipart request, but got {request.ContentType}");
		}

		// Used to accumulate all the form url encoded key value pairs in the 
		// request.
		var formAccumulator = new KeyValueAccumulator();
		string targetFilePath = null;

		var boundary = MultipartRequestHelper.GetBoundary(
			MediaTypeHeaderValue.Parse(request.ContentType),
			_defaultFormOptions.MultipartBoundaryLengthLimit);
		var reader = new MultipartReader(boundary, request.Body);

		var section = await reader.ReadNextSectionAsync();
		while (section != null)
		{
			ContentDispositionHeaderValue contentDisposition;
			var hasContentDispositionHeader = ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out contentDisposition);

			if (hasContentDispositionHeader)
			{
				if (MultipartRequestHelper.HasFileContentDisposition(contentDisposition))
				{
					await section.Body.CopyToAsync(targetStream);
				}
				else if (MultipartRequestHelper.HasFormDataContentDisposition(contentDisposition))
				{
					// Content-Disposition: form-data; name="key"
					//
					// value

					// Do not limit the key name length here because the 
					// multipart headers length limit is already in effect.
					var key = HeaderUtilities.RemoveQuotes(contentDisposition.Name);
					var encoding = GetEncoding(section);
					using (var streamReader = new StreamReader(
						section.Body,
						encoding,
						detectEncodingFromByteOrderMarks: true,
						bufferSize: 1024,
						leaveOpen: true))
					{
						// The value length limit is enforced by MultipartBodyLengthLimit
						var value = await streamReader.ReadToEndAsync();
						if (String.Equals(value, "undefined", StringComparison.OrdinalIgnoreCase))
						{
							value = String.Empty;
						}
						formAccumulator.Append(key.Value, value); // For .NET Core <2.0 remove ".Value" from key

						if (formAccumulator.ValueCount > _defaultFormOptions.ValueCountLimit)
						{
							throw new InvalidDataException($"Form key count limit {_defaultFormOptions.ValueCountLimit} exceeded.");
						}
					}
				}
			}

			// Drains any remaining section body that has not been consumed and
			// reads the headers for the next section.
			section = await reader.ReadNextSectionAsync();
		}

		// Bind form data to a model
		var formValueProvider = new FormValueProvider(
			BindingSource.Form,
			new FormCollection(formAccumulator.GetResults()),
			CultureInfo.CurrentCulture);

		return formValueProvider;
	}

	private static Encoding GetEncoding(MultipartSection section)
	{
		MediaTypeHeaderValue mediaType;
		var hasMediaTypeHeader = MediaTypeHeaderValue.TryParse(section.ContentType, out mediaType);
		// UTF-7 is insecure and should not be honored. UTF-8 will succeed in 
		// most cases.
		if (!hasMediaTypeHeader || Encoding.UTF7.Equals(mediaType.Encoding))
		{
			return Encoding.UTF8;
		}
		return mediaType.Encoding;
	}
}
public static class MultipartRequestHelper
{
	// Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq"
	// The spec says 70 characters is a reasonable limit.
	public static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit)
	{
		//var boundary = Microsoft.Net.Http.Headers.HeaderUtilities.RemoveQuotes(contentType.Boundary);// .NET Core <2.0
		var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary).Value; //.NET Core 2.0
		if (string.IsNullOrWhiteSpace(boundary))
		{
			throw new InvalidDataException("Missing content-type boundary.");
		}

		if (boundary.Length > lengthLimit)
		{
			throw new InvalidDataException(
				$"Multipart boundary length limit {lengthLimit} exceeded.");
		}

		return boundary;
	}

	public static bool IsMultipartContentType(string contentType)
	{
		return !string.IsNullOrEmpty(contentType)
				&& contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0;
	}

	public static bool HasFormDataContentDisposition(ContentDispositionHeaderValue contentDisposition)
	{
		// Content-Disposition: form-data; name="key";
		return contentDisposition != null
				&& contentDisposition.DispositionType.Equals("form-data")
				&& string.IsNullOrEmpty(contentDisposition.FileName.Value) // For .NET Core <2.0 remove ".Value"
				&& string.IsNullOrEmpty(contentDisposition.FileNameStar.Value); // For .NET Core <2.0 remove ".Value"
	}

	public static bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition)
	{
		// Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg"
		return contentDisposition != null
				&& contentDisposition.DispositionType.Equals("form-data")
				&& (!string.IsNullOrEmpty(contentDisposition.FileName.Value) // For .NET Core <2.0 remove ".Value"
					|| !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value)); // For .NET Core <2.0 remove ".Value"
	}
}

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *