views:

2827

answers:

7

We are using a PHP scripting for tunneling file downloads, since we don't want to expose the absolute path of downloadable file:

header("Content-Type: $ctype");
header("Content-Length: " . filesize($file));
header("Content-Disposition: attachment; filename=\"$fileName\"");
readfile($file);

Unfourtunately we noticed that downloads passed through this script can't be resumed by the end user. Is there any way to support resumeable downloads with such a PHP based solution?

+7  A: 

Yes. Support byteranges. See RFC 2616 section 14.35 .

It basically means that you should read the Range header, and start serving the file from the specified offset.

This means that you can't use readfile(), since that serves the whole file. Instead, use fopen() first, then fseek() to the correct position, and then use fpassthru() to serve the file.

Sietse
fpassthru is not a good idea if the file is multiple megabytes, you might run out of memory. Just fread() and print() in chunks.
Wimmer
A: 

Resuming downloads in HTTP is done through the Range header. If the request contains a Range header, and if other indicators (e.g. If-Match, If-Unmodified-Since) indicate that the content hasn't changed since the download was started, you give a 206 response code (rather than 200), indicate the range of bytes you're returning in the Content-Range header, then provide that range in the response body.

I don't know how to do that in PHP, though.

Mike Dimmick
A: 

Yes, you can use the Range header for that. You need to give 3 more headers to the client for a full download:

header ("Accept-Ranges: bytes");
header ("Content-Length: " . $fileSize);
header ("Content-Range: bytes 0-" . $fileSize - 1 . "/" . $fileSize . ";");

Than for an interrupted download you need to check the Range request header by:

$headers = getAllHeaders ();
$range = substr ($headers['Range'], '6');

And in this case don't forget to serve the content with 206 status code:

header ("HTTP/1.1 206 Partial content");
header ("Accept-Ranges: bytes");
header ("Content-Length: " . $remaining_length);
header ("Content-Range: bytes " . $start . "-" . $to . "/" . $fileSize . ";");

You'll get the $start and $to variables from the request header, and use fseek() to seek to the correct position in the file.

Zsolt Sz.
I imagine this code isn't very useful without the getAllHeaders() function...
ceejayoz
@ceejayoz: getallheaders() is a php function that you get if you are using apache http://uk2.php.net/getallheaders
Tom Haigh
+30  A: 

The first thing you need to do is to send the Accept-Ranges: bytes header in all responses, to tell the client that you support partial content. Then, if request with a Range: bytes=x-y header is received (with x and y being numbers) you parse the range the client is requesting, open the file as usual, seek x bytes ahead and send the next y - x bytes. Also set the response to HTTP/1.0 206 Partial Content.

Without having tested anything, this could work, more or less:

$filesize = filesize($file);

$offset = 0;
$length = $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);

 $offset = intval($matches[1]);
 $length = intval($matches[2]) - $offset;
} else {
 $partialContent = false;
}

$file = fopen($file, 'r');

// seek to the requested offset, this is 0 if it's not a partial content request
fseek($file, $offset);

$data = fread($file, $length);

fclose($file);

if ( $partialContent ) {
 // output the right headers for partial content

 header('HTTP/1.1 206 Partial Content');

 header('Content-Range: bytes ' . $offset . '-' . ($offset + $length) . '/' . $filesize);
}

// output the regular HTTP headers
header('Content-Type: ' . $ctype);
header('Content-Length: ' . $filesize);
header('Content-Disposition: attachment; filename="' . $fileName . '"');
header('Accept-Ranges: bytes');

// don't forget to send the data too
print($data);

I may have missed something obvious, and I have most definitely ignored some potential sources of errors, but it should be a start.

There's a description of partial content here and I found some info on partial content on the documentation page for fread.

Theo
Nice detailed answer!
MDCore
Small bug, your regular expression should be: preg_match('/bytes=(\d+)-(\d+)?/', $_SERVER['HTTP_RANGE'], $matches)
deepwell
i like this. never even realised it was do-able.
benlumley
You're right and I've changed it. However, I it's too simplistic anyway, according to the specs you can do "bytes=x-y", "bytes=-x", "bytes=x-", "bytes=x-y,a-b", etc. so the bug in the previous version was the missing end slash, not the lack of a question mark.
Theo
+2  A: 

I found a script here that I used for our download script. It works perfectly for us resuming partial downloads.

Thangaraj
A: 

A really nice way to solve this without having to "roll your own" PHP code is to use the mod_xsendfile Apache module. Then in PHP, you just set the appropriate headers. Apache gets to do its thing.

header("X-Sendfile: /path/to/file");
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; file=\"filename\"");
Jonathan Hawkes
A: 

@Jonathan Hawkes

does your code:

header("X-Sendfile: /path/to/file");
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; file=\"filename\"");

handle the range bit itself too?? I see it has x-sendfile module. I have some questions regarding this. dont we have to specify a Contect-Length header ?

What if we are using CURL to get the file's content from our fileserver then using X-Sendfile to serve the file to the end user? how would X-Sendfile handle the resume download then? (range)

I believe X-Sendfile does handle Ranges - it'll read the request headers and response appropriately. This is much more efficient than doing the same in PHP.
David Caunt
what if the file is on a remote server?
AND we're using CURL to get the file from the server according to the range we received?