Last active
June 9, 2022 10:04
-
-
Save ngbrown/739881c98e3b3600dd1fb814793cdf03 to your computer and use it in GitHub Desktop.
NancyFX PartialFileResponse
This file contains 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
using System; | |
using System.IO; | |
using Nancy; | |
namespace WebService.Extensions | |
{ | |
public static class FormatterExtensions | |
{ | |
public static Response AsPartialFile(this IResponseFormatter formatter, string applicationRelativeFilePath, string contentType) | |
{ | |
return (Response)new PartialFileResponse(applicationRelativeFilePath, contentType, formatter.Context); | |
} | |
public static Response AsPartialFile(this IResponseFormatter formatter, string applicationRelativeFilePath) | |
{ | |
return (Response)new PartialFileResponse(applicationRelativeFilePath, formatter.Context); | |
} | |
} | |
} |
This file contains 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
using System; | |
using System.Collections.Generic; | |
using System.Globalization; | |
using System.IO; | |
using System.Linq; | |
using Nancy; | |
using Nancy.Helpers; | |
namespace WebService.Extensions | |
{ | |
/// <summary> | |
/// A response representing a file. | |
/// </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 PartialFileResponse : Response | |
{ | |
private const string ContentLength = "Content-Length"; | |
private const string AcceptRanges = "Accept-Ranges"; | |
private const string ContentRange = "Content-Range"; | |
private const string ContentDisposition = "Content-Disposition"; | |
private const string ETag = "ETag"; | |
private const string LastModified = "Last-Modified"; | |
private const string IfRange = "If-Range"; | |
/// <summary> | |
/// Represents a list of "base paths" where it is safe to | |
/// serve files from. | |
/// Attempting to server a file outside of these safe paths | |
/// will fail with a 404. | |
/// </summary> | |
public static IList<string> SafePaths { get; set; } | |
/// <summary> | |
/// Size of buffer for transmitting file between flushes. | |
/// </summary> | |
public static int BufferSize = 0x1000; | |
static PartialFileResponse() | |
{ | |
SafePaths = new List<string>(); | |
} | |
/// <summary> | |
/// Initializes a new instance of the <see cref="PartialFileResponse"/> for the file specified | |
/// by the <paramref name="filePath"/> parameter. | |
/// </summary> | |
/// <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 PartialFileResponse(string filePath, NancyContext context) | |
: this(filePath, MimeTypes.GetMimeType(filePath), context) | |
{ | |
} | |
/// <summary> | |
/// Initializes a new instance of the <see cref="PartialFileResponse"/> for the file specified | |
/// by the <paramref name="filePath"/> parameter and the content-type specified by the <paramref name="contentType"/> parameter. | |
/// </summary> | |
/// <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 PartialFileResponse(string filePath, string contentType, NancyContext context) | |
{ | |
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 static Action<Stream> GetFileContent(string filePath, long rangeStart, long rangeEnd) | |
{ | |
return stream => | |
{ | |
using (var source = File.OpenRead(filePath)) | |
{ | |
if (!source.CanSeek) | |
throw new InvalidOperationException( | |
"Sending Range Responses requires a seekable stream eg. FileStream or MemoryStream"); | |
var totalBytesToSend = rangeEnd - rangeStart + 1; | |
var buffer = new byte[BufferSize]; | |
var bytesRemaining = totalBytesToSend; | |
source.Seek(rangeStart, SeekOrigin.Begin); | |
while (bytesRemaining > 0) | |
{ | |
var count = bytesRemaining <= buffer.Length | |
? source.Read(buffer, 0, (int)Math.Min(bytesRemaining, int.MaxValue)) | |
: source.Read(buffer, 0, buffer.Length); | |
try | |
{ | |
stream.Write(buffer, 0, count); | |
stream.Flush(); | |
bytesRemaining -= count; | |
} | |
catch (Exception httpException) | |
{ | |
/* in Asp.Net we can call HttpResponseBase.IsClientConnected | |
* to see if the client broke off the connection | |
* and avoid trying to flush the response stream. | |
* instead I'll swallow the exception that IIS throws in this situation | |
* and rethrow anything else.*/ | |
if (httpException.Message | |
== "An error occurred while communicating with the remote host. The error code is 0x80070057.") | |
{ | |
return; | |
} | |
throw; | |
} | |
} | |
} | |
}; | |
} | |
static bool IsSafeFilePath(string rootPath, string filePath) | |
{ | |
if (!File.Exists(filePath)) | |
{ | |
return false; | |
} | |
var fullPath = Path.GetFullPath(filePath); | |
return fullPath.StartsWith(Path.GetFullPath(rootPath), StringComparison.OrdinalIgnoreCase); | |
} | |
private void InitializeGenericFileResponse(string filePath, string contentType, NancyContext context) | |
{ | |
if (string.IsNullOrEmpty(filePath)) | |
{ | |
this.StatusCode = HttpStatusCode.NotFound; | |
return; | |
} | |
if (SafePaths == null || SafePaths.Count == 0) | |
{ | |
throw new InvalidOperationException("No SafePaths defined."); | |
} | |
foreach (var rootPath in SafePaths) | |
{ | |
string fullPath; | |
if (Path.IsPathRooted(filePath)) | |
{ | |
fullPath = filePath; | |
} | |
else | |
{ | |
fullPath = Path.Combine(rootPath, filePath); | |
} | |
if (IsSafeFilePath(rootPath, fullPath)) | |
{ | |
this.Filename = 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 = new FileInfo(fullPath); | |
var lastWriteTimeUtc = fi.LastWriteTimeUtc; | |
var etag = string.Concat("\"", HashFileInfo(fi).ToString("x16"), "\""); | |
var lastModified = lastWriteTimeUtc.ToString("R"); | |
var sourceLength = fi.Length; | |
this.Headers[AcceptRanges] = "bytes"; | |
if (CacheHelpers.ReturnNotModified(etag, lastWriteTimeUtc, context)) | |
{ | |
this.StatusCode = HttpStatusCode.NotModified; | |
this.ContentType = null; | |
this.Contents = Response.NoBody; | |
return; | |
} | |
this.Headers[ETag] = etag; | |
this.Headers[LastModified] = lastModified; | |
Range range = new Range(false, 0, sourceLength - 1); | |
var ifRange = context.Request.Headers.IfRange; | |
DateTime ifModifiedTime; | |
if (string.IsNullOrEmpty(ifRange) | |
|| ifRange == etag | |
|| (DateTime.TryParseExact(ifRange, "R", CultureInfo.InvariantCulture, DateTimeStyles.None, out ifModifiedTime) | |
&& (lastWriteTimeUtc - ifModifiedTime).Duration().TotalSeconds < 1)) | |
{ | |
// rangeHeader should be of the format "bytes=0-" or "bytes=0-12345" or "bytes=123-456" | |
range = GetRangeFromHeaders(context.Request, sourceLength); | |
} | |
if (range.ReturnRange == false) | |
{ | |
this.Headers[ContentLength] = sourceLength.ToString(CultureInfo.InvariantCulture); | |
this.StatusCode = HttpStatusCode.OK; | |
} | |
else | |
{ | |
if (range.Start < 0 || range.End > sourceLength - 1 || range.Length < 0) | |
{ | |
this.StatusCode = HttpStatusCode.RequestedRangeNotSatisfiable; | |
this.Contents = Response.NoBody; | |
return; | |
} | |
else | |
{ | |
this.Headers[ContentLength] = range.Length.ToString(CultureInfo.InvariantCulture); | |
this.Headers[ContentRange] = string.Format("bytes {0}-{1}/{2}", range.Start, range.End, sourceLength); | |
this.StatusCode = HttpStatusCode.PartialContent; | |
} | |
} | |
if (range.Length > 0) | |
{ | |
this.Contents = GetFileContent(fullPath, range.Start, range.End); | |
} | |
this.ContentType = contentType; | |
} | |
private static Range GetRangeFromHeaders(Request request, long sourceLength) | |
{ | |
// rangeHeader should be of the format "bytes=0-" or "bytes=0-12345" or "bytes=123-456" | |
var rangeHeader = request.Headers["Range"].FirstOrDefault(); | |
if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.Contains("=")) | |
{ | |
var rangeParts = rangeHeader.Split('=')[1].Split('-'); | |
var rangeStart = long.Parse(rangeParts[0]); | |
var rangeEnd = rangeParts.Length == 2 && !string.IsNullOrEmpty(rangeParts[1]) | |
? long.Parse(rangeParts[1]) // the client requested a chunk | |
: sourceLength - 1; | |
return new Range(true, rangeStart, rangeEnd); | |
} | |
return new Range(false, 0, sourceLength - 1); | |
} | |
private static long HashFileInfo(FileInfo fileInfo) | |
{ | |
unchecked // Overflow is fine, just wrap | |
{ | |
long hash = 17; | |
hash = (hash * 23) + fileInfo.Length; | |
hash = (hash * 23) + fileInfo.LastWriteTimeUtc.Ticks; | |
return hash; | |
} | |
} | |
private struct Range | |
{ | |
public Range(bool returnRange, long start, long end) | |
{ | |
this.ReturnRange = returnRange; | |
this.Start = start; | |
this.End = end; | |
} | |
public bool ReturnRange { get; } | |
public long Start { get; } | |
public long End { get; } | |
public long Length => (this.End - this.Start + 1); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment