Supporting resumable HTTP-downloads through an ASHX handler?
Asked Answered
E

2

10

We are providing downloads of our application setups through an ASHX handler in ASP.NET.

A customer told us he uses some third party download manager application and that our way of providing the files currently does not support the "resume" feature of his download manager application.

My questions are:

What are the basic ideas behind resuming a download? Is there a certain HTTP GET request that tells me the offset to start at?

Edythedythe answered 25/3, 2011 at 8:7 Comment(0)
W
4

Resuming a download usually works through the HTTP Range header. For example, if a client wants only the second kilobyte of a file, it might send the header Range: bytes=1024-2048.

You can see page 139 of the RFC for HTTP/1.1 for more information.

Welterweight answered 25/3, 2011 at 8:29 Comment(0)
S
17

Thanks icktoofay for getting me started, here's a complete example to save other developers some time:

Disk Example

/// <summary>
/// Writes the file stored in the filesystem to the response stream without buffering in memory, ideal for large files. Supports resumable downloads.
/// </summary>
/// <param name="filename">The name of the file to write to the HTTP output.</param>
/// <param name="etag">A unique identifier for the content. Required for IE9 resumable downloads, must be a strong etag which means begins and ends in a quote i.e. "\"6c132-941-ad7e3080\""</param>
public static void TransmitFile(this HttpResponse response, string filename, string etag)
{
    var request = HttpContext.Current.Request;
    var fileInfo = new FileInfo(filename);
    var responseLength = fileInfo.Exists ? fileInfo.Length : 0;
    var buffer = new byte[4096];
    var startIndex = 0;

    //if the "If-Match" exists and is different to etag (or is equal to any "*" with no resource) then return 412 precondition failed
    if (request.Headers["If-Match"] == "*" && !fileInfo.Exists ||
        request.Headers["If-Match"] != null && request.Headers["If-Match"] != "*" && request.Headers["If-Match"] != etag)
    {
        response.StatusCode = (int)HttpStatusCode.PreconditionFailed;
        response.End();
    }

    if (!fileInfo.Exists)
    {
        response.StatusCode = (int)HttpStatusCode.NotFound;
        response.End();
    }

    if (request.Headers["If-None-Match"] == etag)
    {
        response.StatusCode = (int)HttpStatusCode.NotModified;
        response.End();
    }

    if (request.Headers["Range"] != null && (request.Headers["If-Range"] == null || request.Headers["IF-Range"] == etag))
    {
        var match = Regex.Match(request.Headers["Range"], @"bytes=(\d*)-(\d*)");
        startIndex = Parse<int>(match.Groups[1].Value);
        responseLength = (Parse<int?>(match.Groups[2].Value) + 1 ?? fileInfo.Length) - startIndex;
        response.StatusCode = (int)HttpStatusCode.PartialContent;
        response.Headers["Content-Range"] = "bytes " + startIndex + "-" + (startIndex + responseLength - 1) + "/" + fileInfo.Length;
    }

    response.Headers["Accept-Ranges"] = "bytes";
    response.Headers["Content-Length"] = responseLength.ToString();
    response.Cache.SetCacheability(HttpCacheability.Public); //required for etag output
    response.Cache.SetETag(etag); //required for IE9 resumable downloads
    response.TransmitFile(filename, startIndex, responseLength);
}

public void ProcessRequest(HttpContext context)
{
    var id = Parse<int>(context.Request.QueryString["id"]);
    var version = context.Request.QueryString["v"];
    var db = new DataClassesDataContext();
    var filePath = db.Documents.Where(d => d.ID == id).Select(d => d.Fullpath).FirstOrDefault();

    if (String.IsNullOfEmpty(filePath) || !File.Exists(filePath))
    {
        context.Response.StatusCode = (int)HttpStatusCode.NotFound;
        context.Response.End();
    }

    context.Response.AddHeader("content-disposition", "filename=" + Path.GetFileName(filePath));
    context.Response.ContentType = GetMimeType(filePath);
    context.Response.TransmitFile(filePath, version);
}

Database Example

/// <summary>
/// Writes the file stored in the database to the response stream without buffering in memory, ideal for large files. Supports resumable downloads.
/// </summary>
/// <param name="retrieveBinarySql">The sql to retrieve the binary data of the file from the database to be transmitted to the client. Parameters can be reffered to by {0} the index in the supplied parameter array.</param>
/// <param name="retrieveBinarySqlParameters">The parameters used in the sql query. Specify null if no parameters are required.</param>
/// <param name="connectionString">The connectring string for the sql database.</param>
/// <param name="contentLength">The length of the content in bytes.</param>
/// <param name="etag">A unique identifier for the content. Required for IE9 resumable downloads, must be a strong etag which means begins and ends in a quote i.e. "\"6c132-941-ad7e3080\""</param>
/// <param name="useFilestream">If the binary data is stored using Sql's Filestream feature set this to true to stream the file directly.</param>
public static void TransmitFile(this HttpResponse response, string retrieveBinarySql, object[] retrieveBinarySqlParameters, string connectionString, int contentLength, string etag, bool useFilestream)
{
    var request = HttpContext.Current.Request;
    var responseLength = contentLength;
    var buffer = new byte[4096];
    var startIndex = 0;

    //if the "If-Match" exists and is different to etag (or is equal to any "*" with no resource) then return 412 precondition failed
    if (request.Headers["If-Match"] == "*" && contentLength == 0 ||
        request.Headers["If-Match"] != null && request.Headers["If-Match"] != "*" && request.Headers["If-Match"] != etag)
    {
        response.StatusCode = (int)HttpStatusCode.PreconditionFailed;
        response.End();
    }

    if (contentLength == 0)
    {
        response.StatusCode = (int)HttpStatusCode.NotFound;
        response.End();
    }

    if (request.Headers["If-None-Match"] == etag)
    {
        response.StatusCode = (int)HttpStatusCode.NotModified;
        response.End();
    }

    if (request.Headers["Range"] != null && (request.Headers["If-Range"] == null || request.Headers["IF-Range"] == etag))
    {
        var match = Regex.Match(request.Headers["Range"], @"bytes=(\d*)-(\d*)");
        startIndex = Parse<int>(match.Groups[1].Value);
        responseLength = (Parse<int?>(match.Groups[2].Value) + 1 ?? contentLength) - startIndex;
        response.StatusCode = (int)HttpStatusCode.PartialContent;
        response.Headers["Content-Range"] = "bytes " + startIndex + "-" + (startIndex + responseLength - 1) + "/" + contentLength;
    }

    response.Headers["Accept-Ranges"] = "bytes";
    response.Headers["Content-Length"] = responseLength.ToString();
    response.Cache.SetCacheability(HttpCacheability.Public); //required for etag output
    response.Cache.SetETag(etag); //required for IE9 resumable downloads
    response.BufferOutput = false; //don't load entire data into memory (buffer) before sending

    if (!useFilestream)
    {
        using (var connection = new SqlConnection(connectionString))
        {
            connection.Open();
            var command = new SqlCommand(retrieveBinarySql, connection);

            for (var i = 0; retrieveBinarySqlParameters != null && i < retrieveBinarySqlParameters.Length; i++)
            {
                command.Parameters.AddWithValue("p" + i, retrieveBinarySqlParameters[i]);
                command.CommandText = command.CommandText.Replace("{" + i + "}", "@p" + i);
            }

            var reader = command.ExecuteReader(CommandBehavior.SequentialAccess);
            if (!reader.Read())
            {
                response.StatusCode = (int)HttpStatusCode.NotFound;
                response.End();
            }

            for (var i = startIndex; i < contentLength; i += buffer.Length)
            {
                var bytesRead = (int)reader.GetBytes(0, i, buffer, 0, buffer.Length);
                response.OutputStream.Write(buffer, 0, bytesRead);
            }
        }
    }
    else
    {
        using (var connection = new SqlConnection(connectionString))
        {
            connection.Open();
            var tran = connection.BeginTransaction(IsolationLevel.ReadCommitted);
            var command = new SqlCommand(Regex.Replace(retrieveBinarySql, @"select \w+ ", v => v.Value.TrimEnd() + ".PathName(), GET_FILESTREAM_TRANSACTION_CONTEXT() "), connection);
            command.Transaction = tran;

            for (var i = 0; retrieveBinarySqlParameters != null && i < retrieveBinarySqlParameters.Length; i++)
            {
                command.Parameters.AddWithValue("p" + i, retrieveBinarySqlParameters[i]);
                command.CommandText = command.CommandText.Replace("{" + i + "}", "@p" + i);
            }

            var reader = command.ExecuteReader();
            if (!reader.Read())
            {
                response.StatusCode = (int)HttpStatusCode.NotFound;
                response.End();
            }

            var path = reader.GetString(0);
            var transactionContext = (byte[])reader.GetValue(1);

            using (var fileStream = new SqlFileStream(path, transactionContext, FileAccess.Read, FileOptions.SequentialScan, 0))
            {
                fileStream.Seek(startIndex, SeekOrigin.Begin);
                int bytesRead;
                do
                {
                    bytesRead = fileStream.Read(buffer, 0, buffer.Length);
                    response.OutputStream.Write(buffer, 0, bytesRead);
                }
                while (bytesRead == buffer.Length);
            }

            tran.Commit();
        }
    }
}

