Support download with resume in PHP
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