Supporting resumable HTTP-downloads through an ASHX handler?

Uwe Keim picture Uwe Keim · Mar 25, 2011 · Viewed 8.9k times · Source

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?

Answer

Michael picture Michael · Jun 25, 2011

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.