Support download with resume in PHP

PHP Aug 04, 2020 Viewed 550 Comments 0

Requirement

In web application, we often need to provide file downloads. For small files, there is no problems since it needs a short time to download. For large files, it’s useful to allow downloads to be resumed. Doing so is more involved, but it’s really worth doing, especially if you serve large files or video/audio.

We can do a resumable downloads with the Http headers of Accept-Ranges, Content-Range, Range.

Http header

Accept-Ranges

The Accept-Ranges response HTTP header is a marker used by the server to advertise its support of partial requests. The value of this field indicates the unit that can be used to define a range. In presence of an Accept-Ranges header, the browser may try to resume an interrupted download, rather than to start it from the start again.

Syntax

Accept-Ranges: bytes
Accept-Ranges: none

none

No range unit is supported, this makes the header equivalent of its own absence and is therefore rarely used, though some browsers, like IE9, it is used to disable or remove the pause buttons in the download manager.

bytes

Defines the range unit the server supports. 

Content-Range

The Content-Range response HTTP header indicates where in a full body message a partial message belongs.

Syntax

Content-Range: <unit> <range-start>-<range-end>/<size>
Content-Range: <unit> <range-start>-<range-end>/*
Content-Range: <unit> */<size>

unit

The unit in which ranges are specified. This is usually bytes.

range-start

An integer in the given unit indicating the beginning of the request range.

range-end

An integer in the given unit indicating the end of the requested range.

size

The total size of the document (or '*' if unknown).

Range

The Range HTTP request header indicates the part of a document that the server should return. Several parts can be requested with one Range header at once, and the server may send back these ranges in a multipart document. If the server sends back ranges, it uses the 206 Partial Content for the response. If the ranges are invalid, the server returns the 416 Range Not Satisfiable error. The server can also ignore the Range header and return the whole document with a 200 status code.

Syntax

Range: <unit>=<range-start>-
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=-<suffix-length>

unit

The unit in which ranges are specified. This is usually bytes.

range-start

An integer in the given unit indicating the beginning of the request range.

range-end

An integer in the given unit indicating the end of the requested range. This value is optional and, if omitted, the end of the document is taken as the end of the range.

suffix-length

An integer in the given unit indicating the number of units at the end of the file to return.

Example

Assume that the client only send one Range in request header. The code of index.php is as follows.

<?php
$filePath = filter_input(INPUT_GET, "path");
if (empty($filePath)) {
    exit("name empty");
}
if (!file_exists($filePath)) {
    exit("file not exist: " . $filePath);
}
$fileSize = filesize($filePath);
$fInfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($fInfo, $filePath);
$start = 0;
$end = $fileSize;

if (isset($_SERVER['HTTP_RANGE'])) {
    // if the HTTP_RANGE header is set we're dealing with partial content
    $partialContent = true;
    // find the requested range
    // this might be too simplistic, apparently the client can request
    // multiple ranges, which can become pretty complex, so ignore it for now
    preg_match('/bytes=(\d+)-(\d+)?/', $_SERVER['HTTP_RANGE'], $matches);
    $start = intval($matches[1]);
    if (isset($matches[2])) {
        $end = intval($matches[2]);
    } else {
        $end = $fileSize - 1;
    }
} else {
    $partialContent = false;
}
$readSize = $end - $start + 1;
header("Content-Disposition: attachment; filename=\"{$filePath}\"");
header("Content-Type: $mimeType");
header("Content-Length: " . $readSize);
//echo "Content-Length: " . $readSize . PHP_EOL;
if ($partialContent) {
    // output the right headers for partial content
    header('HTTP/1.1 206 Partial Content');
    header('Content-Range: bytes ' . $start . '-' . ($end) . '/' . $fileSize);
//    echo 'Content-Range: bytes ' . $start . '-' . ($end) . '/' . $fileSize . PHP_EOL;
} else {
    header('Accept-Ranges: bytes');
}
$file = fopen($filePath, "r");
if ($start > 0) {
    // seek to the requested offset, this is 0 if it's not a partial content request
    fseek($file, $start);
}
$total = 0;
$chunk = 1024 * 16;
while (!feof($file) && $total < $readSize) {
    if ($total + $chunk > $readSize) {
        $length = $readSize - $total;
    } else {
        $length = $chunk;
    }
    $total += $length;
    print(fread($file, $length));
//    fread($file, $length);
    ob_flush();
    flush();
}
fclose($file);

Run it

Using the curl command to output the response header only. 

  • Send without header
$ curl -I "http://localhost/index.php?path=test.zip" 
HTTP/1.1 200 OK
Server: nginx/1.19.0
Date: Sun, 02 Aug 2020 10:39:30 GMT
Content-Type: application/zip
Content-Length: 778
Connection: keep-alive
X-Powered-By: PHP/5.4.45
Content-Disposition: attachment; filename="test.zip"
Accept-Ranges: bytes
  • Send with Range
% curl -I -H "range: bytes=3-10" "http://localhost/index.php?path=test.zip" 
HTTP/1.1 206 Partial Content
Server: nginx/1.19.0
Date: Sun, 02 Aug 2020 10:42:00 GMT
Content-Type: application/zip
Content-Length: 8
Connection: keep-alive
X-Powered-By: PHP/5.4.45
Content-Disposition: attachment; filename="test.zip"
Content-Range: bytes 3-10/777
Updated Aug 04, 2020