Created
June 17, 2014 15:35
-
-
Save danbarua/6e0859635e5371885a16 to your computer and use it in GitHub Desktop.
Testable implementation of Nancy's GenericFileResponse
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
namespace WordWatch.Server.Api | |
{ | |
using System; | |
using System.IO; | |
using System.IO.Abstractions; | |
using Nancy; | |
using Nancy.Helpers; | |
using Nancy.Responses; | |
/// <summary> | |
/// A testable implementation of Nancy's GenericFileResponse class. | |
/// Copy + pasted from Nancy source code and adapted to use System.IO.Abstractions IFileSystem implemenation. | |
/// </summary> | |
/// <remarks>If the response contains an invalid file (not found, empty name, missing extension and so on) the status code of the response will be set to <see cref="HttpStatusCode.NotFound"/>.</remarks> | |
public class TestableFileResponse : Response | |
{ | |
private readonly IFileSystem fileSystem; | |
/// <summary> | |
/// Size of buffer for transmitting file. Default size 4 Mb | |
/// </summary> | |
public static int BufferSize = 4 * 1024 * 1024; | |
/// <summary> | |
/// Initializes a new instance of the <see cref="TestableFileResponse"/> for the file specified | |
/// by the <param name="filePath" /> parameter. | |
/// </summary> | |
/// <param name="fileSystem">The <seealso cref="System.IO.Abstractions.IFileSystem"/> filesystem implementation.</param> | |
/// <param name="filePath">The name of the file, including path relative to the root of the application, that should be returned.</param> | |
/// <remarks>The <see cref="MimeTypes.GetMimeType"/> method will be used to determine the mimetype of the file and will be used as the content-type of the response. If no match if found the content-type will be set to application/octet-stream.</remarks> | |
public TestableFileResponse(IFileSystem fileSystem, string filePath) : | |
this(fileSystem, filePath, MimeTypes.GetMimeType(filePath)) | |
{ | |
} | |
/// <summary> | |
/// Initializes a new instance of the <see cref="TestableFileResponse"/> for the file specified | |
/// by the <param name="filePath" /> parameter. | |
/// </summary> | |
/// <param name="fileSystem">The <seealso cref="System.IO.Abstractions.IFileSystem"/> filesystem implementation.</param> | |
/// <param name="filePath">The name of the file, including path relative to the root of the application, that should be returned.</param> | |
/// <remarks>The <see cref="MimeTypes.GetMimeType"/> method will be used to determine the mimetype of the file and will be used as the content-type of the response. If no match if found the content-type will be set to application/octet-stream.</remarks> | |
/// <param name="context">Current context</param> | |
public TestableFileResponse(IFileSystem fileSystem, string filePath, NancyContext context) | |
: this(fileSystem, filePath, MimeTypes.GetMimeType(filePath), context) | |
{ | |
} | |
/// <summary> | |
/// Initializes a new instance of the <see cref="TestableFileResponse"/> for the file specified | |
/// by the <param name="filePath" /> parameter and the content-type specified by the <param name="contentType" /> parameter. | |
/// </summary> | |
/// <param name="fileSystem">The <seealso cref="System.IO.Abstractions.IFileSystem"/> filesystem implementation.</param> | |
/// <param name="filePath">The name of the file, including path relative to the root of the application, that should be returned.</param> | |
/// <param name="contentType">The content-type of the response.</param> | |
/// <param name="context">Current context</param> | |
public TestableFileResponse(IFileSystem fileSystem, string filePath, string contentType, NancyContext context = null) | |
{ | |
this.fileSystem = fileSystem; | |
this.InitializeGenericFileResponse(filePath, contentType, context); | |
} | |
/// <summary> | |
/// Gets the filename of the file response | |
/// </summary> | |
/// <value>A string containing the name of the file.</value> | |
public string Filename { get; protected set; } | |
private Action<Stream> GetFileContent(string filePath, long length) | |
{ | |
return stream => | |
{ | |
using (var file = this.fileSystem.File.OpenRead(filePath)) | |
{ | |
file.CopyTo(stream, (int)(length < BufferSize ? length : BufferSize)); | |
} | |
}; | |
} | |
private bool IsSafeFilePath(string rootPath, string filePath) | |
{ | |
if (!this.fileSystem.Path.HasExtension(filePath)) | |
{ | |
return false; | |
} | |
if (!this.fileSystem.File.Exists(filePath)) | |
{ | |
return false; | |
} | |
var fullPath = this.fileSystem.Path.GetFullPath(filePath); | |
return fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase); | |
} | |
private void InitializeGenericFileResponse(string filePath, string contentType, NancyContext context) | |
{ | |
if (string.IsNullOrEmpty(filePath)) | |
{ | |
this.StatusCode = HttpStatusCode.NotFound; | |
return; | |
} | |
if (GenericFileResponse.SafePaths == null || GenericFileResponse.SafePaths.Count == 0) | |
{ | |
throw new InvalidOperationException("No SafePaths defined."); | |
} | |
foreach (var rootPath in GenericFileResponse.SafePaths) | |
{ | |
string fullPath; | |
if (this.fileSystem.Path.IsPathRooted(filePath)) | |
{ | |
fullPath = filePath; | |
} | |
else | |
{ | |
fullPath = this.fileSystem.Path.Combine(rootPath, filePath); | |
} | |
if (this.IsSafeFilePath(rootPath, fullPath)) | |
{ | |
this.Filename = this.fileSystem.Path.GetFileName(fullPath); | |
this.SetResponseValues(contentType, fullPath, context); | |
return; | |
} | |
} | |
this.StatusCode = HttpStatusCode.NotFound; | |
} | |
private void SetResponseValues(string contentType, string fullPath, NancyContext context) | |
{ | |
// TODO - set a standard caching time and/or public? | |
var fi = this.fileSystem.FileInfo.FromFileName(fullPath); | |
var lastWriteTimeUtc = fi.LastWriteTimeUtc; | |
var etag = string.Concat("\"", lastWriteTimeUtc.Ticks.ToString("x"), "\""); | |
var lastModified = lastWriteTimeUtc.ToString("R"); | |
if (CacheHelpers.ReturnNotModified(etag, lastWriteTimeUtc, context)) | |
{ | |
this.StatusCode = HttpStatusCode.NotModified; | |
this.ContentType = null; | |
this.Contents = Response.NoBody; | |
return; | |
} | |
this.Headers["ETag"] = etag; | |
this.Headers["Last-Modified"] = lastModified; | |
if (fi.Length > 0) | |
{ | |
this.Contents = this.GetFileContent(fullPath, fi.Length); | |
} | |
this.ContentType = contentType; | |
this.StatusCode = HttpStatusCode.OK; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment