diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ControllerBase.cs b/src/Microsoft.AspNetCore.Mvc.Core/ControllerBase.cs index 3a6469783e..457d88393b 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ControllerBase.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ControllerBase.cs @@ -1135,8 +1135,10 @@ namespace Microsoft.AspNetCore.Mvc } /// - /// Returns a file with the specified as content - /// () and the specified as the Content-Type. + /// Returns a file with the specified as content (), + /// and the specified as the Content-Type. + /// This supports range requests ( or + /// if the range is not satisfiable). /// /// The file contents. /// The Content-Type of the file. @@ -1149,8 +1151,9 @@ namespace Microsoft.AspNetCore.Mvc /// /// Returns a file with the specified as content (), the - /// specified as the Content-Type and the - /// specified as the suggested file name. + /// specified as the Content-Type and the specified as the suggested file name. + /// This supports range requests ( or + /// if the range is not satisfiable). /// /// The file contents. /// The Content-Type of the file. @@ -1163,8 +1166,54 @@ namespace Microsoft.AspNetCore.Mvc } /// - /// Returns a file in the specified () - /// with the specified as the Content-Type. + /// Returns a file with the specified as content (), + /// and the specified as the Content-Type. + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// The file contents. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + [NonAction] + public virtual FileContentResult File(byte[] fileContents, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new FileContentResult(fileContents, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + } + + /// + /// Returns a file with the specified as content (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// The file contents. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + [NonAction] + public virtual FileContentResult File(byte[] fileContents, string contentType, string fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new FileContentResult(fileContents, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + }; + } + + /// + /// Returns a file in the specified (), with the + /// specified as the Content-Type. + /// This supports range requests ( or + /// if the range is not satisfiable). /// /// The with the contents of the file. /// The Content-Type of the file. @@ -1179,6 +1228,8 @@ namespace Microsoft.AspNetCore.Mvc /// Returns a file in the specified () with the /// specified as the Content-Type and the /// specified as the suggested file name. + /// This supports range requests ( or + /// if the range is not satisfiable). /// /// The with the contents of the file. /// The Content-Type of the file. @@ -1190,9 +1241,55 @@ namespace Microsoft.AspNetCore.Mvc return new FileStreamResult(fileStream, contentType) { FileDownloadName = fileDownloadName }; } + /// + /// Returns a file in the specified (), + /// and the specified as the Content-Type. + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + [NonAction] + public virtual FileStreamResult File(Stream fileStream, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new FileStreamResult(fileStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + } + + /// + /// Returns a file in the specified (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + [NonAction] + public virtual FileStreamResult File(Stream fileStream, string contentType, string fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new FileStreamResult(fileStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + }; + } + /// /// Returns the file specified by () with the /// specified as the Content-Type. + /// This supports range requests ( or + /// if the range is not satisfiable). /// /// The virtual path of the file to be returned. /// The Content-Type of the file. @@ -1207,6 +1304,8 @@ namespace Microsoft.AspNetCore.Mvc /// Returns the file specified by () with the /// specified as the Content-Type and the /// specified as the suggested file name. + /// This supports range requests ( or + /// if the range is not satisfiable). /// /// The virtual path of the file to be returned. /// The Content-Type of the file. @@ -1218,9 +1317,55 @@ namespace Microsoft.AspNetCore.Mvc return new VirtualFileResult(virtualPath, contentType) { FileDownloadName = fileDownloadName }; } + /// + /// Returns the file specified by (), and the + /// specified as the Content-Type. + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// The virtual path of the file to be returned. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + [NonAction] + public virtual VirtualFileResult File(string virtualPath, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new VirtualFileResult(virtualPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + } + + /// + /// Returns the file specified by (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// The virtual path of the file to be returned. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + [NonAction] + public virtual VirtualFileResult File(string virtualPath, string contentType, string fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new VirtualFileResult(virtualPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + }; + } + /// /// Returns the file specified by () with the /// specified as the Content-Type. + /// This supports range requests ( or + /// if the range is not satisfiable). /// /// The physical path of the file to be returned. /// The Content-Type of the file. @@ -1235,6 +1380,8 @@ namespace Microsoft.AspNetCore.Mvc /// Returns the file specified by () with the /// specified as the Content-Type and the /// specified as the suggested file name. + /// This supports range requests ( or + /// if the range is not satisfiable). /// /// The physical path of the file to be returned. /// The Content-Type of the file. @@ -1249,6 +1396,50 @@ namespace Microsoft.AspNetCore.Mvc return new PhysicalFileResult(physicalPath, contentType) { FileDownloadName = fileDownloadName }; } + /// + /// Returns the file specified by (), and + /// the specified as the Content-Type. + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// The physical path of the file to be returned. + /// The Content-Type of the file. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + [NonAction] + public virtual PhysicalFileResult PhysicalFile(string physicalPath, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new PhysicalFileResult(physicalPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + } + + /// + /// Returns the file specified by (), the + /// specified as the Content-Type, and the specified as the suggested file name. + /// This supports range requests ( or + /// if the range is not satisfiable). + /// + /// The physical path of the file to be returned. + /// The Content-Type of the file. + /// The suggested file name. + /// The of when the file was last modified. + /// The associated with the file. + /// The created for the response. + [NonAction] + public virtual PhysicalFileResult PhysicalFile(string physicalPath, string contentType, string fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag) + { + return new PhysicalFileResult(physicalPath, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + FileDownloadName = fileDownloadName, + }; + } + /// /// Creates an that produces an response. /// diff --git a/src/Microsoft.AspNetCore.Mvc.Core/FileContentResult.cs b/src/Microsoft.AspNetCore.Mvc.Core/FileContentResult.cs index 861909e3e2..30c5a5d213 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/FileContentResult.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/FileContentResult.cs @@ -3,8 +3,6 @@ using System; using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; diff --git a/src/Microsoft.AspNetCore.Mvc.Core/FileResult.cs b/src/Microsoft.AspNetCore.Mvc.Core/FileResult.cs index bec8d8ec59..dea1dff3e3 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/FileResult.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/FileResult.cs @@ -2,8 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc { @@ -43,5 +42,15 @@ namespace Microsoft.AspNetCore.Mvc get { return _fileDownloadName ?? string.Empty; } set { _fileDownloadName = value; } } + + /// + /// Gets or sets the last modified information associated with the . + /// + public DateTimeOffset? LastModified { get; set; } + + /// + /// Gets or sets the etag associated with the . + /// + public EntityTagHeaderValue EntityTag { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileContentResultExecutor.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileContentResultExecutor.cs index 5fd8d05654..9d26a364a2 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileContentResultExecutor.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileContentResultExecutor.cs @@ -2,8 +2,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.IO; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc.Internal { @@ -26,11 +28,22 @@ namespace Microsoft.AspNetCore.Mvc.Internal throw new ArgumentNullException(nameof(result)); } - SetHeadersAndLog(context, result); - return WriteFileAsync(context, result); + var (range, rangeLength, serveBody) = SetHeadersAndLog( + context, + result, + result.FileContents.Length, + result.LastModified, + result.EntityTag); + + if (!serveBody) + { + return Task.CompletedTask; + } + + return WriteFileAsync(context, result, range, rangeLength); } - protected virtual Task WriteFileAsync(ActionContext context, FileContentResult result) + protected virtual Task WriteFileAsync(ActionContext context, FileContentResult result, RangeItemHeaderValue range, long rangeLength) { if (context == null) { @@ -42,9 +55,15 @@ namespace Microsoft.AspNetCore.Mvc.Internal throw new ArgumentNullException(nameof(result)); } - var response = context.HttpContext.Response; + if (range != null && rangeLength == 0) + { + return Task.CompletedTask; + } - return response.Body.WriteAsync(result.FileContents, offset: 0, count: result.FileContents.Length); + var response = context.HttpContext.Response; + var outputStream = response.Body; + var fileContentsStream = new MemoryStream(result.FileContents); + return WriteFileAsync(context.HttpContext, fileContentsStream, range, rangeLength); } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileResultExecutorBase.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileResultExecutorBase.cs index c6b2e3f148..45f89fb5a2 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileResultExecutorBase.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileResultExecutorBase.cs @@ -2,6 +2,14 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Http.Headers; +using Microsoft.AspNetCore.Internal; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; @@ -9,20 +17,38 @@ namespace Microsoft.AspNetCore.Mvc.Internal { public class FileResultExecutorBase { + private const string AcceptRangeHeaderValue = "bytes"; + + // default buffer size as defined in BufferedStream type + protected const int BufferSize = 0x1000; + public FileResultExecutorBase(ILogger logger) { Logger = logger; } + internal enum PreconditionState + { + Unspecified, + NotModified, + ShouldProcess, + PreconditionFailed, + IgnoreRangeRequest + } + protected ILogger Logger { get; } - protected virtual void SetHeadersAndLog(ActionContext context, FileResult result) + protected virtual (RangeItemHeaderValue range, long rangeLength, bool serveBody) SetHeadersAndLog( + ActionContext context, + FileResult result, long? + fileLength, DateTimeOffset? + lastModified = null, + EntityTagHeaderValue etag = null) { if (context == null) { throw new ArgumentNullException(nameof(context)); } - if (result == null) { throw new ArgumentNullException(nameof(result)); @@ -31,9 +57,56 @@ namespace Microsoft.AspNetCore.Mvc.Internal SetContentType(context, result); SetContentDispositionHeader(context, result); Logger.FileResultExecuting(result.FileDownloadName); + if (fileLength.HasValue) + { + SetAcceptRangeHeader(context); + } + + var request = context.HttpContext.Request; + var httpRequestHeaders = request.GetTypedHeaders(); + var response = context.HttpContext.Response; + var httpResponseHeaders = response.GetTypedHeaders(); + if (lastModified.HasValue) + { + httpResponseHeaders.LastModified = lastModified; + } + if (etag != null) + { + httpResponseHeaders.ETag = etag; + } + + var serveBody = !HttpMethods.IsHead(request.Method); + if (HttpMethods.IsHead(request.Method) || HttpMethods.IsGet(request.Method)) + { + var preconditionState = GetPreconditionState(context, httpRequestHeaders, lastModified, etag); + if (request.Headers.ContainsKey(HeaderNames.Range) && + (preconditionState == PreconditionState.Unspecified || + preconditionState == PreconditionState.ShouldProcess)) + { + return SetRangeHeaders(context, httpRequestHeaders, fileLength, lastModified, etag); + } + if (preconditionState == PreconditionState.NotModified) + { + serveBody = false; + response.StatusCode = StatusCodes.Status304NotModified; + } + else if (preconditionState == PreconditionState.PreconditionFailed) + { + serveBody = false; + response.StatusCode = StatusCodes.Status412PreconditionFailed; + } + } + + return (range: null, rangeLength: 0, serveBody: serveBody); } - private void SetContentDispositionHeader(ActionContext context, FileResult result) + private static void SetContentType(ActionContext context, FileResult result) + { + var response = context.HttpContext.Response; + response.ContentType = result.ContentType; + } + + private static void SetContentDispositionHeader(ActionContext context, FileResult result) { if (!string.IsNullOrEmpty(result.FileDownloadName)) { @@ -48,10 +121,199 @@ namespace Microsoft.AspNetCore.Mvc.Internal } } - private void SetContentType(ActionContext context, FileResult result) + private static void SetAcceptRangeHeader(ActionContext context) { var response = context.HttpContext.Response; - response.ContentType = result.ContentType; + response.Headers[HeaderNames.AcceptRanges] = AcceptRangeHeaderValue; + } + + private static PreconditionState GetEtagMatchState( + IList etagHeader, + EntityTagHeaderValue etag, + PreconditionState matchFoundState, + PreconditionState matchNotFoundState) + { + if (etagHeader != null && etagHeader.Any()) + { + var state = matchNotFoundState; + foreach (var entityTag in etagHeader) + { + if (entityTag.Equals(EntityTagHeaderValue.Any) || entityTag.Compare(etag, useStrongComparison: true)) + { + state = matchFoundState; + break; + } + } + + return state; + } + + return PreconditionState.Unspecified; + } + + // Internal for testing + internal static PreconditionState GetPreconditionState( + ActionContext context, + RequestHeaders httpRequestHeaders, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue etag = null) + { + var ifMatchState = PreconditionState.Unspecified; + var ifNoneMatchState = PreconditionState.Unspecified; + var ifModifiedSinceState = PreconditionState.Unspecified; + var ifUnmodifiedSinceState = PreconditionState.Unspecified; + var ifRangeState = PreconditionState.Unspecified; + + // 14.24 If-Match + var ifMatch = httpRequestHeaders.IfMatch; + if (etag != null) + { + ifMatchState = GetEtagMatchState( + etagHeader: ifMatch, + etag: etag, + matchFoundState: PreconditionState.ShouldProcess, + matchNotFoundState: PreconditionState.PreconditionFailed); + } + + // 14.26 If-None-Match + var ifNoneMatch = httpRequestHeaders.IfNoneMatch; + if (etag != null) + { + ifNoneMatchState = GetEtagMatchState( + etagHeader: ifNoneMatch, + etag: etag, + matchFoundState: PreconditionState.NotModified, + matchNotFoundState: PreconditionState.ShouldProcess); + } + + var now = DateTimeOffset.UtcNow; + + // 14.25 If-Modified-Since + var ifModifiedSince = httpRequestHeaders.IfModifiedSince; + if (lastModified.HasValue && ifModifiedSince.HasValue && ifModifiedSince <= now) + { + var modified = ifModifiedSince < lastModified; + ifModifiedSinceState = modified ? PreconditionState.ShouldProcess : PreconditionState.NotModified; + } + + // 14.28 If-Unmodified-Since + var ifUnmodifiedSince = httpRequestHeaders.IfUnmodifiedSince; + if (lastModified.HasValue && ifUnmodifiedSince.HasValue && ifUnmodifiedSince <= now) + { + var unmodified = ifUnmodifiedSince >= lastModified; + ifUnmodifiedSinceState = unmodified ? PreconditionState.ShouldProcess : PreconditionState.PreconditionFailed; + } + + var ifRange = httpRequestHeaders.IfRange; + if (ifRange != null) + { + // If the validator given in the If-Range header field matches the + // current validator for the selected representation of the target + // resource, then the server SHOULD process the Range header field as + // requested. If the validator does not match, the server MUST ignore + // the Range header field. + if (ifRange.LastModified.HasValue) + { + if (lastModified.HasValue && lastModified > ifRange.LastModified) + { + ifRangeState = PreconditionState.IgnoreRangeRequest; + } + } + else if (etag != null && ifRange.EntityTag != null && !ifRange.EntityTag.Compare(etag, useStrongComparison: true)) + { + ifRangeState = PreconditionState.IgnoreRangeRequest; + } + } + + var state = GetMaxPreconditionState(ifMatchState, ifNoneMatchState, ifModifiedSinceState, ifUnmodifiedSinceState, ifRangeState); + return state; + } + + private static PreconditionState GetMaxPreconditionState(params PreconditionState[] states) + { + var max = PreconditionState.Unspecified; + for (var i = 0; i < states.Length; i++) + { + if (states[i] > max) + { + max = states[i]; + } + } + + return max; + } + + private static (RangeItemHeaderValue range, long rangeLength, bool serveBody) SetRangeHeaders( + ActionContext context, + RequestHeaders httpRequestHeaders, + long? fileLength, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue etag = null) + { + var response = context.HttpContext.Response; + var httpResponseHeaders = response.GetTypedHeaders(); + + // Checked for presence of Range header explicitly before calling this method. + // Range may be null for parsing errors, multiple ranges and when the file length is missing. + var range = fileLength.HasValue ? ParseRange(context, httpRequestHeaders, fileLength.Value, lastModified, etag) : null; + if (range == null) + { + // 14.16 Content-Range - A server sending a response with status code 416 (Requested range not satisfiable) + // SHOULD include a Content-Range field with a byte-range-resp-spec of "*". The instance-length specifies + // the current length of the selected resource. e.g. */length + response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; + if (fileLength.HasValue) + { + httpResponseHeaders.ContentRange = new ContentRangeHeaderValue(fileLength.Value); + } + + return (range: null, rangeLength: 0, serveBody: false); + } + + httpResponseHeaders.ContentRange = new ContentRangeHeaderValue( + range.From.Value, + range.To.Value, + fileLength.Value); + + response.StatusCode = StatusCodes.Status206PartialContent; + var rangeLength = SetContentLength(context, range); + return (range, rangeLength, serveBody: true); + } + + private static long SetContentLength(ActionContext context, RangeItemHeaderValue range) + { + var start = range.From.Value; + var end = range.To.Value; + var length = end - start + 1; + var response = context.HttpContext.Response; + response.ContentLength = length; + return length; + } + + private static RangeItemHeaderValue ParseRange( + ActionContext context, + RequestHeaders httpRequestHeaders, + long fileLength, + DateTimeOffset? lastModified = null, + EntityTagHeaderValue etag = null) + { + var httpContext = context.HttpContext; + var response = httpContext.Response; + + var range = RangeHelper.ParseRange(httpContext, httpRequestHeaders, lastModified, etag); + + if (range != null) + { + var normalizedRanges = RangeHelper.NormalizeRanges(range, fileLength); + if (normalizedRanges == null || normalizedRanges.Count == 0) + { + return null; + } + + return normalizedRanges.Single(); + } + + return null; } protected static ILogger CreateLogger(ILoggerFactory factory) @@ -63,5 +325,31 @@ namespace Microsoft.AspNetCore.Mvc.Internal return factory.CreateLogger(); } + + protected static async Task WriteFileAsync(HttpContext context, Stream fileStream, RangeItemHeaderValue range, long rangeLength) + { + var outputStream = context.Response.Body; + using (fileStream) + { + try + { + if (range == null) + { + await StreamCopyOperation.CopyToAsync(fileStream, outputStream, count: null, bufferSize: BufferSize, cancel: context.RequestAborted); + } + else + { + fileStream.Seek(range.From.Value, SeekOrigin.Begin); + await StreamCopyOperation.CopyToAsync(fileStream, outputStream, rangeLength, BufferSize, context.RequestAborted); + } + } + catch (OperationCanceledException) + { + // Don't throw this exception, it's most likely caused by the client disconnecting. + // However, if it was cancelled for any other reason we need to prevent empty responses. + context.Abort(); + } + } + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileStreamResultExecutor.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileStreamResultExecutor.cs index 76bbde3594..5912d73190 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileStreamResultExecutor.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/FileStreamResultExecutor.cs @@ -4,14 +4,12 @@ using System; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc.Internal { public class FileStreamResultExecutor : FileResultExecutorBase { - // default buffer size as defined in BufferedStream type - private const int BufferSize = 0x1000; - public FileStreamResultExecutor(ILoggerFactory loggerFactory) : base(CreateLogger(loggerFactory)) { @@ -29,11 +27,28 @@ namespace Microsoft.AspNetCore.Mvc.Internal throw new ArgumentNullException(nameof(result)); } - SetHeadersAndLog(context, result); - return WriteFileAsync(context, result); + long? fileLength = null; + if (result.FileStream.CanSeek) + { + fileLength = result.FileStream.Length; + } + + var (range, rangeLength, serveBody) = SetHeadersAndLog( + context, + result, + fileLength, + result.LastModified, + result.EntityTag); + + if (!serveBody) + { + return Task.CompletedTask; + } + + return WriteFileAsync(context, result, range, rangeLength); } - protected virtual async Task WriteFileAsync(ActionContext context, FileStreamResult result) + protected virtual Task WriteFileAsync(ActionContext context, FileStreamResult result, RangeItemHeaderValue range, long rangeLength) { if (context == null) { @@ -45,13 +60,14 @@ namespace Microsoft.AspNetCore.Mvc.Internal throw new ArgumentNullException(nameof(result)); } + if (range != null && rangeLength == 0) + { + return Task.CompletedTask; + } + var response = context.HttpContext.Response; var outputStream = response.Body; - - using (result.FileStream) - { - await result.FileStream.CopyToAsync(outputStream, BufferSize); - } + return WriteFileAsync(context.HttpContext, result.FileStream, range, rangeLength); } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/PhysicalFileResultExecutor.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/PhysicalFileResultExecutor.cs index c835790048..889630c68e 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/PhysicalFileResultExecutor.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/PhysicalFileResultExecutor.cs @@ -8,13 +8,12 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.Core; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc.Internal { public class PhysicalFileResultExecutor : FileResultExecutorBase { - private const int DefaultBufferSize = 0x1000; - public PhysicalFileResultExecutor(ILoggerFactory loggerFactory) : base(CreateLogger(loggerFactory)) { @@ -32,11 +31,30 @@ namespace Microsoft.AspNetCore.Mvc.Internal throw new ArgumentNullException(nameof(result)); } - SetHeadersAndLog(context, result); - return WriteFileAsync(context, result); + var fileInfo = GetFileInfo(result.FileName); + if (!fileInfo.Exists) + { + throw new FileNotFoundException( + Resources.FormatFileResult_InvalidPath(result.FileName), result.FileName); + } + + var lastModified = result.LastModified ?? fileInfo.LastModified; + var (range, rangeLength, serveBody) = SetHeadersAndLog( + context, + result, + fileInfo.Length, + lastModified, + result.EntityTag); + + if (serveBody) + { + return WriteFileAsync(context, result, range, rangeLength); + } + + return Task.CompletedTask; } - protected virtual async Task WriteFileAsync(ActionContext context, PhysicalFileResult result) + protected virtual Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue range, long rangeLength) { if (context == null) { @@ -48,8 +66,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal throw new ArgumentNullException(nameof(result)); } - var response = context.HttpContext.Response; + if (range != null && rangeLength == 0) + { + return Task.CompletedTask; + } + var response = context.HttpContext.Response; if (!Path.IsPathRooted(result.FileName)) { throw new NotSupportedException(Resources.FormatFileResult_PathNotRooted(result.FileName)); @@ -58,21 +80,23 @@ namespace Microsoft.AspNetCore.Mvc.Internal var sendFile = response.HttpContext.Features.Get(); if (sendFile != null) { - await sendFile.SendFileAsync( + if (range != null) + { + return sendFile.SendFileAsync( + result.FileName, + offset: range.From ?? 0L, + count: rangeLength, + cancellation: default(CancellationToken)); + } + + return sendFile.SendFileAsync( result.FileName, offset: 0, count: null, cancellation: default(CancellationToken)); } - else - { - var fileStream = GetFileStream(result.FileName); - using (fileStream) - { - await fileStream.CopyToAsync(response.Body, DefaultBufferSize); - } - } + return WriteFileAsync(context.HttpContext, GetFileStream(result.FileName), range, rangeLength); } protected virtual Stream GetFileStream(string path) @@ -87,8 +111,28 @@ namespace Microsoft.AspNetCore.Mvc.Internal FileMode.Open, FileAccess.Read, FileShare.ReadWrite, - DefaultBufferSize, + BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); } + + protected virtual FileMetadata GetFileInfo(string path) + { + var fileInfo = new FileInfo(path); + return new FileMetadata + { + Exists = fileInfo.Exists, + Length = fileInfo.Length, + LastModified = fileInfo.LastWriteTimeUtc, + }; + } + + protected class FileMetadata + { + public bool Exists { get; set; } + + public long Length { get; set; } + + public DateTimeOffset LastModified { get; set; } + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/VirtualFileResultExecutor.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/VirtualFileResultExecutor.cs index 770a933b9b..825de2686d 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/VirtualFileResultExecutor.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/VirtualFileResultExecutor.cs @@ -10,12 +10,12 @@ using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.Core; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; namespace Microsoft.AspNetCore.Mvc.Internal { public class VirtualFileResultExecutor : FileResultExecutorBase { - private const int DefaultBufferSize = 0x1000; private readonly IHostingEnvironment _hostingEnvironment; public VirtualFileResultExecutor(ILoggerFactory loggerFactory, IHostingEnvironment hostingEnvironment) @@ -41,11 +41,30 @@ namespace Microsoft.AspNetCore.Mvc.Internal throw new ArgumentNullException(nameof(result)); } - SetHeadersAndLog(context, result); - return WriteFileAsync(context, result); + var fileInfo = GetFileInformation(result); + if (!fileInfo.Exists) + { + throw new FileNotFoundException( + Resources.FormatFileResult_InvalidPath(result.FileName), result.FileName); + } + + var lastModified = result.LastModified ?? fileInfo.LastModified; + var (range, rangeLength, serveBody) = SetHeadersAndLog( + context, + result, + fileInfo.Length, + lastModified, + result.EntityTag); + + if (serveBody) + { + return WriteFileAsync(context, result, fileInfo, range, rangeLength); + } + + return Task.CompletedTask; } - protected virtual async Task WriteFileAsync(ActionContext context, VirtualFileResult result) + protected virtual Task WriteFileAsync(ActionContext context, VirtualFileResult result, IFileInfo fileInfo, RangeItemHeaderValue range, long rangeLength) { if (context == null) { @@ -57,7 +76,37 @@ namespace Microsoft.AspNetCore.Mvc.Internal throw new ArgumentNullException(nameof(result)); } + if (range != null && rangeLength == 0) + { + return Task.CompletedTask; + } + var response = context.HttpContext.Response; + var physicalPath = fileInfo.PhysicalPath; + var sendFile = response.HttpContext.Features.Get(); + if (sendFile != null && !string.IsNullOrEmpty(physicalPath)) + { + if (range != null) + { + return sendFile.SendFileAsync( + physicalPath, + offset: range.From ?? 0L, + count: rangeLength, + cancellation: default(CancellationToken)); + } + + return sendFile.SendFileAsync( + physicalPath, + offset: 0, + count: null, + cancellation: default(CancellationToken)); + } + + return WriteFileAsync(context.HttpContext, GetFileStream(fileInfo), range, rangeLength); + } + + private IFileInfo GetFileInformation(VirtualFileResult result) + { var fileProvider = GetFileProvider(result); var normalizedPath = result.FileName; @@ -67,32 +116,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal } var fileInfo = fileProvider.GetFileInfo(normalizedPath); - if (fileInfo.Exists) - { - var physicalPath = fileInfo.PhysicalPath; - var sendFile = response.HttpContext.Features.Get(); - if (sendFile != null && !string.IsNullOrEmpty(physicalPath)) - { - await sendFile.SendFileAsync( - physicalPath, - offset: 0, - count: null, - cancellation: default(CancellationToken)); - } - else - { - var fileStream = GetFileStream(fileInfo); - using (fileStream) - { - await fileStream.CopyToAsync(response.Body, DefaultBufferSize); - } - } - } - else - { - throw new FileNotFoundException( - Resources.FormatFileResult_InvalidPath(result.FileName), result.FileName); - } + return fileInfo; } private IFileProvider GetFileProvider(VirtualFileResult result) @@ -103,7 +127,6 @@ namespace Microsoft.AspNetCore.Mvc.Internal } result.FileProvider = _hostingEnvironment.WebRootFileProvider; - return result.FileProvider; } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj b/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj index 5acd398017..058082e23f 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj +++ b/src/Microsoft.AspNetCore.Mvc.Core/Microsoft.AspNetCore.Mvc.Core.csproj @@ -25,6 +25,8 @@ Microsoft.AspNetCore.Mvc.RouteAttribute + + @@ -37,6 +39,7 @@ Microsoft.AspNetCore.Mvc.RouteAttribute + diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ControllerBaseTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ControllerBaseTest.cs index 0a7de27d3c..49ad6abc6a 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ControllerBaseTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ControllerBaseTest.cs @@ -1427,6 +1427,31 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test Assert.Equal(string.Empty, result.FileDownloadName); } + [Theory] + [InlineData(null, null)] + [InlineData(null, "\"Etag\"")] + [InlineData("05/01/2008 +1:00", null)] + [InlineData("05/01/2008 +1:00", "\"Etag\"")] + public void File_WithContents_LastModifiedAndEtag(string lastModifiedString, string entityTagString) + { + // Arrange + var controller = new TestableController(); + var fileContents = new byte[0]; + var lastModified = (lastModifiedString == null) ? (DateTimeOffset?)null : DateTimeOffset.Parse(lastModifiedString); + var entityTag = (entityTagString == null) ? null : new EntityTagHeaderValue(entityTagString); + + // Act + var result = controller.File(fileContents, "application/pdf", lastModified, entityTag); + + // Assert + Assert.NotNull(result); + Assert.Same(fileContents, result.FileContents); + Assert.Equal("application/pdf", result.ContentType.ToString()); + Assert.Equal(string.Empty, result.FileDownloadName); + Assert.Equal(lastModified, result.LastModified); + Assert.Equal(entityTag, result.EntityTag); + } + [Fact] public void File_WithContentsAndFileDownloadName() { @@ -1444,6 +1469,31 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test Assert.Equal("someDownloadName", result.FileDownloadName); } + [Theory] + [InlineData(null, null)] + [InlineData(null, "\"Etag\"")] + [InlineData("05/01/2008 +1:00", null)] + [InlineData("05/01/2008 +1:00", "\"Etag\"")] + public void File_WithContentsAndFileDownloadName_LastModifiedAndEtag(string lastModifiedString, string entityTagString) + { + // Arrange + var controller = new TestableController(); + var fileContents = new byte[0]; + var lastModified = (lastModifiedString == null) ? (DateTimeOffset?)null : DateTimeOffset.Parse(lastModifiedString); + var entityTag = (entityTagString == null) ? null : new EntityTagHeaderValue(entityTagString); + + // Act + var result = controller.File(fileContents, "application/pdf", "someDownloadName", lastModified, entityTag); + + // Assert + Assert.NotNull(result); + Assert.Same(fileContents, result.FileContents); + Assert.Equal("application/pdf", result.ContentType.ToString()); + Assert.Equal("someDownloadName", result.FileDownloadName); + Assert.Equal(lastModified, result.LastModified); + Assert.Equal(entityTag, result.EntityTag); + } + [Fact] public void File_WithPath() { @@ -1461,6 +1511,31 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test Assert.Equal(string.Empty, result.FileDownloadName); } + [Theory] + [InlineData(null, null)] + [InlineData(null, "\"Etag\"")] + [InlineData("05/01/2008 +1:00", null)] + [InlineData("05/01/2008 +1:00", "\"Etag\"")] + public void File_WithPath_LastModifiedAndEtag(string lastModifiedString, string entityTagString) + { + // Arrange + var controller = new TestableController(); + var path = Path.GetFullPath("somepath"); + var lastModified = (lastModifiedString == null) ? (DateTimeOffset?)null : DateTimeOffset.Parse(lastModifiedString); + var entityTag = (entityTagString == null) ? null : new EntityTagHeaderValue(entityTagString); + + // Act + var result = controller.File(path, "application/pdf", lastModified, entityTag); + + // Assert + Assert.NotNull(result); + Assert.Equal(path, result.FileName); + Assert.Equal("application/pdf", result.ContentType.ToString()); + Assert.Equal(string.Empty, result.FileDownloadName); + Assert.Equal(lastModified, result.LastModified); + Assert.Equal(entityTag, result.EntityTag); + } + [Fact] public void File_WithPathAndFileDownloadName() { @@ -1478,6 +1553,31 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test Assert.Equal("someDownloadName", result.FileDownloadName); } + [Theory] + [InlineData(null, null)] + [InlineData(null, "\"Etag\"")] + [InlineData("05/01/2008 +1:00", null)] + [InlineData("05/01/2008 +1:00", "\"Etag\"")] + public void File_WithPathAndFileDownloadName_LastModifiedAndEtag(string lastModifiedString, string entityTagString) + { + // Arrange + var controller = new TestableController(); + var path = Path.GetFullPath("somepath"); + var lastModified = (lastModifiedString == null) ? (DateTimeOffset?)null : DateTimeOffset.Parse(lastModifiedString); + var entityTag = (entityTagString == null) ? null : new EntityTagHeaderValue(entityTagString); + + // Act + var result = controller.File(path, "application/pdf", "someDownloadName", lastModified, entityTag); + + // Assert + Assert.NotNull(result); + Assert.Equal(path, result.FileName); + Assert.Equal("application/pdf", result.ContentType.ToString()); + Assert.Equal("someDownloadName", result.FileDownloadName); + Assert.Equal(lastModified, result.LastModified); + Assert.Equal(entityTag, result.EntityTag); + } + [Fact] public void File_WithStream() { @@ -1500,6 +1600,36 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test Assert.Equal(string.Empty, result.FileDownloadName); } + [Theory] + [InlineData(null, null)] + [InlineData(null, "\"Etag\"")] + [InlineData("05/01/2008 +1:00", null)] + [InlineData("05/01/2008 +1:00", "\"Etag\"")] + public void File_WithStream_LastModifiedAndEtag(string lastModifiedString, string entityTagString) + { + // Arrange + var mockHttpContext = new Mock(); + mockHttpContext.Setup(x => x.Response.RegisterForDispose(It.IsAny())); + + var controller = new TestableController(); + controller.ControllerContext.HttpContext = mockHttpContext.Object; + + var fileStream = Stream.Null; + var lastModified = (lastModifiedString == null) ? (DateTimeOffset?)null : DateTimeOffset.Parse(lastModifiedString); + var entityTag = (entityTagString == null) ? null : new EntityTagHeaderValue(entityTagString); + + // Act + var result = controller.File(fileStream, "application/pdf", lastModified, entityTag); + + // Assert + Assert.NotNull(result); + Assert.Same(fileStream, result.FileStream); + Assert.Equal("application/pdf", result.ContentType.ToString()); + Assert.Equal(string.Empty, result.FileDownloadName); + Assert.Equal(lastModified, result.LastModified); + Assert.Equal(entityTag, result.EntityTag); + } + [Fact] public void File_WithStreamAndFileDownloadName() { @@ -1521,6 +1651,35 @@ namespace Microsoft.AspNetCore.Mvc.Core.Test Assert.Equal("someDownloadName", result.FileDownloadName); } + [Theory] + [InlineData(null, null)] + [InlineData(null, "\"Etag\"")] + [InlineData("05/01/2008 +1:00", null)] + [InlineData("05/01/2008 +1:00", "\"Etag\"")] + public void File_WithStreamAndFileDownloadName_LastModifiedAndEtag(string lastModifiedString, string entityTagString) + { + // Arrange + var mockHttpContext = new Mock(); + + var controller = new TestableController(); + controller.ControllerContext.HttpContext = mockHttpContext.Object; + + var fileStream = Stream.Null; + var lastModified = (lastModifiedString == null) ? (DateTimeOffset?)null : DateTimeOffset.Parse(lastModifiedString); + var entityTag = (entityTagString == null) ? null : new EntityTagHeaderValue(entityTagString); + + // Act + var result = controller.File(fileStream, "application/pdf", "someDownloadName", lastModified, entityTag); + + // Assert + Assert.NotNull(result); + Assert.Same(fileStream, result.FileStream); + Assert.Equal("application/pdf", result.ContentType.ToString()); + Assert.Equal("someDownloadName", result.FileDownloadName); + Assert.Equal(lastModified, result.LastModified); + Assert.Equal(entityTag, result.EntityTag); + } + [Fact] public void HttpUnauthorized_SetsStatusCode() { diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/FileContentResultTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/FileContentResultTest.cs index 72bb0c36b5..0c913f5c7e 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/FileContentResultTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/FileContentResultTest.cs @@ -1,7 +1,9 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.IO; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -11,6 +13,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Net.Http.Headers; using Xunit; namespace Microsoft.AspNetCore.Mvc @@ -46,6 +49,29 @@ namespace Microsoft.AspNetCore.Mvc MediaTypeAssert.Equal(expectedMediaType, result.ContentType); } + [Fact] + public void Constructor_SetsLastModifiedAndEtag() + { + // Arrange + var fileContents = new byte[0]; + var contentType = "text/plain"; + var expectedMediaType = contentType; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + + // Act + var result = new FileContentResult(fileContents, contentType) + { + LastModified = lastModified, + EntityTag = entityTag + }; + + // Assert + Assert.Equal(lastModified, result.LastModified); + Assert.Equal(entityTag, result.EntityTag); + MediaTypeAssert.Equal(expectedMediaType, result.ContentType); + } + [Fact] public async Task WriteFileAsync_CopiesBuffer_ToOutputStream() { @@ -68,6 +94,268 @@ namespace Microsoft.AspNetCore.Mvc Assert.Equal(buffer, outStream.ToArray()); } + [Theory] + [InlineData(0, 4, "Hello", 5)] + [InlineData(6, 10, "World", 5)] + [InlineData(null, 5, "World", 5)] + [InlineData(6, null, "World", 5)] + public async Task WriteFileAsync_PreconditionStateShouldProcess_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + + var result = new FileContentResult(byteArray, contentType) + { + LastModified = lastModified, + EntityTag = entityTag + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.Range = new RangeHeaderValue(start, end); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + start = start ?? 11 - end; + end = start + contentLength - 1; + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + var contentRange = new ContentRangeHeaderValue(start.Value, end.Value, byteArray.Length); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Equal(contentLength, httpResponse.ContentLength); + Assert.Equal(expectedString, body); + } + + [Fact] + public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() + { + // Arrange + var contentType = "text/plain"; + var lastModified = DateTimeOffset.MinValue; + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + + var result = new FileContentResult(byteArray, contentType) + { + LastModified = lastModified, + EntityTag = entityTag + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + requestHeaders.Range = new RangeHeaderValue(0, 4); + requestHeaders.IfRange = new RangeConditionHeaderValue(DateTimeOffset.MinValue); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + var contentRange = new ContentRangeHeaderValue(0, 4, byteArray.Length); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Equal(5, httpResponse.ContentLength); + Assert.Equal("Hello", body); + } + + [Fact] + public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() + { + // Arrange + var contentType = "text/plain"; + var lastModified = DateTimeOffset.MinValue.AddDays(1); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + + var result = new FileContentResult(byteArray, contentType) + { + LastModified = lastModified, + EntityTag = entityTag + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + requestHeaders.Range = new RangeHeaderValue(0, 4); + requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"NotEtag\"")); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Equal("Hello World", body); + } + + [Theory] + [InlineData("0-5")] + [InlineData("bytes = 11-0")] + [InlineData("bytes = 1-4, 5-11")] + public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestedNotSatisfiable(string rangeString) + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + + var result = new FileContentResult(byteArray, contentType) + { + LastModified = lastModified, + EntityTag = entityTag + }; + + var httpContext = GetHttpContext(); + httpContext.Request.Headers[HeaderNames.Range] = rangeString; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + var contentRange = new ContentRangeHeaderValue(byteArray.Length); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Empty(body); + } + + [Fact] + public async Task WriteFileAsync_RangeRequested_PreconditionFailed() + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + + var result = new FileContentResult(byteArray, contentType) + { + LastModified = lastModified, + EntityTag = entityTag + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"NotEtag\""), + }; + httpContext.Request.Headers[HeaderNames.Range] = "bytes = 0-6"; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status412PreconditionFailed, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Empty(httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Null(httpResponse.ContentLength); + Assert.Empty(body); + } + + [Fact] + public async Task WriteFileAsync_RangeRequested_NotModified() + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + + var result = new FileContentResult(byteArray, contentType) + { + LastModified = lastModified, + EntityTag = entityTag + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfNoneMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + httpContext.Request.Headers[HeaderNames.Range] = "bytes = 0-6"; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status304NotModified, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Empty(httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Null(httpResponse.ContentLength); + Assert.Empty(body); + } + [Fact] public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() { diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs index dbab62b669..aea1c3dab1 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/FileResultTest.cs @@ -250,6 +250,160 @@ namespace Microsoft.AspNetCore.Mvc Assert.Equal(expectedOutput, actual); } + [Fact] + public async Task SetsAcceptRangeHeader() + { + // Arrange + var httpContext = GetHttpContext(); + var actionContext = CreateActionContext(httpContext); + + var result = new EmptyFileResult("application/my-type"); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + Assert.Equal("bytes", httpContext.Response.Headers[HeaderNames.AcceptRanges]); + } + + [Theory] + [InlineData("\"Etag\"", "\"NotEtag\"", "\"Etag\"")] + [InlineData("\"Etag\"", null, null)] + [InlineData(null, "\"NotEtag\"", "\"Etag\"")] + public void GetPreconditionState_ShouldProcess(string ifMatch, string ifNoneMatch, string ifRange) + { + // Arrange + var actionContext = new ActionContext(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = HttpMethods.Get; + var httpRequestHeaders = httpContext.Request.GetTypedHeaders(); + var lastModified = DateTimeOffset.MinValue; + lastModified = new DateTimeOffset(lastModified.Year, lastModified.Month, lastModified.Day, lastModified.Hour, lastModified.Minute, lastModified.Second, TimeSpan.FromSeconds(0)); + var etag = new EntityTagHeaderValue("\"Etag\""); + httpRequestHeaders.IfMatch = ifMatch == null ? null : new[] + { + new EntityTagHeaderValue(ifMatch), + }; + + httpRequestHeaders.IfNoneMatch = ifNoneMatch == null ? null : new[] + { + new EntityTagHeaderValue(ifNoneMatch), + }; + httpRequestHeaders.IfRange = ifRange == null ? null : new RangeConditionHeaderValue(ifRange); + httpRequestHeaders.IfUnmodifiedSince = lastModified; + httpRequestHeaders.IfModifiedSince = DateTimeOffset.MinValue.AddDays(1); + actionContext.HttpContext = httpContext; + + // Act + var state = FileResultExecutorBase.GetPreconditionState( + actionContext, + httpRequestHeaders, + lastModified, + etag); + + // Assert + Assert.Equal(FileResultExecutorBase.PreconditionState.ShouldProcess, state); + } + + [Theory] + [InlineData("\"NotEtag\"", null)] + [InlineData("\"Etag\"", "\"Etag\"")] + [InlineData(null, null)] + public void GetPreconditionState_ShouldNotProcess_PreconditionFailed(string ifMatch, string ifNoneMatch) + { + // Arrange + var actionContext = new ActionContext(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = HttpMethods.Delete; + var httpRequestHeaders = httpContext.Request.GetTypedHeaders(); + var lastModified = DateTimeOffset.MinValue.AddDays(1); + var etag = new EntityTagHeaderValue("\"Etag\""); + httpRequestHeaders.IfMatch = ifMatch == null ? null : new[] + { + new EntityTagHeaderValue(ifMatch), + }; + + httpRequestHeaders.IfNoneMatch = ifNoneMatch == null ? null : new[] + { + new EntityTagHeaderValue(ifNoneMatch), + }; + httpRequestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue; + httpRequestHeaders.IfModifiedSince = DateTimeOffset.MinValue.AddDays(2); + actionContext.HttpContext = httpContext; + + // Act + var state = FileResultExecutorBase.GetPreconditionState( + actionContext, + httpRequestHeaders, + lastModified, + etag); + + // Assert + Assert.Equal(FileResultExecutorBase.PreconditionState.PreconditionFailed, state); + } + + [Theory] + [InlineData(null, "\"Etag\"")] + [InlineData(null, null)] + public void GetPreconditionState_ShouldNotProcess_NotModified(string ifMatch, string ifNoneMatch) + { + // Arrange + var actionContext = new ActionContext(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = HttpMethods.Get; + var httpRequestHeaders = httpContext.Request.GetTypedHeaders(); + var lastModified = DateTimeOffset.MinValue; + lastModified = new DateTimeOffset(lastModified.Year, lastModified.Month, lastModified.Day, lastModified.Hour, lastModified.Minute, lastModified.Second, TimeSpan.FromSeconds(0)); + var etag = new EntityTagHeaderValue("\"Etag\""); + httpRequestHeaders.IfMatch = ifMatch == null ? null : new[] + { + new EntityTagHeaderValue(ifMatch), + }; + + httpRequestHeaders.IfNoneMatch = ifNoneMatch == null ? null : new[] + { + new EntityTagHeaderValue(ifNoneMatch), + }; + httpRequestHeaders.IfModifiedSince = lastModified; + actionContext.HttpContext = httpContext; + + // Act + var state = FileResultExecutorBase.GetPreconditionState( + actionContext, + httpRequestHeaders, + lastModified, + etag); + + // Assert + Assert.Equal(FileResultExecutorBase.PreconditionState.NotModified, state); + } + + [Fact] + public void GetPreconditionState_ShouldNotProcess_IgnoreRangeRequest() + { + // Arrange + var actionContext = new ActionContext(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = HttpMethods.Get; + var httpRequestHeaders = httpContext.Request.GetTypedHeaders(); + var lastModified = DateTimeOffset.MinValue; + lastModified = new DateTimeOffset(lastModified.Year, lastModified.Month, lastModified.Day, lastModified.Hour, lastModified.Minute, lastModified.Second, TimeSpan.FromSeconds(0)); + var etag = new EntityTagHeaderValue("\"Etag\""); + httpRequestHeaders.IfRange = new RangeConditionHeaderValue("\"NotEtag\""); + httpRequestHeaders.IfModifiedSince = lastModified; + actionContext.HttpContext = httpContext; + + // Act + var state = FileResultExecutorBase.GetPreconditionState( + actionContext, + httpRequestHeaders, + lastModified, + etag); + + // Assert + Assert.Equal(FileResultExecutorBase.PreconditionState.IgnoreRangeRequest, state); + } + private static IServiceCollection CreateServices() { var services = new ServiceCollection(); @@ -297,13 +451,13 @@ namespace Microsoft.AspNetCore.Mvc private class EmptyFileResultExecutor : FileResultExecutorBase { public EmptyFileResultExecutor(ILoggerFactory loggerFactory) - :base(CreateLogger(loggerFactory)) + : base(CreateLogger(loggerFactory)) { } public Task ExecuteAsync(ActionContext context, EmptyFileResult result) { - SetHeadersAndLog(context, result); + SetHeadersAndLog(context, result, 0L); result.WasWriteFileCalled = true; return Task.FromResult(0); } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/FileStreamResultTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/FileStreamResultTest.cs index 937b81b53d..cb8ab453ce 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/FileStreamResultTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/FileStreamResultTest.cs @@ -1,8 +1,10 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -13,6 +15,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Net.Http.Headers; using Moq; using Xunit; @@ -49,6 +52,348 @@ namespace Microsoft.AspNetCore.Mvc MediaTypeAssert.Equal(expectedMediaType, result.ContentType); } + [Fact] + public void Constructor_SetsLastModifiedAndEtag() + { + // Arrange + var stream = Stream.Null; + var contentType = "text/plain"; + var expectedMediaType = contentType; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + + // Act + var result = new FileStreamResult(stream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + + // Assert + Assert.Equal(lastModified, result.LastModified); + Assert.Equal(entityTag, result.EntityTag); + MediaTypeAssert.Equal(expectedMediaType, result.ContentType); + } + + [Theory] + [InlineData(0, 4, "Hello", 5)] + [InlineData(6, 10, "World", 5)] + [InlineData(null, 5, "World", 5)] + [InlineData(6, null, "World", 5)] + public async Task WriteFileAsync_PreconditionStateShouldProcess_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + readStream.SetLength(11); + + var result = new FileStreamResult(readStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.Range = new RangeHeaderValue(start, end); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + start = start ?? 11 - end; + end = start + contentLength - 1; + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + var contentRange = new ContentRangeHeaderValue(start.Value, end.Value, byteArray.Length); + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Equal(contentLength, httpResponse.ContentLength); + Assert.Equal(expectedString, body); + } + + [Fact] + public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() + { + // Arrange + var contentType = "text/plain"; + var lastModified = DateTimeOffset.MinValue; + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + readStream.SetLength(11); + + var result = new FileStreamResult(readStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + requestHeaders.Range = new RangeHeaderValue(0, 4); + requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + var contentRange = new ContentRangeHeaderValue(0, 4, byteArray.Length); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Equal(5, httpResponse.ContentLength); + Assert.Equal("Hello", body); + } + + [Fact] + public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() + { + // Arrange + var contentType = "text/plain"; + var lastModified = DateTimeOffset.MinValue.AddDays(1); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + readStream.SetLength(11); + + var result = new FileStreamResult(readStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + requestHeaders.Range = new RangeHeaderValue(0, 4); + requestHeaders.IfRange = new RangeConditionHeaderValue(DateTimeOffset.MinValue); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Equal("Hello World", body); + } + + [Theory] + [InlineData("0-5")] + [InlineData("bytes = 11-0")] + [InlineData("bytes = 1-4, 5-11")] + public async Task WriteFileAsync_PreconditionStateUnspecified_RangeRequestedNotSatisfiable(string rangeString) + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + + var result = new FileStreamResult(readStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + httpContext.Request.Headers[HeaderNames.Range] = rangeString; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + var contentRange = new ContentRangeHeaderValue(byteArray.Length); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Empty(body); + } + + [Fact] + public async Task WriteFileAsync_RangeRequested_PreconditionFailed() + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + + var result = new FileStreamResult(readStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"NotEtag\""), + }; + httpContext.Request.Headers[HeaderNames.Range] = "bytes = 0-6"; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status412PreconditionFailed, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Empty(httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Null(httpResponse.ContentLength); + Assert.Empty(body); + } + + [Fact] + public async Task WriteFileAsync_RangeRequested_NotModified() + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes("Hello World"); + var readStream = new MemoryStream(byteArray); + + var result = new FileStreamResult(readStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfNoneMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + httpContext.Request.Headers[HeaderNames.Range] = "bytes = 0-6"; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status304NotModified, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Empty(httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Null(httpResponse.ContentLength); + Assert.Empty(body); + } + + [Theory] + [InlineData(0)] + [InlineData(null)] + public async Task WriteFileAsync_RangeRequested_FileLengthZeroOrNull(long? fileLength) + { + // Arrange + var contentType = "text/plain"; + var lastModified = new DateTimeOffset(); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + var byteArray = Encoding.ASCII.GetBytes(""); + var readStream = new MemoryStream(byteArray); + fileLength = fileLength ?? 0L; + readStream.SetLength(fileLength.Value); + var result = new FileStreamResult(readStream, contentType) + { + LastModified = lastModified, + EntityTag = entityTag, + }; + + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.Range = new RangeHeaderValue(0, 5); + requestHeaders.IfMatch = new[] + { + new EntityTagHeaderValue("\"Etag\""), + }; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + + var contentRange = new ContentRangeHeaderValue(byteArray.Length); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.Equal(lastModified.ToString("R"), httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Empty(body); + } + [Fact] public async Task WriteFileAsync_WritesResponse_InChunksOfFourKilobytes() { @@ -153,7 +498,6 @@ namespace Microsoft.AspNetCore.Mvc var httpContext = new DefaultHttpContext(); httpContext.RequestServices = services.BuildServiceProvider(); - return httpContext; } } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/PhysicalFileResultTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/PhysicalFileResultTest.cs index 8731208531..210b64c6a6 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/PhysicalFileResultTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/PhysicalFileResultTest.cs @@ -6,7 +6,6 @@ using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -16,6 +15,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Net.Http.Headers; using Moq; using Xunit; @@ -52,6 +52,198 @@ namespace Microsoft.AspNetCore.Mvc MediaTypeAssert.Equal(expectedMediaType, result.ContentType); } + [Theory] + [InlineData(0, 3, "File", 4)] + [InlineData(8, 13, "Result", 6)] + [InlineData(null, 5, "ts�", 5)] + [InlineData(8, null, "ResultTestFile contents�", 26)] + public async Task WriteFileAsync_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) + { + // Arrange + var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); + var result = new TestPhysicalFileResult(path, "text/plain"); + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; + requestHeaders.Range = new RangeHeaderValue(start, end); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + start = start ?? 34 - end; + end = start + contentLength - 1; + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + var contentRange = new ContentRangeHeaderValue(start.Value, end.Value, 34); + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(contentLength, httpResponse.ContentLength); + Assert.Equal(expectedString, body); + } + + [Fact] + public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() + { + // Arrange + var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); + var result = new TestPhysicalFileResult(path, "text/plain"); + var entityTag = result.EntityTag = new EntityTagHeaderValue("\"Etag\""); + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; + requestHeaders.Range = new RangeHeaderValue(0, 3); + requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + var contentRange = new ContentRangeHeaderValue(0, 3, 34); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Equal(4, httpResponse.ContentLength); + Assert.Equal("File", body); + } + + [Fact] + public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() + { + // Arrange + var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); + var result = new TestPhysicalFileResult(path, "text/plain"); + var entityTag = result.EntityTag = new EntityTagHeaderValue("\"Etag\""); + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; + requestHeaders.Range = new RangeHeaderValue(0, 3); + requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"NotEtag\"")); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal("FilePathResultTestFile contents�", body); + } + + [Theory] + [InlineData("0-5")] + [InlineData("bytes = 11-0")] + [InlineData("bytes = 1-4, 5-11")] + public async Task WriteFileAsync_RangeRequested_NotSatisfiable(string rangeString) + { + // Arrange + var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); + var result = new TestPhysicalFileResult(path, "text/plain"); + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; + httpContext.Request.Headers[HeaderNames.Range] = rangeString; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + var contentRange = new ContentRangeHeaderValue(34); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Empty(body); + } + + [Fact] + public async Task WriteFileAsync_RangeRequested_PreconditionFailed() + { + // Arrange + var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); + var result = new TestPhysicalFileResult(path, "text/plain"); + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue; + httpContext.Request.Headers[HeaderNames.Range] = "bytes = 0-6"; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status412PreconditionFailed, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Empty(httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Null(httpResponse.ContentLength); + Assert.Empty(body); + } + + [Fact] + public async Task WriteFileAsync_RangeRequested_NotModified() + { + // Arrange + var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); + var result = new TestPhysicalFileResult(path, "text/plain"); + var httpContext = GetHttpContext(); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfModifiedSince = DateTimeOffset.MinValue.AddDays(1); + httpContext.Request.Headers[HeaderNames.Range] = "bytes = 0-6"; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status304NotModified, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Empty(httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Null(httpResponse.ContentLength); + Assert.Empty(body); + } + [Fact] public async Task ExecuteResultAsync_FallsbackToStreamCopy_IfNoIHttpSendFilePresent() { @@ -94,6 +286,46 @@ namespace Microsoft.AspNetCore.Mvc sendFileMock.Verify(); } + [Theory] + [InlineData(0, 3, "File", 4)] + [InlineData(8, 13, "Result", 6)] + [InlineData(null, 3, "ts¡", 3)] + [InlineData(8, null, "ResultTestFile contents¡", 26)] + public async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent(long? start, long? end, string expectedString, long contentLength) + { + // Arrange + var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); + var result = new TestPhysicalFileResult(path, "text/plain"); + + var sendFile = new TestSendFileFeature(); + var httpContext = GetHttpContext(); + httpContext.Features.Set(sendFile); + var context = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.Range = new RangeHeaderValue(start, end); + requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue.AddDays(1); + httpContext.Request.Method = HttpMethods.Get; + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + start = start ?? 34 - end; + end = start + contentLength - 1; + var httpResponse = actionContext.HttpContext.Response; + Assert.Equal(Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")), sendFile.Name); + Assert.Equal(start, sendFile.Offset); + Assert.Equal(contentLength, sendFile.Length); + Assert.Equal(CancellationToken.None, sendFile.Token); + var contentRange = new ContentRangeHeaderValue(start.Value, end.Value, 34); + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(contentLength, httpResponse.ContentLength); + } + [Fact] public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() { @@ -236,6 +468,35 @@ namespace Microsoft.AspNetCore.Mvc return new MemoryStream(Encoding.UTF8.GetBytes("FilePathResultTestFile contents�")); } } + + protected override FileMetadata GetFileInfo(string path) + { + var lastModified = DateTimeOffset.MinValue.AddDays(1); + return new FileMetadata + { + Exists = true, + Length = 34, + LastModified = new DateTimeOffset(lastModified.Year, lastModified.Month, lastModified.Day, lastModified.Hour, lastModified.Minute, lastModified.Second, TimeSpan.FromSeconds(0)) + }; + } + } + + private class TestSendFileFeature : IHttpSendFileFeature + { + public string Name { get; set; } + public long Offset { get; set; } + public long? Length { get; set; } + public CancellationToken Token { get; set; } + + public Task SendFileAsync(string path, long offset, long? length, CancellationToken cancellation) + { + Name = path; + Offset = offset; + Length = length; + Token = cancellation; + + return Task.FromResult(0); + } } private static IServiceCollection CreateServices() diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/VirtualFileResultTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/VirtualFileResultTest.cs index 2cb6e5a0fe..4034bbb0c1 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/VirtualFileResultTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/VirtualFileResultTest.cs @@ -17,6 +17,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Net.Http.Headers; using Moq; using Xunit; @@ -51,8 +52,272 @@ namespace Microsoft.AspNetCore.Mvc // Assert Assert.Equal(path, result.FileName); MediaTypeAssert.Equal(expectedMediaType, result.ContentType); - } + + [Theory] + [InlineData(0, 3, "File", 4)] + [InlineData(8, 13, "Result", 6)] + [InlineData(null, 4, "ts¡", 4)] + [InlineData(8, null, "ResultTestFile contents¡", 25)] + public async Task WriteFileAsync_WritesRangeRequested(long? start, long? end, string expectedString, long contentLength) + { + // Arrange + var path = Path.GetFullPath("helllo.txt"); + var contentType = "text/plain; charset=us-ascii; p1=p1-value"; + var result = new TestVirtualFileResult(path, contentType); + var appEnvironment = new Mock(); + appEnvironment.Setup(app => app.WebRootFileProvider) + .Returns(GetFileProvider(path)); + + var httpContext = GetHttpContext(); + httpContext.Response.Body = new MemoryStream(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(appEnvironment.Object) + .AddTransient() + .AddTransient() + .BuildServiceProvider(); + + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.Range = new RangeHeaderValue(start, end); + requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue.AddDays(1); + httpContext.Request.Method = HttpMethods.Get; + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + start = start ?? 33 - end; + end = start + contentLength - 1; + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + var contentRange = new ContentRangeHeaderValue(start.Value, end.Value, 33); + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(contentLength, httpResponse.ContentLength); + Assert.Equal(expectedString, body); + } + + [Fact] + public async Task WriteFileAsync_IfRangeHeaderValid_WritesRequestedRange() + { + // Arrange + var path = Path.GetFullPath("helllo.txt"); + var contentType = "text/plain; charset=us-ascii; p1=p1-value"; + var result = new TestVirtualFileResult(path, contentType); + var appEnvironment = new Mock(); + appEnvironment.Setup(app => app.WebRootFileProvider) + .Returns(GetFileProvider(path)); + + var httpContext = GetHttpContext(); + httpContext.Response.Body = new MemoryStream(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(appEnvironment.Object) + .AddTransient() + .AddTransient() + .BuildServiceProvider(); + + var entityTag = result.EntityTag = new EntityTagHeaderValue("\"Etag\""); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; + requestHeaders.Range = new RangeHeaderValue(0, 3); + requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + var contentRange = new ContentRangeHeaderValue(0, 3, 33); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Equal(4, httpResponse.ContentLength); + Assert.Equal("File", body); + } + + [Fact] + public async Task WriteFileAsync_IfRangeHeaderInvalid_RangeRequestedIgnored() + { + // Arrange + var path = Path.GetFullPath("helllo.txt"); + var contentType = "text/plain; charset=us-ascii; p1=p1-value"; + var result = new TestVirtualFileResult(path, contentType); + var appEnvironment = new Mock(); + appEnvironment.Setup(app => app.WebRootFileProvider) + .Returns(GetFileProvider(path)); + + var httpContext = GetHttpContext(); + httpContext.Response.Body = new MemoryStream(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(appEnvironment.Object) + .AddTransient() + .AddTransient() + .BuildServiceProvider(); + + var entityTag = result.EntityTag = new EntityTagHeaderValue("\"Etag\""); + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfModifiedSince = DateTimeOffset.MinValue; + requestHeaders.Range = new RangeHeaderValue(0, 3); + requestHeaders.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"NotEtag\"")); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status200OK, httpResponse.StatusCode); + Assert.Equal(entityTag.ToString(), httpResponse.Headers[HeaderNames.ETag]); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal("FilePathResultTestFile contents¡", body); + } + + [Theory] + [InlineData("0-5")] + [InlineData("bytes = 11-0")] + [InlineData("bytes = 1-4, 5-11")] + public async Task WriteFileAsync_RangeRequested_NotSatisfiable(string rangeString) + { + // Arrange + var path = Path.GetFullPath("helllo.txt"); + var contentType = "text/plain; charset=us-ascii; p1=p1-value"; + var result = new TestVirtualFileResult(path, contentType); + var appEnvironment = new Mock(); + appEnvironment.Setup(app => app.WebRootFileProvider) + .Returns(GetFileProvider(path)); + + var httpContext = GetHttpContext(); + httpContext.Response.Body = new MemoryStream(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(appEnvironment.Object) + .AddTransient() + .AddTransient() + .BuildServiceProvider(); + + var requestHeaders = httpContext.Request.GetTypedHeaders(); + httpContext.Request.Headers[HeaderNames.Range] = rangeString; + requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue.AddDays(1); + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + var contentRange = new ContentRangeHeaderValue(33); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Empty(body); + } + + [Fact] + public async Task WriteFileAsync_RangeRequested_PreconditionFailed() + { + // Arrange + var path = Path.GetFullPath("helllo.txt"); + var contentType = "text/plain; charset=us-ascii; p1=p1-value"; + var result = new TestVirtualFileResult(path, contentType); + var appEnvironment = new Mock(); + appEnvironment.Setup(app => app.WebRootFileProvider) + .Returns(GetFileProvider(path)); + + var httpContext = GetHttpContext(); + httpContext.Response.Body = new MemoryStream(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(appEnvironment.Object) + .AddTransient() + .AddTransient() + .BuildServiceProvider(); + + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue; + httpContext.Request.Headers[HeaderNames.Range] = "bytes = 0-6"; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status412PreconditionFailed, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Empty(httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Null(httpResponse.ContentLength); + Assert.Empty(body); + } + + [Fact] + public async Task WriteFileAsync_RangeRequested_NotModified() + { + // Arrange + var path = Path.GetFullPath("helllo.txt"); + var contentType = "text/plain; charset=us-ascii; p1=p1-value"; + var result = new TestVirtualFileResult(path, contentType); + var appEnvironment = new Mock(); + appEnvironment.Setup(app => app.WebRootFileProvider) + .Returns(GetFileProvider(path)); + + var httpContext = GetHttpContext(); + httpContext.Response.Body = new MemoryStream(); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(appEnvironment.Object) + .AddTransient() + .AddTransient() + .BuildServiceProvider(); + + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.IfModifiedSince = DateTimeOffset.MinValue.AddDays(1); + httpContext.Request.Headers[HeaderNames.Range] = "bytes = 0-6"; + httpContext.Request.Method = HttpMethods.Get; + httpContext.Response.Body = new MemoryStream(); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var httpResponse = actionContext.HttpContext.Response; + httpResponse.Body.Seek(0, SeekOrigin.Begin); + var streamReader = new StreamReader(httpResponse.Body); + var body = streamReader.ReadToEndAsync().Result; + Assert.Equal(StatusCodes.Status304NotModified, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Empty(httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Null(httpResponse.ContentLength); + Assert.Empty(body); + } + [Fact] public async Task ExecuteResultAsync_FallsBackToWebRootFileProvider_IfNoFileProviderIsPresent() { @@ -133,6 +398,58 @@ namespace Microsoft.AspNetCore.Mvc sendFileMock.Verify(); } + [Theory] + [InlineData(0, 3, "File", 4)] + [InlineData(8, 13, "Result", 6)] + [InlineData(null, 3, "ts¡", 3)] + [InlineData(8, null, "ResultTestFile contents¡", 25)] + public async Task ExecuteResultAsync_CallsSendFileAsyncWithRequestedRange_IfIHttpSendFilePresent(long? start, long? end, string expectedString, long contentLength) + { + // Arrange + var path = Path.Combine("TestFiles", "FilePathResultTestFile.txt"); + var result = new TestVirtualFileResult(path, "text/plain") + { + FileProvider = GetFileProvider(path), + }; + + var sendFile = new TestSendFileFeature(); + var httpContext = GetHttpContext(); + httpContext.Features.Set(sendFile); + var context = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + var appEnvironment = new Mock(); + appEnvironment.Setup(app => app.WebRootFileProvider) + .Returns(GetFileProvider(path)); + httpContext.RequestServices = new ServiceCollection() + .AddSingleton(appEnvironment.Object) + .AddTransient() + .AddTransient() + .BuildServiceProvider(); + + var requestHeaders = httpContext.Request.GetTypedHeaders(); + requestHeaders.Range = new RangeHeaderValue(start, end); + requestHeaders.IfUnmodifiedSince = DateTimeOffset.MinValue.AddDays(1); + httpContext.Request.Method = HttpMethods.Get; + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + start = start ?? 33 - end; + end = start + contentLength - 1; + var httpResponse = actionContext.HttpContext.Response; + Assert.Equal(Path.Combine("TestFiles", "FilePathResultTestFile.txt"), sendFile.Name); + Assert.Equal(start, sendFile.Offset); + Assert.Equal(contentLength, sendFile.Length); + Assert.Equal(CancellationToken.None, sendFile.Token); + var contentRange = new ContentRangeHeaderValue(start.Value, end.Value, 33); + Assert.Equal(StatusCodes.Status206PartialContent, httpResponse.StatusCode); + Assert.Equal("bytes", httpResponse.Headers[HeaderNames.AcceptRanges]); + Assert.Equal(contentRange.ToString(), httpResponse.Headers[HeaderNames.ContentRange]); + Assert.NotEmpty(httpResponse.Headers[HeaderNames.LastModified]); + Assert.Equal(contentLength, httpResponse.ContentLength); + } + [Fact] public async Task ExecuteResultAsync_SetsSuppliedContentTypeAndEncoding() { @@ -325,7 +642,11 @@ namespace Microsoft.AspNetCore.Mvc private static IFileProvider GetFileProvider(string path) { var fileInfo = new Mock(); + fileInfo.SetupGet(fi => fi.Length).Returns(33); fileInfo.SetupGet(fi => fi.Exists).Returns(true); + var lastModified = DateTimeOffset.MinValue.AddDays(1); + lastModified = new DateTimeOffset(lastModified.Year, lastModified.Month, lastModified.Day, lastModified.Hour, lastModified.Minute, lastModified.Second, TimeSpan.FromSeconds(0)); + fileInfo.SetupGet(fi => fi.LastModified).Returns(lastModified); fileInfo.SetupGet(fi => fi.PhysicalPath).Returns(path); var fileProvider = new Mock(); fileProvider.Setup(fp => fp.GetFileInfo(path)) @@ -355,7 +676,7 @@ namespace Microsoft.AspNetCore.Mvc private class TestVirtualFileResultExecutor : VirtualFileResultExecutor { public TestVirtualFileResultExecutor(ILoggerFactory loggerFactory, IHostingEnvironment hostingEnvironment) - : base(loggerFactory,hostingEnvironment) + : base(loggerFactory, hostingEnvironment) { } @@ -373,5 +694,23 @@ namespace Microsoft.AspNetCore.Mvc } } } + + private class TestSendFileFeature : IHttpSendFileFeature + { + public string Name { get; set; } + public long Offset { get; set; } + public long? Length { get; set; } + public CancellationToken Token { get; set; } + + public Task SendFileAsync(string path, long offset, long? length, CancellationToken cancellation) + { + Name = path; + Offset = offset; + Length = length; + Token = cancellation; + + return Task.FromResult(0); + } + } } } \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs index a37acbfd6b..a87f20754d 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/FileResultTests.cs @@ -1,8 +1,11 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.IO; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; using System.Threading.Tasks; using Microsoft.AspNetCore.Testing.xunit; using Xunit; @@ -37,6 +40,92 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("This is a sample text file", body); } + [Theory] + [InlineData(0, 6, "This is")] + [InlineData(17, 25, "text file")] + [InlineData(0, 50, "This is a sample text file")] + public async Task FileFromDisk_CanBeEnabled_WithMiddleware_RangeRequest(long start, long end, string expectedBody) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromDisk"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(start, end); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal(expectedBody, body); + } + + [Theory] + [InlineData("0-6")] + [InlineData("bytes = 11-6")] + [InlineData("bytes = 1-4, 5-11")] + public async Task FileFromDisk_CanBeEnabled_WithMiddleware_RangeRequestNotSatisfiable(string rangeString) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromDisk"); + httpRequestMessage.Headers.TryAddWithoutValidation("Range", rangeString); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.Empty(body); + } + + [Theory] + [InlineData(0, 6, "This is")] + [InlineData(17, 25, "text file")] + [InlineData(0, 50, "This is a sample text file")] + public async Task FileFromDisk_CanBeEnabled_WithMiddleware_RangeRequest_WithLastModifiedAndEtag(long start, long end, string expectedBody) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromDisk_WithLastModifiedAndEtag"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(start, end); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal(expectedBody, body); + } + + [Theory] + [InlineData("0-6")] + [InlineData("bytes = 11-6")] + [InlineData("bytes = 1-4, 5-11")] + public async Task FileFromDisk_CanBeEnabled_WithMiddleware_RangeRequestNotSatisfiable_WithLastModifiedAndEtag(string rangeString) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromDiskWithFileName_WithLastModifiedAndEtag"); + httpRequestMessage.Headers.TryAddWithoutValidation("Range", rangeString); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.Empty(body); + } + [ConditionalFact] // https://github.com/aspnet/Mvc/issues/2727 [FrameworkSkipCondition(RuntimeFrameworks.Mono)] @@ -60,6 +149,45 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); } + [Fact] + public async Task FileFromDisk_ReturnsFileWithFileName_IfRangeHeaderValid_RangeRequest_WithLastModifiedAndEtag() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromDiskWithFileName_WithLastModifiedAndEtag"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal("This is", body); + } + + [Fact] + public async Task FileFromDisk_ReturnsFileWithFileName_IfRangeHeaderInvalid_RangeRequestIgnored_WithLastModifiedAndEtag() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromDiskWithFileName_WithLastModifiedAndEtag"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"NotEtag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("This is a sample text file", body); + } + [Fact] public async Task FileFromStream_ReturnsFile() { @@ -77,6 +205,49 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("This is sample text from a stream", body); } + [Theory] + [InlineData(0, 6, "This is")] + [InlineData(25, 32, "a stream")] + [InlineData(0, 50, "This is sample text from a stream")] + public async Task FileFromStream_ReturnsFile_RangeRequest(long start, long end, string expectedBody) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromStream"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(start, end); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal(expectedBody, body); + } + + [Theory] + [InlineData("0-6")] + [InlineData("bytes = 11-6")] + [InlineData("bytes = 1-4, 5-11")] + public async Task FileFromStream_ReturnsFile_RangeRequestNotSatisfiable(string rangeString) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromStream"); + httpRequestMessage.Headers.TryAddWithoutValidation("Range", rangeString); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.Empty(body); + } + [Fact] public async Task FileFromStream_ReturnsFileWithFileName() { @@ -98,6 +269,45 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); } + [Fact] + public async Task FileFromStream_ReturnsFileWithFileName_IfRangeHeaderValid_RangeRequest() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromStreamWithFileName_WithEtag"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal("This is", body); + } + + [Fact] + public async Task FileFromStream_ReturnsFileWithFileName_IfRangeHeaderInvalid_RangeRequestNotSatisfiable() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromStreamWithFileName_WithEtag"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"NotEtag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("This is sample text from a stream", body); + } + [Fact] public async Task FileFromBinaryData_ReturnsFile() { @@ -115,6 +325,49 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("This is a sample text from a binary array", body); } + [Theory] + [InlineData(0, 6, "This is")] + [InlineData(29, 40, "binary array")] + [InlineData(0, 50, "This is a sample text from a binary array")] + public async Task FileFromBinaryData_ReturnsFile_RangeRequest(long start, long end, string expectedBody) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromBinaryData"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(start, end); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal(expectedBody, body); + } + + [Theory] + [InlineData("0-6")] + [InlineData("bytes = 11-6")] + [InlineData("bytes = 1-4, 5-11")] + public async Task FileFromBinaryData_ReturnsFile_RangeRequestNotSatisfiable(string rangeString) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromBinaryData"); + httpRequestMessage.Headers.TryAddWithoutValidation("Range", rangeString); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.Empty(body); + } + [Fact] public async Task FileFromBinaryData_ReturnsFileWithFileName() { @@ -136,6 +389,45 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); } + [Fact] + public async Task FileFromBinaryData_ReturnsFileWithFileName_IfRangeHeaderValid_RangeRequest() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromBinaryDataWithFileName_WithEtag"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal("This is", body); + } + + [Fact] + public async Task FileFromBinaryData_ReturnsFileWithFileName_IfRangeHeaderInvalid_RangeRequestIgnored() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/DownloadFiles/DownloadFromBinaryDataWithFileName_WithEtag"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"NotEtag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("This is a sample text from a binary array", body); + } + [Fact] public async Task FileFromEmbeddedResources_ReturnsFileWithFileName() { @@ -159,5 +451,99 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.NotNull(contentDisposition); Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); } + + [Theory] + [InlineData(0, 6, "Sample ")] + [InlineData(20, 37, "embedded resource.")] + [InlineData(7, 50, "text file as embedded resource.")] + public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_RangeRequest(long start, long end, string expectedBody) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/EmbeddedFiles/DownloadFileWithFileName"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(start, end); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal(expectedBody, body); + var contentDisposition = response.Content.Headers.ContentDisposition.ToString(); + Assert.NotNull(contentDisposition); + Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); + } + + [Fact] + public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_IfRangeHeaderValid_RangeRequest() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/EmbeddedFiles/DownloadFileWithFileName_WithEtag"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"Etag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.PartialContent, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.NotNull(body); + Assert.Equal("Sample ", body); + var contentDisposition = response.Content.Headers.ContentDisposition.ToString(); + Assert.NotNull(contentDisposition); + Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); + } + + [Fact] + public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_IfRangeHeaderInvalid_RangeRequestIgnored() + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/EmbeddedFiles/DownloadFileWithFileName_WithEtag"); + httpRequestMessage.Headers.Range = new RangeHeaderValue(0, 6); + httpRequestMessage.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue("\"NotEtag\"")); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("Sample text file as embedded resource.", body); + var contentDisposition = response.Content.Headers.ContentDisposition.ToString(); + Assert.NotNull(contentDisposition); + Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); + } + + [Theory] + [InlineData("0-6")] + [InlineData("bytes = 11-6")] + [InlineData("bytes = 1-4, 5-11")] + public async Task FileFromEmbeddedResources_ReturnsFileWithFileName_RangeRequestNotSatisfiable(string rangeString) + { + // Arrange + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://localhost/EmbeddedFiles/DownloadFileWithFileName"); + httpRequestMessage.Headers.TryAddWithoutValidation("Range", rangeString); + + // Act + var response = await Client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.RequestedRangeNotSatisfiable, response.StatusCode); + Assert.NotNull(response.Content.Headers.ContentType); + Assert.Equal("text/plain", response.Content.Headers.ContentType.ToString()); + var body = await response.Content.ReadAsStringAsync(); + Assert.Empty(body); + var contentDisposition = response.Content.Headers.ContentDisposition.ToString(); + Assert.NotNull(contentDisposition); + Assert.Equal("attachment; filename=downloadName.txt; filename*=UTF-8''downloadName.txt", contentDisposition); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj index d51c5d91f4..17b6030ae7 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj @@ -48,6 +48,7 @@ + diff --git a/test/WebSites/FilesWebSite/Controllers/DownloadFilesController.cs b/test/WebSites/FilesWebSite/Controllers/DownloadFilesController.cs index 2446d4afcb..e86e211932 100644 --- a/test/WebSites/FilesWebSite/Controllers/DownloadFilesController.cs +++ b/test/WebSites/FilesWebSite/Controllers/DownloadFilesController.cs @@ -1,10 +1,12 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.IO; using System.Text; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; namespace FilesWebSite { @@ -23,12 +25,28 @@ namespace FilesWebSite return PhysicalFile(path, "text/plain"); } + public IActionResult DownloadFromDisk_WithLastModifiedAndEtag() + { + var path = Path.Combine(_hostingEnvironment.ContentRootPath, "sample.txt"); + var lastModified = new DateTimeOffset(year: 1999, month: 11, day: 04, hour: 3, minute: 0, second: 0, offset: new TimeSpan(0)); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + return PhysicalFile(path, "text/plain", lastModified, entityTag); + } + public IActionResult DownloadFromDiskWithFileName() { var path = Path.Combine(_hostingEnvironment.ContentRootPath, "sample.txt"); return PhysicalFile(path, "text/plain", "downloadName.txt"); } + public IActionResult DownloadFromDiskWithFileName_WithLastModifiedAndEtag() + { + var path = Path.Combine(_hostingEnvironment.ContentRootPath, "sample.txt"); + var lastModified = new DateTimeOffset(year: 1999, month: 11, day: 04, hour: 3, minute: 0, second: 0, offset: new TimeSpan(0)); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + return PhysicalFile(path, "text/plain", "downloadName.txt", lastModified, entityTag); + } + public IActionResult DownloadFromStream() { var stream = new MemoryStream(); @@ -51,6 +69,17 @@ namespace FilesWebSite return File(stream, "text/plain", "downloadName.txt"); } + public IActionResult DownloadFromStreamWithFileName_WithEtag() + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write("This is sample text from a stream"); + writer.Flush(); + stream.Seek(0, SeekOrigin.Begin); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + return File(stream, "text/plain", "downloadName.txt", lastModified: null, entityTag: entityTag); + } + public IActionResult DownloadFromBinaryData() { var data = Encoding.UTF8.GetBytes("This is a sample text from a binary array"); @@ -62,5 +91,12 @@ namespace FilesWebSite var data = Encoding.UTF8.GetBytes("This is a sample text from a binary array"); return File(data, "text/plain", "downloadName.txt"); } + + public IActionResult DownloadFromBinaryDataWithFileName_WithEtag() + { + var data = Encoding.UTF8.GetBytes("This is a sample text from a binary array"); + var entityTag = new EntityTagHeaderValue("\"Etag\""); + return File(data, "text/plain", "downloadName.txt", lastModified: null, entityTag: entityTag); + } } } diff --git a/test/WebSites/FilesWebSite/Controllers/EmbeddedFilesController.cs b/test/WebSites/FilesWebSite/Controllers/EmbeddedFilesController.cs index 5b471886b7..758aea7b7d 100644 --- a/test/WebSites/FilesWebSite/Controllers/EmbeddedFilesController.cs +++ b/test/WebSites/FilesWebSite/Controllers/EmbeddedFilesController.cs @@ -17,5 +17,17 @@ namespace FilesWebSite FileDownloadName = "downloadName.txt" }; } + + public IActionResult DownloadFileWithFileName_WithEtag() + { + var file = new VirtualFileResult("/Greetings.txt", "text/plain") + { + FileProvider = new EmbeddedFileProvider(GetType().GetTypeInfo().Assembly, "FilesWebSite.EmbeddedResources"), + FileDownloadName = "downloadName.txt" + }; + + file.EntityTag = new Microsoft.Net.Http.Headers.EntityTagHeaderValue("\"Etag\""); + return file; + } } } \ No newline at end of file diff --git a/test/WebSites/FilesWebSite/EmbeddedResources/Greetings.txt b/test/WebSites/FilesWebSite/EmbeddedResources/Greetings.txt index 6b5d08138b..9531546f3d 100644 --- a/test/WebSites/FilesWebSite/EmbeddedResources/Greetings.txt +++ b/test/WebSites/FilesWebSite/EmbeddedResources/Greetings.txt @@ -1 +1 @@ -Sample text file as embedded resource. \ No newline at end of file +Sample text file as embedded resource. \ No newline at end of file diff --git a/test/WebSites/FilesWebSite/sample.txt b/test/WebSites/FilesWebSite/sample.txt index a34f4c4be3..0f8c58ac58 100644 --- a/test/WebSites/FilesWebSite/sample.txt +++ b/test/WebSites/FilesWebSite/sample.txt @@ -1 +1 @@ -This is a sample text file \ No newline at end of file +This is a sample text file \ No newline at end of file