From a2023d35ee2805435e89a1b59cc6a7a78bc79307 Mon Sep 17 00:00:00 2001 From: jacalvar Date: Thu, 25 Sep 2014 23:50:06 -0700 Subject: [PATCH] [Fixes #429] FileResult 1) Implemented FilePathResult to efficiently return files from disk. 2) Implemented FileStreamResult to return content from a stream. 3) Implemented FileContentResult to return content from a byte array. --- Mvc.sln | 13 + samples/MvcSample.Web/HomeController.cs | 10 + samples/MvcSample.Web/sample.txt | 1 + .../ActionResults/FileContentResult.cs | 41 +++ .../ActionResults/FilePathResult.cs | 84 ++++++ .../ActionResults/FileResult.cs | 266 ++++++++++++++++++ .../ActionResults/FileStreamResult.cs | 49 ++++ src/Microsoft.AspNet.Mvc.Core/Controller.cs | 85 ++++++ .../Properties/Resources.Designer.cs | 16 ++ src/Microsoft.AspNet.Mvc.Core/Resources.resx | 4 + .../ActionResults/FileContentResultTest.cs | 50 ++++ .../ActionResults/FilePathResultTest.cs | 74 +++++ .../ActionResults/FileResultTest.cs | 240 ++++++++++++++++ .../ActionResults/FileStreamResultTest.cs | 91 ++++++ .../ControllerTests.cs | 108 ++++++- .../TestFiles/FilePathResultTestFile.txt | 1 + .../FileResultTests.cs | 156 ++++++++++ .../project.json | 1 + .../Controllers/DowloadFilesController.cs | 66 +++++ test/WebSites/FilesWebSite/FilesWebSite.kproj | 30 ++ .../FilesWebSite/SendFileMiddleware.cs | 73 +++++ test/WebSites/FilesWebSite/Startup.cs | 29 ++ test/WebSites/FilesWebSite/project.json | 11 + test/WebSites/FilesWebSite/sample.txt | 1 + 24 files changed, 1498 insertions(+), 2 deletions(-) create mode 100644 samples/MvcSample.Web/sample.txt create mode 100644 src/Microsoft.AspNet.Mvc.Core/ActionResults/FileContentResult.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/ActionResults/FilePathResult.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/ActionResults/FileResult.cs create mode 100644 src/Microsoft.AspNet.Mvc.Core/ActionResults/FileStreamResult.cs create mode 100644 test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileContentResultTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FilePathResultTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileResultTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileStreamResultTest.cs create mode 100644 test/Microsoft.AspNet.Mvc.Core.Test/TestFiles/FilePathResultTestFile.txt create mode 100644 test/Microsoft.AspNet.Mvc.FunctionalTests/FileResultTests.cs create mode 100644 test/WebSites/FilesWebSite/Controllers/DowloadFilesController.cs create mode 100644 test/WebSites/FilesWebSite/FilesWebSite.kproj create mode 100644 test/WebSites/FilesWebSite/SendFileMiddleware.cs create mode 100644 test/WebSites/FilesWebSite/Startup.cs create mode 100644 test/WebSites/FilesWebSite/project.json create mode 100644 test/WebSites/FilesWebSite/sample.txt diff --git a/Mvc.sln b/Mvc.sln index e13f657391..6907bee3b5 100644 --- a/Mvc.sln +++ b/Mvc.sln @@ -84,6 +84,8 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ApiExplorerWebSite", "test\ EndProject Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "ReflectedModelWebSite", "test\WebSites\ReflectedModelWebSite\ReflectedModelWebSite.kproj", "{C2EF54F8-8886-4260-A322-44F76245F95D}" EndProject +Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "FilesWebSite", "test\WebSites\FilesWebSite\FilesWebSite.kproj", "{0EF9860B-10D7-452F-B0F4-A405B88BEBB3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -414,6 +416,16 @@ Global {A192E504-2881-41DC-90D1-B7F1DD1134E8}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU {A192E504-2881-41DC-90D1-B7F1DD1134E8}.Release|Mixed Platforms.Build.0 = Release|Any CPU {A192E504-2881-41DC-90D1-B7F1DD1134E8}.Release|x86.ActiveCfg = Release|Any CPU + {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Debug|x86.ActiveCfg = Debug|Any CPU + {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Release|Any CPU.Build.0 = Release|Any CPU + {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {0EF9860B-10D7-452F-B0F4-A405B88BEBB3}.Release|x86.ActiveCfg = Release|Any CPU {61061528-071E-424E-965A-07BCC2F02672}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {61061528-071E-424E-965A-07BCC2F02672}.Debug|Any CPU.Build.0 = Debug|Any CPU {61061528-071E-424E-965A-07BCC2F02672}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU @@ -472,6 +484,7 @@ Global {1976AC4A-FEA4-4587-A158-D9F79736D2B6} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {96107AC0-18E2-474D-BAB4-2FFF2185FBCD} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {A192E504-2881-41DC-90D1-B7F1DD1134E8} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} + {0EF9860B-10D7-452F-B0F4-A405B88BEBB3} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {61061528-071E-424E-965A-07BCC2F02672} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} {C2EF54F8-8886-4260-A322-44F76245F95D} = {16703B76-C9F7-4C75-AE6C-53D92E308E3C} EndGlobalSection diff --git a/samples/MvcSample.Web/HomeController.cs b/samples/MvcSample.Web/HomeController.cs index 30341b570b..6bf0088cd8 100644 --- a/samples/MvcSample.Web/HomeController.cs +++ b/samples/MvcSample.Web/HomeController.cs @@ -34,6 +34,16 @@ namespace MvcSample.Web return HttpNotFound(); } + public ActionResult SendFileFromDisk() + { + return File("sample.txt", "text/plain"); + } + + public ActionResult SendFileFromDiskWithName() + { + return File("sample.txt", "text/plain", "sample-file.txt"); + } + public bool IsDefaultNameSpace() { var namespaceToken = ActionContext.RouteData.DataTokens["NameSpace"] as string; diff --git a/samples/MvcSample.Web/sample.txt b/samples/MvcSample.Web/sample.txt new file mode 100644 index 0000000000..6abaf945fa --- /dev/null +++ b/samples/MvcSample.Web/sample.txt @@ -0,0 +1 @@ +This is a sample file returned from a controller \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionResults/FileContentResult.cs b/src/Microsoft.AspNet.Mvc.Core/ActionResults/FileContentResult.cs new file mode 100644 index 0000000000..cb608f1ec8 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ActionResults/FileContentResult.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Threading; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// Represents an that when executed will + /// write a binary file to the response. + /// + public class FileContentResult : FileResult + { + /// + /// Creates a new instance with + /// the provided and the + /// provided . + /// + /// The bytes that represent the file contents. + /// The Content-Type header of the response. + public FileContentResult([NotNull] byte[] fileContents, string contentType) + : base(contentType) + { + FileContents = fileContents; + } + + /// + /// Gets the file contents. + /// + public byte[] FileContents { get; private set; } + + /// + protected override Task WriteFileAsync(HttpResponse response, CancellationToken cancellation) + { + return response.Body.WriteAsync(FileContents, 0, FileContents.Length, cancellation); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionResults/FilePathResult.cs b/src/Microsoft.AspNet.Mvc.Core/ActionResults/FilePathResult.cs new file mode 100644 index 0000000000..592c6e5fca --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ActionResults/FilePathResult.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Threading; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.HttpFeature; +using Microsoft.AspNet.Mvc.Core; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// Represents an that when executed will + /// write a file from disk to the response using mechanisms provided + /// by the host. + /// + public class FilePathResult : FileResult + { + private const int DefaultBufferSize = 0x1000; + + /// + /// Creates a new instance with + /// the provided and the + /// provided . + /// + /// The path to the file. The path must be an absolute + /// path. Relative and virtual paths are not supported. + /// The Content-Type header of the response. + public FilePathResult([NotNull] string fileName, [NotNull] string contentType) + : base(contentType) + { + if (!Path.IsPathRooted(fileName)) + { + var message = Resources.FormatFileResult_InvalidPathType_RelativeOrVirtualPath(fileName); + throw new ArgumentException(message, "fileName"); + } + + FileName = fileName; + } + + /// + /// Gets the path to the file that will be sent back as the response. + /// + public string FileName { get; private set; } + + /// + protected override Task WriteFileAsync(HttpResponse response, CancellationToken cancellation) + { + var sendFile = response.HttpContext.GetFeature(); + if (sendFile != null) + { + return sendFile.SendFileAsync( + FileName, + offset: 0, + length: null, + cancellation: cancellation); + } + else + { + return CopyStreamToResponse(FileName, response, cancellation); + } + } + + private static async Task CopyStreamToResponse( + string fileName, + HttpResponse response, + CancellationToken cancellation) + { + var fileStream = new FileStream( + fileName, FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + DefaultBufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + using (fileStream) + { + await fileStream.CopyToAsync(response.Body, DefaultBufferSize, cancellation); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionResults/FileResult.cs b/src/Microsoft.AspNet.Mvc.Core/ActionResults/FileResult.cs new file mode 100644 index 0000000000..1b7fba8f97 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ActionResults/FileResult.cs @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// Represents an that when executed will + /// write a file as the response. + /// + public abstract class FileResult : ActionResult + { + private string _fileDownloadName; + + /// + /// Creates a new instance with + /// the provided . + /// + /// The Content-Type header of the response. + protected FileResult([NotNull] string contentType) + { + ContentType = contentType; + } + + /// + /// Gets the Content-Type header value that will be written to the response. + /// + public string ContentType { get; private set; } + + /// + /// Gets the file name that will be used in the Content-Disposition header of the response. + /// + public string FileDownloadName + { + get { return _fileDownloadName ?? string.Empty; } + set { _fileDownloadName = value; } + } + + /// + public override Task ExecuteResultAsync([NotNull] ActionContext context) + { + var response = context.HttpContext.Response; + response.ContentType = ContentType; + + if (!string.IsNullOrEmpty(FileDownloadName)) + { + // From RFC 2183, Sec. 2.3: + // The sender may want to suggest a filename to be used if the entity is + // detached and stored in a separate file. If the receiving MUA writes + // the entity to a file, the suggested filename should be used as a + // basis for the actual filename, where possible. + var headerValue = ContentDispositionUtil.GetHeaderValue(FileDownloadName); + context.HttpContext.Response.Headers.Set("Content-Disposition", headerValue); + } + + // We aren't flowing the cancellation token appropiately, see + // https://github.com/aspnet/Mvc/issues/743 for details. + return WriteFileAsync(response, CancellationToken.None); + } + + /// + /// Writes the file to the response. + /// + /// + /// The where the file will be written + /// + /// The to cancel the operation. + /// + /// A that will complete when the file has been written to the response. + /// + protected abstract Task WriteFileAsync(HttpResponse response, CancellationToken cancellation); + + // This is a temporary implementation until we have the right abstractions in HttpAbstractions. + internal static class ContentDispositionUtil + { + private const string HexDigits = "0123456789ABCDEF"; + + private static void AddByteToStringBuilder(byte b, StringBuilder builder) + { + builder.Append('%'); + + int i = b; + AddHexDigitToStringBuilder(i >> 4, builder); + AddHexDigitToStringBuilder(i % 16, builder); + } + + private static void AddHexDigitToStringBuilder(int digit, StringBuilder builder) + { + builder.Append(HexDigits[digit]); + } + + private static string CreateRfc2231HeaderValue(string filename) + { + var builder = new StringBuilder("attachment; filename*=UTF-8''"); + + var filenameBytes = Encoding.UTF8.GetBytes(filename); + foreach (var b in filenameBytes) + { + if (IsByteValidHeaderValueCharacter(b)) + { + builder.Append((char)b); + } + else + { + AddByteToStringBuilder(b, builder); + } + } + + return builder.ToString(); + } + + public static string GetHeaderValue(string fileName) + { + // If fileName contains any Unicode characters, encode according + // to RFC 2231 (with clarifications from RFC 5987) + foreach (var c in fileName) + { + if ((int)c > 127) + { + return CreateRfc2231HeaderValue(fileName); + } + } + + return CreateNonUnicodeCharactersHeaderValue(fileName); + } + + private static string CreateNonUnicodeCharactersHeaderValue(string fileName) + { + var escapedFileName = EscapeFileName(fileName); + return string.Format("attachment; filename={0}", escapedFileName); + } + + private static string EscapeFileName(string fileName) + { + var hasToBeQuoted = false; + + // We can't break the loop earlier because we need to check the + // whole name for \n, in which case we need to provide a special + // encoding. + for (var i = 0; i < fileName.Length; i++) + { + if (fileName[i] == '\n') + { + // See RFC 2047 for more details + return GetRfc2047Base64EncodedWord(fileName); + } + + // Control characters = (octets 0 - 31) and DEL (127) + if (char.IsControl(fileName[i])) + { + hasToBeQuoted = true; + } + + switch (fileName[i]) + { + case '(': + case ')': + case '<': + case '>': + case '@': + case ',': + case ';': + case ':': + case '\\': + case '/': + case '[': + case ']': + case '?': + case '=': + case '{': + case '}': + case ' ': + case '\t': + case '"': + hasToBeQuoted = true; + break; + default: + break; + } + } + + return hasToBeQuoted ? QuoteFileName(fileName) : fileName; + } + + private static string QuoteFileName(string fileName) + { + var builder = new StringBuilder(); + builder.Append("\""); + + for (var i = 0; i < fileName.Length; i++) + { + switch (fileName[i]) + { + case '\\': + // Escape \ + builder.Append("\\\\"); + break; + case '"': + // Escape " + builder.Append("\\\""); + break; + default: + builder.Append(fileName[i]); + break; + } + } + + builder.Append("\""); + return builder.ToString(); + } + + private static string GetRfc2047Base64EncodedWord(string fileName) + { + // See RFC 2047 for details. Section 8 for examples. + const string charset = "utf-8"; + // B means Base64 + const string encoding = "B"; + + var fileNameBytes = Encoding.UTF8.GetBytes(fileName); + var base64EncodedFileName = Convert.ToBase64String(fileNameBytes); + + // Encoded words are defined as "=?{charset}?{encoding}?{encpoded value}?=" + return string.Format("\"=?{0}?{1}?{2}?=\"", charset, encoding, base64EncodedFileName); + } + + // Application of RFC 2231 Encoding to Hypertext Transfer Protocol (HTTP) Header Fields, sec. 3.2 + // http://greenbytes.de/tech/webdav/draft-reschke-rfc2231-in-http-latest.html + private static bool IsByteValidHeaderValueCharacter(byte b) + { + if ((byte)'0' <= b && b <= (byte)'9') + { + return true; // is digit + } + if ((byte)'a' <= b && b <= (byte)'z') + { + return true; // lowercase letter + } + if ((byte)'A' <= b && b <= (byte)'Z') + { + return true; // uppercase letter + } + + switch (b) + { + case (byte)'-': + case (byte)'.': + case (byte)'_': + case (byte)'~': + case (byte)':': + case (byte)'!': + case (byte)'$': + case (byte)'&': + case (byte)'+': + return true; + } + + return false; + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/ActionResults/FileStreamResult.cs b/src/Microsoft.AspNet.Mvc.Core/ActionResults/FileStreamResult.cs new file mode 100644 index 0000000000..d7e4337790 --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/ActionResults/FileStreamResult.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; + +namespace Microsoft.AspNet.Mvc +{ + /// + /// Represents an that when executed will + /// write a file from a stream to the response. + /// + public class FileStreamResult : FileResult + { + // default buffer size as defined in BufferedStream type + private const int BufferSize = 0x1000; + + /// + /// Creates a new instance with + /// the provided and the + /// provided . + /// + /// The stream with the file. + /// The Content-Type header of the response. + public FileStreamResult([NotNull] Stream fileStream, string contentType) + : base(contentType) + { + FileStream = fileStream; + } + + /// + /// Gets the stream with the file that will be sent back as the response. + /// + public Stream FileStream { get; private set; } + + /// + protected async override Task WriteFileAsync(HttpResponse response, CancellationToken cancellation) + { + var outputStream = response.Body; + + using (FileStream) + { + await FileStream.CopyToAsync(outputStream, BufferSize, cancellation); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Mvc.Core/Controller.cs b/src/Microsoft.AspNet.Mvc.Core/Controller.cs index 9b3e79c39a..b9ee828566 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Controller.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Controller.cs @@ -2,6 +2,7 @@ // 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.Security.Principal; using System.Text; using System.Threading.Tasks; @@ -424,6 +425,90 @@ namespace Microsoft.AspNet.Mvc return new RedirectToRouteResult(Url, routeName, routeValues, permanent: true); } + /// + /// Returns a file with the specified as content and the + /// specified as the Content-Type. + /// + /// The file contents. + /// The Content-Type of the file. + /// The created for the response. + [NonAction] + public virtual FileContentResult File(byte[] fileContents, string contentType) + { + return File(fileContents, contentType, fileDownloadName: null); + } + + /// + /// Returns a file with the specified as content, the + /// specified as the Content-Type and the + /// specified as the suggested file name. + /// + /// The file contents. + /// The Content-Type of the file. + /// The suggested file name. + /// The created for the response. + [NonAction] + public virtual FileContentResult File(byte[] fileContents, string contentType, string fileDownloadName) + { + return new FileContentResult(fileContents, contentType) { FileDownloadName = fileDownloadName }; + } + + /// + /// Returns a file in the specified with the + /// specified as the Content-Type. + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The created for the response. + [NonAction] + public virtual FileStreamResult File(Stream fileStream, string contentType) + { + return File(fileStream, contentType, fileDownloadName: null); + } + + /// + /// Returns a file in the specified with the + /// specified as the Content-Type and the + /// specified as the suggested file name. + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The suggested file name. + /// The created for the response. + [NonAction] + public virtual FileStreamResult File(Stream fileStream, string contentType, string fileDownloadName) + { + return new FileStreamResult(fileStream, contentType) { FileDownloadName = fileDownloadName }; + } + + /// + /// Returns the file specified by with the + /// specified as the Content-Type. + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The created for the response. + [NonAction] + public virtual FilePathResult File(string fileName, string contentType) + { + return File(fileName, contentType, fileDownloadName: null); + } + + /// + /// Returns the file specified by with the + /// specified as the Content-Type and the + /// specified as the suggested file name. + /// + /// The with the contents of the file. + /// The Content-Type of the file. + /// The suggested file name. + /// The created for the response. + [NonAction] + public virtual FilePathResult File(string fileName, string contentType, string fileDownloadName) + { + return new FilePathResult(fileName, contentType) { FileDownloadName = fileDownloadName }; + } + /// /// Creates an that produces a Not Found (404) response. /// diff --git a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs index 72f9f30018..8144638ae8 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Mvc.Core/Properties/Resources.Designer.cs @@ -1482,6 +1482,22 @@ namespace Microsoft.AspNet.Mvc.Core return GetString("AttributeRoute_NullTemplateRepresentation"); } + /// + /// "The path to the file must be absolute: {0}" + /// + internal static string FileResult_InvalidPathType_RelativeOrVirtualPath + { + get { return GetString("FileResult_InvalidPathType_RelativeOrVirtualPath"); } + } + + /// + /// "The path to the file must be absolute: {0}" + /// + internal static string FormatFileResult_InvalidPathType_RelativeOrVirtualPath(object p0) + { + return string.Format(CultureInfo.CurrentCulture, GetString("FileResult_InvalidPathType_RelativeOrVirtualPath"), p0); + } + /// /// Multiple actions matched. The following actions matched route data and had all constraints satisfied:{0}{0}{1} /// diff --git a/src/Microsoft.AspNet.Mvc.Core/Resources.resx b/src/Microsoft.AspNet.Mvc.Core/Resources.resx index 4633517ed2..38e275f72d 100644 --- a/src/Microsoft.AspNet.Mvc.Core/Resources.resx +++ b/src/Microsoft.AspNet.Mvc.Core/Resources.resx @@ -406,4 +406,8 @@ Multiple actions matched. The following actions matched route data and had all constraints satisfied:{0}{0}{1} 0 is the newline - 1 is a newline separate list of action display names + + "The path to the file must be absolute: {0}" + {0} is the value for the provided path + \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileContentResultTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileContentResultTest.cs new file mode 100644 index 0000000000..5d0837b2b2 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileContentResultTest.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNet.PipelineCore; +using Microsoft.AspNet.Routing; +using Xunit; + +namespace Microsoft.AspNet.Mvc +{ + public class FileContentResultTest + { + [Fact] + public void Constructor_SetsFileContents() + { + // Arrange + var fileContents = new byte[0]; + + // Act + var result = new FileContentResult(fileContents, "text/plain"); + + // Assert + Assert.Same(fileContents, result.FileContents); + } + + [Fact] + public async Task WriteFileAsync_CopiesBuffer_ToOutputStream() + { + // Arrange + var buffer = new byte[] { 1, 2, 3, 4, 5 }; + + var httpContext = new DefaultHttpContext(); + + var outStream = new MemoryStream(); + httpContext.Response.Body = outStream; + + var context = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + var result = new FileContentResult(buffer, "text/plain"); + + // Act + await result.ExecuteResultAsync(context); + + // Assert + Assert.Equal(buffer, outStream.ToArray()); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FilePathResultTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FilePathResultTest.cs new file mode 100644 index 0000000000..e39866680d --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FilePathResultTest.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNet.HttpFeature; +using Microsoft.AspNet.PipelineCore; +using Microsoft.AspNet.Routing; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc +{ + public class FilePathResultTest + { + [Fact] + public void Constructor_SetsFileName() + { + // Arrange & Act + var path = Path.GetFullPath("helllo.txt"); + var result = new FilePathResult(path, "text/plain"); + + // Act & Assert + Assert.Equal(path, result.FileName); + } + + [Fact] + public async Task ExecuteResultAsync_FallsbackToStreamCopy_IfNoIHttpSendFilePresent() + { + // Arrange + var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); + var result = new FilePathResult(path, "text/plain"); + + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = new MemoryStream(); + + var context = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(context); + httpContext.Response.Body.Position = 0; + + // Assert + Assert.NotNull(httpContext.Response.Body); + var contents = await new StreamReader(httpContext.Response.Body).ReadToEndAsync(); + Assert.Equal("FilePathResultTestFile contents", contents); + } + + [Fact] + public async Task ExecuteResultAsync_CallsSendFileAsync_IfIHttpSendFilePresent() + { + // Arrange + var path = Path.GetFullPath(Path.Combine("TestFiles", "FilePathResultTestFile.txt")); + var result = new FilePathResult(path, "text/plain"); + + var sendFileMock = new Mock(); + sendFileMock + .Setup(s => s.SendFileAsync(path, 0, null, CancellationToken.None)) + .Returns(Task.FromResult(0)); + + var httpContext = new DefaultHttpContext(); + httpContext.SetFeature(sendFileMock.Object); + + var context = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(context); + + // Assert + sendFileMock.Verify(); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileResultTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileResultTest.cs new file mode 100644 index 0000000000..2ae6021964 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileResultTest.cs @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.Routing; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc +{ + public class FileResultTest + { + [Fact] + public void Constructor_SetsContentType() + { + // Act + var result = new EmptyFileResult("text/plain"); + + // Assert + Assert.Equal("text/plain", result.ContentType); + } + + [Fact] + public async Task ContentDispositionHeader_IsEncodedCorrectly() + { + // See comment in FileResult.cs detailing how the FileDownloadName should be encoded. + + // Arrange + var httpContext = new Mock(); + httpContext.SetupSet(c => c.Response.ContentType = "application/my-type").Verifiable(); + httpContext + .Setup(c => c.Response.Headers.Set("Content-Disposition", @"attachment; filename=""some\\file""")) + .Verifiable(); + + var actionContext = CreateActionContext(httpContext.Object); + + var result = new EmptyFileResult("application/my-type") + { + FileDownloadName = @"some\file" + }; + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + Assert.True(result.WasWriteFileCalled); + httpContext.Verify(); + } + + [Fact] + public async Task ContentDispositionHeader_IsEncodedCorrectly_ForUnicodeCharacters() + { + // Arrange + var httpContext = new Mock(); + httpContext.SetupSet(c => c.Response.ContentType = "application/my-type").Verifiable(); + httpContext + .Setup(c => c.Response.Headers.Set( + "Content-Disposition", + @"attachment; filename*=UTF-8''ABCXYZabcxyz012789!%40%23$%25%5E&%2A%28%29-%3D_+.:~%CE%94")) + .Verifiable(); + + var actionContext = CreateActionContext(httpContext.Object); + + var result = new EmptyFileResult("application/my-type") + { + FileDownloadName = "ABCXYZabcxyz012789!@#$%^&*()-=_+.:~Δ" + }; + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + Assert.True(result.WasWriteFileCalled); + httpContext.Verify(); + } + + [Fact] + public async Task ExecuteResultAsync_DoesNotSetContentDisposition_IfNotSpecified() + { + // Arrange + var httpContext = new Mock(MockBehavior.Strict); + httpContext.SetupSet(c => c.Response.ContentType = "application/my-type").Verifiable(); + httpContext.Setup(c => c.Response.Body).Returns(Stream.Null); + + var actionContext = CreateActionContext(httpContext.Object); + + var result = new EmptyFileResult("application/my-type"); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + Assert.True(result.WasWriteFileCalled); + httpContext.Verify(); + } + + [Fact] + public async Task ExecuteResultAsync_SetsContentDisposition_IfSpecified() + { + // Arrange + var httpContext = new Mock(MockBehavior.Strict); + httpContext.SetupSet(c => c.Response.ContentType = "application/my-type").Verifiable(); + httpContext + .Setup(c => c.Response.Headers.Set("Content-Disposition", "attachment; filename=filename.ext")) + .Verifiable(); + + var actionContext = CreateActionContext(httpContext.Object); + + var result = new EmptyFileResult("application/my-type") + { + FileDownloadName = "filename.ext" + }; + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + Assert.True(result.WasWriteFileCalled); + httpContext.Verify(); + } + + public static TheoryData ContentDispositionData + { + get + { + return new TheoryData + { + // Non quoted values + { "09aAzZ", "attachment; filename=09aAzZ" }, + { "a.b", "attachment; filename=a.b" }, + { "#", "attachment; filename=#" }, + { "-", "attachment; filename=-" }, + { "_", "attachment; filename=_" }, + + // Values that need to be quoted + { ": :", "attachment; filename=\": :\"" }, + { "~", "attachment; filename=~" }, + { "$", "attachment; filename=$" }, + { "&", "attachment; filename=&" }, + { "+", "attachment; filename=+" }, + { "(", "attachment; filename=\"(\"" }, + { ")", "attachment; filename=\")\"" }, + { "<", "attachment; filename=\"<\"" }, + { ">", "attachment; filename=\">\"" }, + { "@", "attachment; filename=\"@\"" }, + { ",", "attachment; filename=\",\"" }, + { ";", "attachment; filename=\";\"" }, + { ":", "attachment; filename=\":\"" }, + { "/", "attachment; filename=\"/\"" }, + { "[", "attachment; filename=\"[\"" }, + { "]", "attachment; filename=\"]\"" }, + { "?", "attachment; filename=\"?\"" }, + { "=", "attachment; filename=\"=\"" }, + { "{", "attachment; filename=\"{\"" }, + { "}", "attachment; filename=\"}\"" }, + { " ", "attachment; filename=\" \"" }, + { "a\tb", "attachment; filename=\"a\tb\"" }, + { "a b", "attachment; filename=\"a b\"" }, + + // Values that need to be escaped + { "\"", "attachment; filename=\"\\\"\"" }, + { "\\", "attachment; filename=\"\\\\\"" }, + + // Values that need to be specially encoded (Base64, see rfc2047) + { "a\nb", "attachment; filename=\"=?utf-8?B?YQpi?=\"" }, + + // Values with non unicode characters + { "résumé.txt", "attachment; filename*=UTF-8''r%C3%A9sum%C3%A9.txt" }, + { "Δ", "attachment; filename*=UTF-8''%CE%94" }, + { "Δ\t", "attachment; filename*=UTF-8''%CE%94%09" }, + { "ABCXYZabcxyz012789!@#$%^&*()-=_+.:~Δ", @"attachment; filename*=UTF-8''ABCXYZabcxyz012789!%40%23$%25%5E&%2A%28%29-%3D_+.:~%CE%94" }, + }; + } + } + + public static TheoryData ContentDispositionControlCharactersData + { + get + { + var data = new TheoryData(); + for (var i = 0; i < 32; i++) + { + if (i == 10) + { + // skip \n as it has a special encoding + continue; + } + + data.Add(char.ConvertFromUtf32(i), "attachment; filename=\"" + char.ConvertFromUtf32(i) + "\""); + } + + data.Add(char.ConvertFromUtf32(127), "attachment; filename=\"" + char.ConvertFromUtf32(127) + "\""); + + return data; + } + } + + [Theory] + [MemberData(nameof(ContentDispositionData))] + [MemberData(nameof(ContentDispositionControlCharactersData))] + public void GetHeaderValue_Produces_Correct_ContentDisposition(string input, string expectedOutput) + { + // Arrange & Act + var actual = FileResult.ContentDispositionUtil.GetHeaderValue(input); + + // Assert + Assert.Equal(expectedOutput, actual); + } + + private static ActionContext CreateActionContext(HttpContext context) + { + return new ActionContext(context, new RouteData(), new ActionDescriptor()); + } + + private class EmptyFileResult : FileResult + { + public bool WasWriteFileCalled; + + public EmptyFileResult() + : this(MediaTypeNames.Application.Octet) + { + } + + public EmptyFileResult(string contentType) + : base(contentType) + { + } + + protected override Task WriteFileAsync(HttpResponse response, CancellationToken cancellation) + { + WasWriteFileCalled = true; + return Task.FromResult(0); + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileStreamResultTest.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileStreamResultTest.cs new file mode 100644 index 0000000000..3cac078839 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ActionResults/FileStreamResultTest.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNet.PipelineCore; +using Microsoft.AspNet.Routing; +using Moq; +using Xunit; + +namespace Microsoft.AspNet.Mvc +{ + public class FileStreamResultTest + { + [Fact] + public void Constructor_SetsFileName() + { + // Arrange + var stream = Stream.Null; + + // Act + var result = new FileStreamResult(stream, "text/plain"); + + // Assert + Assert.Equal(stream, result.FileStream); + } + + [Fact] + public async Task WriteFileAsync_WritesResponse_InChunksOfFourKilobytes() + { + // Arrange + var mockReadStream = new Mock(); + mockReadStream.SetupSequence(s => s.ReadAsync(It.IsAny(), 0, 0x1000, CancellationToken.None)) + .Returns(Task.FromResult(0x1000)) + .Returns(Task.FromResult(0x500)) + .Returns(Task.FromResult(0)); + + var mockBodyStream = new Mock(); + mockBodyStream + .Setup(s => s.WriteAsync(It.IsAny(), 0, 0x1000, CancellationToken.None)) + .Returns(Task.FromResult(0)); + + mockBodyStream + .Setup(s => s.WriteAsync(It.IsAny(), 0, 0x500, CancellationToken.None)) + .Returns(Task.FromResult(0)); + + var result = new FileStreamResult(mockReadStream.Object, "text/plain"); + + var httpContext = new DefaultHttpContext(); + httpContext.Response.Body = mockBodyStream.Object; + + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + mockReadStream.Verify(); + mockBodyStream.Verify(); + } + + [Fact] + public async Task WriteFileAsync_CopiesProvidedStream_ToOutputStream() + { + // Arrange + // Generate an array of bytes with a predictable pattern + // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F, 10, 11, 12, 13 + var originalBytes = Enumerable.Range(0, 0x1234) + .Select(b => (byte)(b % 20)).ToArray(); + + var originalStream = new MemoryStream(originalBytes); + + var httpContext = new DefaultHttpContext(); + var outStream = new MemoryStream(); + httpContext.Response.Body = outStream; + + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + + var result = new FileStreamResult(originalStream, "text/plain"); + + // Act + await result.ExecuteResultAsync(actionContext); + + // Assert + var outBytes = outStream.ToArray(); + Assert.True(originalBytes.SequenceEqual(outBytes)); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/ControllerTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/ControllerTests.cs index 80cb2c3e0d..2a2768bc25 100644 --- a/test/Microsoft.AspNet.Mvc.Core.Test/ControllerTests.cs +++ b/test/Microsoft.AspNet.Mvc.Core.Test/ControllerTests.cs @@ -2,10 +2,12 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; using System.Threading.Tasks; using System.Reflection; -using System.Text; +using Microsoft.AspNet.Http; using Microsoft.AspNet.Mvc.ModelBinding; using Microsoft.AspNet.Routing; using Microsoft.AspNet.Testing; @@ -13,7 +15,6 @@ using Microsoft.AspNet.Testing; using Moq; #endif using Xunit; -using Microsoft.AspNet.Http; namespace Microsoft.AspNet.Mvc.Test { @@ -368,6 +369,109 @@ namespace Microsoft.AspNet.Mvc.Test Assert.Equal(expected, resultPermanent.RouteValues); } + [Fact] + public void File_WithContents() + { + // Arrange + var controller = new Controller(); + var fileContents = new byte[0]; + + // Act + var result = controller.File(fileContents, "someContentType"); + + // Assert + Assert.NotNull(result); + Assert.Same(fileContents, result.FileContents); + Assert.Equal("someContentType", result.ContentType); + Assert.Equal(string.Empty, result.FileDownloadName); + } + + [Fact] + public void File_WithContentsAndFileDownloadName() + { + // Arrange + var controller = new Controller(); + var fileContents = new byte[0]; + + // Act + var result = controller.File(fileContents, "someContentType", "someDownloadName"); + + // Assert + Assert.NotNull(result); + Assert.Same(fileContents, result.FileContents); + Assert.Equal("someContentType", result.ContentType); + Assert.Equal("someDownloadName", result.FileDownloadName); + } + + [Fact] + public void File_WithPath() + { + // Arrange + var controller = new Controller(); + var path = Path.GetFullPath("somepath"); + + // Act + var result = controller.File(path, "someContentType"); + + // Assert + Assert.NotNull(result); + Assert.Equal(path, result.FileName); + Assert.Equal("someContentType", result.ContentType); + Assert.Equal(string.Empty, result.FileDownloadName); + } + + [Fact] + public void File_WithPathAndFileDownloadName() + { + // Arrange + var controller = new Controller(); + var path = Path.GetFullPath("somepath"); + + // Act + var result = controller.File(path, "someContentType", "someDownloadName"); + + // Assert + Assert.NotNull(result); + Assert.Equal(path, result.FileName); + Assert.Equal("someContentType", result.ContentType); + Assert.Equal("someDownloadName", result.FileDownloadName); + } + + [Fact] + public void File_WithStream() + { + // Arrange + var controller = new Controller(); + var fileStream = Stream.Null; + + // Act + var result = controller.File(fileStream, "someContentType"); + + // Assert + Assert.NotNull(result); + Assert.Same(fileStream, result.FileStream); + Assert.Equal("someContentType", result.ContentType); + Assert.Equal(string.Empty, result.FileDownloadName); + } + + [Fact] + public void File_WithStreamAndFileDownloadName() + { + // Arrange + var controller = new Controller(); + var fileStream = Stream.Null; + + // Act + var result = controller.File(fileStream, "someContentType", "someDownloadName"); + + // Assert + Assert.NotNull(result); + Assert.Same(fileStream, result.FileStream); + Assert.Equal("someContentType", result.ContentType); + Assert.Equal("someDownloadName", result.FileDownloadName); + } + + [Fact] public void HttpNotFound_SetsStatusCode() { diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/TestFiles/FilePathResultTestFile.txt b/test/Microsoft.AspNet.Mvc.Core.Test/TestFiles/FilePathResultTestFile.txt new file mode 100644 index 0000000000..1ae10032bf --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/TestFiles/FilePathResultTestFile.txt @@ -0,0 +1 @@ +FilePathResultTestFile contents \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/FileResultTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/FileResultTests.cs new file mode 100644 index 0000000000..23fc3317f4 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/FileResultTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Net; +using System.Threading.Tasks; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.TestHost; +using Xunit; + +namespace Microsoft.AspNet.Mvc.FunctionalTests +{ + public class FileResultTests + { + private readonly IServiceProvider _services = TestHelper.CreateServices("FilesWebSite"); + private readonly Action _app = new FilesWebSite.Startup().Configure; + + [Fact] + public async Task FileFromDisk_CanBeEnabled_WithMiddleware() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/DownloadFiles/DowloadFromDisk"); + + // 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.NotNull(body); + Assert.Equal("This is a sample text file", body); + } + + [Fact] + public async Task FileFromDisk_ReturnsFileWithFileName() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/DownloadFiles/DowloadFromDiskWithFileName"); + + // 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.NotNull(body); + Assert.Equal("This is a sample text file", body); + + var contentDisposition = response.Content.Headers.ContentDisposition.ToString(); + Assert.NotNull(contentDisposition); + Assert.Equal("attachment; filename=downloadName.txt", contentDisposition); + } + + [Fact] + public async Task FileFromStream_ReturnsFile() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/DownloadFiles/DowloadFromStream"); + + // 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.NotNull(body); + Assert.Equal("This is sample text from a stream", body); + } + + [Fact] + public async Task FileFromStream_ReturnsFileWithFileName() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/DownloadFiles/DowloadFromStreamWithFileName"); + + // 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.NotNull(body); + Assert.Equal("This is sample text from a stream", body); + + var contentDisposition = response.Content.Headers.ContentDisposition.ToString(); + Assert.NotNull(contentDisposition); + Assert.Equal("attachment; filename=downloadName.txt", contentDisposition); + } + + [Fact] + public async Task FileFromBinaryData_ReturnsFile() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/DownloadFiles/DowloadFromBinaryData"); + + // 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.NotNull(body); + Assert.Equal("This is a sample text from a binary array", body); + } + + [Fact] + public async Task FileFromBinaryData_ReturnsFileWithFileName() + { + // Arrange + var server = TestServer.Create(_services, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/DownloadFiles/DowloadFromBinaryDataWithFileName"); + + // 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.NotNull(body); + Assert.Equal("This is a sample text from a binary array", body); + + var contentDisposition = response.Content.Headers.ContentDisposition.ToString(); + Assert.NotNull(contentDisposition); + Assert.Equal("attachment; filename=downloadName.txt", contentDisposition); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json b/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json index 8acc20ce70..526e766a55 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/project.json @@ -10,6 +10,7 @@ "BasicWebSite": "", "CompositeViewEngine": "", "ConnegWebsite": "", + "FilesWebSite": "", "FiltersWebSite": "", "FormatterWebSite": "", "InlineConstraintsWebSite": "", diff --git a/test/WebSites/FilesWebSite/Controllers/DowloadFilesController.cs b/test/WebSites/FilesWebSite/Controllers/DowloadFilesController.cs new file mode 100644 index 0000000000..e37af6c0cd --- /dev/null +++ b/test/WebSites/FilesWebSite/Controllers/DowloadFilesController.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Text; +using Microsoft.AspNet.Mvc; +using Microsoft.Framework.Runtime; + +namespace FilesWebSite +{ + public class DownloadFilesController : Controller + { + private readonly IApplicationEnvironment _appEnvironment; + + public DownloadFilesController(IApplicationEnvironment appEnvironment) + { + _appEnvironment = appEnvironment; + } + + public IActionResult DowloadFromDisk() + { + var path = Path.Combine(_appEnvironment.ApplicationBasePath, "sample.txt"); + return File(path, "text/plain"); + } + + public IActionResult DowloadFromDiskWithFileName() + { + var path = Path.Combine(_appEnvironment.ApplicationBasePath, "sample.txt"); + return File(path, "text/plain", "downloadName.txt"); + } + + public IActionResult DowloadFromStream() + { + 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); + + return File(stream, "text/plain"); + } + + public IActionResult DowloadFromStreamWithFileName() + { + 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); + + return File(stream, "text/plain", "downloadName.txt"); + } + + public IActionResult DowloadFromBinaryData() + { + var data = Encoding.UTF8.GetBytes("This is a sample text from a binary array"); + return File(data, "text/plain"); + } + + public IActionResult DowloadFromBinaryDataWithFileName() + { + var data = Encoding.UTF8.GetBytes("This is a sample text from a binary array"); + return File(data, "text/plain", "downloadName.txt"); + } + } +} \ No newline at end of file diff --git a/test/WebSites/FilesWebSite/FilesWebSite.kproj b/test/WebSites/FilesWebSite/FilesWebSite.kproj new file mode 100644 index 0000000000..3b17a2f4d7 --- /dev/null +++ b/test/WebSites/FilesWebSite/FilesWebSite.kproj @@ -0,0 +1,30 @@ + + + + 12.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + Debug + AnyCPU + + + + 0ef9860b-10d7-452f-b0f4-a405b88bebb3 + Web + FilesWebSite + + + ConsoleDebugger + + + WebDebugger + + + + + 2.0 + 18002 + + + \ No newline at end of file diff --git a/test/WebSites/FilesWebSite/SendFileMiddleware.cs b/test/WebSites/FilesWebSite/SendFileMiddleware.cs new file mode 100644 index 0000000000..62e6beb295 --- /dev/null +++ b/test/WebSites/FilesWebSite/SendFileMiddleware.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Framework.Runtime; +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.HttpFeature; + +namespace FilesWebSite +{ + public class SendFileMiddleware + { + private const int DefaultBufferSize = 0x1000; + + private readonly RequestDelegate _next; + + public SendFileMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext context) + { + var environment = (IApplicationEnvironment)context.RequestServices.GetService(typeof(IApplicationEnvironment)); + + if (context.GetFeature() == null) + { + var sendFile = new SendFileFallBack(context.Response.Body, environment.ApplicationBasePath); + context.SetFeature(sendFile); + } + + await _next(context); + } + + private class SendFileFallBack : IHttpSendFileFeature + { + private readonly string _appBasePath; + private Stream _responseStream; + + public SendFileFallBack(Stream responseStream, string appBasePath) + { + _responseStream = responseStream; + _appBasePath = appBasePath; + } + + public async Task SendFileAsync(string path, long offset, long? length, CancellationToken cancellation) + { + using (var stream = new FileStream(Path.Combine(_appBasePath, path), FileMode.Open)) + { + length = length ?? stream.Length - offset; + + stream.Seek(offset, SeekOrigin.Begin); + + var bufferSize = length < DefaultBufferSize ? length.Value : DefaultBufferSize; + var buffer = new byte[bufferSize]; + var bytesRead = 0; + + do + { + var bytesToRead = bufferSize < length ? bufferSize : length; + bytesRead = await stream.ReadAsync(buffer, 0, (int)bytesToRead); + length = length - bytesRead; + + await _responseStream.WriteAsync(buffer, 0, bytesRead); + } while (bytesRead > 0 && length > 0); + } + } + } + } +} \ No newline at end of file diff --git a/test/WebSites/FilesWebSite/Startup.cs b/test/WebSites/FilesWebSite/Startup.cs new file mode 100644 index 0000000000..59c706ca1a --- /dev/null +++ b/test/WebSites/FilesWebSite/Startup.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Builder; +using Microsoft.AspNet.Routing; +using Microsoft.Framework.DependencyInjection; + +namespace FilesWebSite +{ + public class Startup + { + public void Configure(IApplicationBuilder app) + { + var configuration = app.GetTestConfiguration(); + + app.UseServices(services => + { + services.AddMvc(configuration); + }); + + app.UseMiddleware(); + + app.UseMvc(routes => + { + routes.MapRoute(name: null, template: "{controller}/{action}", defaults: null); + }); + } + } +} \ No newline at end of file diff --git a/test/WebSites/FilesWebSite/project.json b/test/WebSites/FilesWebSite/project.json new file mode 100644 index 0000000000..1d2b4c1309 --- /dev/null +++ b/test/WebSites/FilesWebSite/project.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "Microsoft.AspNet.Mvc": "", + "Microsoft.AspNet.Mvc.TestConfiguration": "", + "Microsoft.AspNet.Server.IIS": "1.0.0-*" + }, + "frameworks": { + "aspnet50": { }, + "aspnetcore50": { } + } +} diff --git a/test/WebSites/FilesWebSite/sample.txt b/test/WebSites/FilesWebSite/sample.txt new file mode 100644 index 0000000000..a34f4c4be3 --- /dev/null +++ b/test/WebSites/FilesWebSite/sample.txt @@ -0,0 +1 @@ +This is a sample text file \ No newline at end of file