public void ProcessRequest(HttpContext context)
{
    var id = Parse<int>(context.Request.QueryString["id"]);
    var db = new DataClassesDataContext();
    var doc = db.Documents.Where(d => d.ID == id).Select(d => new { d.Data.Length, d.Filename, d.Version }).FirstOrDefault();

    if (doc == null)
    {
        context.Response.StatusCode = (int)HttpStatusCode.NotFound;
        context.Response.End();
    }

    context.Response.AddHeader("content-disposition", "filename=" + doc.Filename);
    context.Response.ContentType = GetMimeType(doc.Filename);
    context.Response.TransmitFile("select data from documents where id = {0}", new[] { id }, db.Connection.ConnectionString, doc.Length, doc.Version, false);
}

Helper Methods

public static T Parse<T>(object value)
{
    //convert value to string to allow conversion from types like float to int
    //converter.IsValid only works since .NET4 but still returns invalid values for a few cases like NULL for Unit and not respecting locale for date validation
    try { return (T)System.ComponentModel.TypeDescriptor.GetConverter(typeof(T)).ConvertFrom(value.ToString()); }
    catch (Exception) { return default(T); }
}

public string GetMimeType(string fileName)
{
    //note use version 2.0.0.0 if .NET 4 is not installed, in .NET 4.5 this method has now been made public, this method apparently stores a list of mime types which would be more complete then using registry
    return (string)Assembly.Load("System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a")
        .GetType("System.Web.MimeMapping")
        .GetMethod("GetMimeMapping", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static)
        .Invoke(null, new object[] { fileName });
}

What this demonstrates is a way of reading part of the file from either the disk or database and outputting as response rather than loading the entire file into memory, which wastes resources if the download is paused or resumed half way through.

Edit: added etag to enable resumable downloads in IE9, thanks to EricLaw for his help in getting it to work correctly in IE9.

Stunner answered 25/3, 2011 at 8:7 Comment(6)
@mcm_ham: Please provide either a URL or a Fiddler capture of your download so we can look into why you're not seeing resume.Impignorate
@Impignorate thanks for the assistance. Here is the url and fiddler capture: minsoft.org/handler.ashx minsoft.org/HandlerCapture.xmlStunner
As I explained on my post blogs.msdn.com/b/ieinternals/archive/2011/06/03/… your page resumes just fine. You should not rely on the F12 developer tools to show you a HTTP/206 response.Impignorate
@EricLaw, thanks for your help with fiddler I see that I was missing "bytes" at the beginning of the "Content-Range" header and now it works correctly in IE9.Stunner
@Stunner Dude, thanks so much! It took me forever to figure out why on earth the iPad wouldn't play wav streamed wave files, but it was okay when IIS served them.Birdsong
Double Dude! This made my HTML5 Video streaming work after looking for a few days of hot to hide files and serve bytes only based on id! Some tweaks required but it rock solid man! You deserve a crate of beer!Trinetta
W
4

Resuming a download usually works through the HTTP Range header. For example, if a client wants only the second kilobyte of a file, it might send the header Range: bytes=1024-2048.

You can see page 139 of the RFC for HTTP/1.1 for more information.

Welterweight answered 25/3, 2011 at 8:29 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.