Skip to content

Instantly share code, notes, and snippets.

@danbarua
Created June 17, 2014 15:35
Show Gist options
  • Save danbarua/6e0859635e5371885a16 to your computer and use it in GitHub Desktop.
Save danbarua/6e0859635e5371885a16 to your computer and use it in GitHub Desktop.
Testable implementation of Nancy's GenericFileResponse
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