Skip to content

Commit e38820a

Browse files
authored
Merge pull request php-curl-class#410 from zachborboa/master
Support resume for Curl::download()
2 parents cf107b8 + 7e0b477 commit e38820a

File tree

7 files changed

+260
-25
lines changed

7 files changed

+260
-25
lines changed

src/Curl/Curl.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,27 @@ public function download($url, $mixed_filename)
305305
$fh = tmpfile();
306306
} else {
307307
$filename = $mixed_filename;
308-
$fh = fopen($filename, 'wb');
308+
309+
// Use a temporary file when downloading. Not using a temporary file can cause an error when an existing
310+
// file has already fully completed downloading and a new download is started with the same destination save
311+
// path. The download request will include header "Range: bytes=$filesize-" which is syntactically valid,
312+
// but unsatisfiable.
313+
$download_filename = $filename . '.pccdownload';
314+
315+
$mode = 'wb';
316+
// Attempt to resume download only when a temporary download file exists and is not empty.
317+
if (file_exists($download_filename) && $filesize = filesize($download_filename)) {
318+
$mode = 'ab';
319+
$first_byte_position = $filesize;
320+
$range = $first_byte_position . '-';
321+
$this->setOpt(CURLOPT_RANGE, $range);
322+
}
323+
$fh = fopen($download_filename, $mode);
324+
325+
// Move the downloaded temporary file to the destination save path.
326+
$this->downloadCompleteFunction = function ($fh) use ($download_filename, $filename) {
327+
rename($download_filename, $filename);
328+
};
309329
}
310330

311331
$this->setOpt(CURLOPT_FILE, $fh);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace ContentRangeServer;
4+
5+
use RangeHeader\RangeHeader;
6+
7+
class ContentRangeServer
8+
{
9+
public function serve($path)
10+
{
11+
$range = new RangeHeader($_SERVER['HTTP_RANGE']);
12+
13+
$filesize = filesize($path);
14+
$fp = fopen($path, 'r');
15+
16+
if (!isset($_SERVER['HTTP_RANGE'])) {
17+
header('HTTP/1.1 200 OK');
18+
header('Content-Length: ' . $filesize);
19+
header('Accept-Ranges: bytes');
20+
fpassthru($fp);
21+
} else {
22+
header('HTTP/1.1 206 Partial Content');
23+
header('Content-Length: ' . $range->getLength($filesize));
24+
header('Content-Range: ' . $range->getContentRangeHeader($filesize));
25+
26+
$start = $range->getFirstBytePosition($filesize);
27+
if ($start > 0) {
28+
fseek($fp, $start, SEEK_SET);
29+
}
30+
31+
$length = $range->getLength($filesize);
32+
$chunk_size = 4096;
33+
while ($length) {
34+
$read = $length > $chunk_size ? $chunk_size : $length;
35+
$length -= $read;
36+
echo fread($fp, $read);
37+
}
38+
}
39+
40+
fclose($fp);
41+
}
42+
}

tests/PHPCurlClass/Helper.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<?php
2+
23
namespace Helper;
34

45
use Curl\Curl;
@@ -67,6 +68,15 @@ function create_tmp_file($data)
6768
return $tmp_file;
6869
}
6970

71+
function get_tmp_file_path()
72+
{
73+
// Return temporary file path without creating file.
74+
$tmp_file_path =
75+
rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) .
76+
DIRECTORY_SEPARATOR . 'php-curl-class.' . uniqid(rand(), true);
77+
return $tmp_file_path;
78+
}
79+
7080
function get_png()
7181
{
7282
$tmp_filename = tempnam('/tmp', 'php-curl-class.');
@@ -89,3 +99,29 @@ function mime_type($file_path)
8999
return $mime_type;
90100
}
91101
}
102+
103+
function upload_file_to_server($upload_file_path) {
104+
$upload_test = new Test();
105+
$upload_test->server('upload_response', 'POST', array(
106+
'image' => '@' . $upload_file_path,
107+
));
108+
$uploaded_file_path = $upload_test->curl->response->file_path;
109+
110+
// Ensure files are not the same path.
111+
assert(!($upload_file_path === $uploaded_file_path));
112+
113+
// Ensure file uploaded successfully.
114+
assert(md5_file($upload_file_path) === $upload_test->curl->responseHeaders['ETag']);
115+
116+
return $uploaded_file_path;
117+
}
118+
119+
function remove_file_from_server($uploaded_file_path) {
120+
$download_test = new Test();
121+
122+
// Ensure file successfully removed.
123+
assert('true' === $download_test->server('upload_cleanup', 'POST', array(
124+
'file_path' => $uploaded_file_path,
125+
)));
126+
assert(file_exists($uploaded_file_path) === false);
127+
}

tests/PHPCurlClass/PHPCurlClassTest.php

Lines changed: 97 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -609,15 +609,9 @@ public function testOptionsRequestMethod()
609609

610610
public function testDownload()
611611
{
612-
// Upload a file.
612+
// Create and upload a file.
613613
$upload_file_path = Helper\get_png();
614-
$upload_test = new Test();
615-
$upload_test->server('upload_response', 'POST', array(
616-
'image' => '@' . $upload_file_path,
617-
));
618-
$uploaded_file_path = $upload_test->curl->response->file_path;
619-
$this->assertNotEquals($upload_file_path, $uploaded_file_path);
620-
$this->assertEquals(md5_file($upload_file_path), $upload_test->curl->responseHeaders['ETag']);
614+
$uploaded_file_path = Helper\upload_file_to_server($upload_file_path);
621615

622616
// Download the file.
623617
$downloaded_file_path = tempnam('/tmp', 'php-curl-class.');
@@ -638,27 +632,19 @@ public function testDownload()
638632
$this->assertFalse(is_bool($download_test->curl->rawResponse));
639633

640634
// Remove server file.
641-
$download_test = new Test();
642-
$this->assertEquals('true', $download_test->server('upload_cleanup', 'POST', array(
643-
'file_path' => $uploaded_file_path,
644-
)));
635+
Helper\remove_file_from_server($uploaded_file_path);
645636

646637
unlink($upload_file_path);
647638
unlink($downloaded_file_path);
648639
$this->assertFalse(file_exists($upload_file_path));
649-
$this->assertFalse(file_exists($uploaded_file_path));
650640
$this->assertFalse(file_exists($downloaded_file_path));
651641
}
652642

653643
public function testDownloadCallback()
654644
{
655-
// Upload a file.
645+
// Create and upload a file.
656646
$upload_file_path = Helper\get_png();
657-
$upload_test = new Test();
658-
$upload_test->server('upload_response', 'POST', array(
659-
'image' => '@' . $upload_file_path,
660-
));
661-
$uploaded_file_path = $upload_test->curl->response->file_path;
647+
$uploaded_file_path = Helper\upload_file_to_server($upload_file_path);
662648

663649
// Download the file.
664650
$callback_called = false;
@@ -679,13 +665,101 @@ public function testDownloadCallback()
679665
$this->assertTrue($callback_called);
680666

681667
// Remove server file.
682-
$this->assertEquals('true', $upload_test->server('upload_cleanup', 'POST', array(
683-
'file_path' => $uploaded_file_path,
684-
)));
668+
Helper\remove_file_from_server($uploaded_file_path);
685669

686670
unlink($upload_file_path);
687671
$this->assertFalse(file_exists($upload_file_path));
688-
$this->assertFalse(file_exists($uploaded_file_path));
672+
}
673+
674+
public function testDownloadRange()
675+
{
676+
// Create and upload a file.
677+
$filename = Helper\get_png();
678+
$uploaded_file_path = Helper\upload_file_to_server($filename);
679+
680+
$filesize = filesize($filename);
681+
682+
foreach (array(
683+
false,
684+
0,
685+
1,
686+
2,
687+
3,
688+
5,
689+
10,
690+
25,
691+
50,
692+
$filesize - 3,
693+
$filesize - 2,
694+
$filesize - 1,
695+
) as $length) {
696+
697+
$source = Test::TEST_URL;
698+
$destination = Helper\get_tmp_file_path();
699+
700+
// Start with no file.
701+
if ($length === false) {
702+
$this->assertFalse(file_exists($destination));
703+
704+
// Start with $length bytes of file.
705+
} else {
706+
707+
// Simulate resuming partially downloaded temporary file.
708+
$partial_filename = $destination . '.pccdownload';
709+
710+
if ($length === 0) {
711+
$partial_content = '';
712+
} else {
713+
$file = fopen($filename, 'rb');
714+
$partial_content = fread($file, $length);
715+
fclose($file);
716+
}
717+
718+
// Partial content size should be $length bytes large for testing resume download behavior.
719+
if ($length <= $filesize) {
720+
$this->assertEquals($length, strlen($partial_content));
721+
722+
// Partial content should not be larger than the original file size.
723+
} else {
724+
$this->assertEquals($filesize, strlen($partial_content));
725+
}
726+
727+
file_put_contents($partial_filename, $partial_content);
728+
$this->assertEquals(strlen($partial_content), strlen(file_get_contents($partial_filename)));
729+
}
730+
731+
// Download (the remaining bytes of) the file.
732+
$curl = new Curl();
733+
$curl->setHeader('X-DEBUG-TEST', 'download_file_range');
734+
$curl->download($source . '?' . http_build_query(array(
735+
'file_path' => $uploaded_file_path,
736+
)), $destination);
737+
738+
clearstatcache();
739+
740+
$expected_bytes_downloaded = $filesize - min($length, $filesize);
741+
$bytes_downloaded = $curl->responseHeaders['content-length'];
742+
if ($length === false || $length === 0) {
743+
$expected_http_status_code = 200; // 200 OK
744+
$this->assertEquals($expected_bytes_downloaded, $bytes_downloaded);
745+
} elseif ($length >= $filesize) {
746+
$expected_http_status_code = 416; // 416 Requested Range Not Satisfiable
747+
} else {
748+
$expected_http_status_code = 206; // 206 Partial Content
749+
$this->assertEquals($expected_bytes_downloaded, $bytes_downloaded);
750+
}
751+
$this->assertEquals($expected_http_status_code, $curl->httpStatusCode);
752+
$this->assertEquals($filesize, filesize($destination));
753+
754+
unlink($destination);
755+
$this->assertFalse(file_exists($destination));
756+
}
757+
758+
// Remove server file.
759+
Helper\remove_file_from_server($uploaded_file_path);
760+
761+
unlink($filename);
762+
$this->assertFalse(file_exists($filename));
689763
}
690764

691765
public function testMaxFilesize()

tests/PHPCurlClass/RangeHeader.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace RangeHeader;
4+
5+
class RangeHeader
6+
{
7+
private $first_byte;
8+
private $last_byte;
9+
10+
public function __construct($http_range_header)
11+
{
12+
// Simulate basic support for the Content-Range header.
13+
preg_match('/bytes=(\d+)?-(\d+)?/', $http_range_header, $matches);
14+
$this->first_byte = isset($matches['1']) ? (int)$matches['1'] : null;
15+
$this->last_byte = isset($matches['2']) ? (int)$matches['2'] : null;
16+
}
17+
18+
public function getFirstBytePosition($file_size)
19+
{
20+
$size = (int)$file_size;
21+
22+
if ($this->first_byte === null) {
23+
return $size - 1 - $this->last_byte;
24+
}
25+
26+
return $this->first_byte;
27+
}
28+
29+
public function getLastBytePosition($file_size)
30+
{
31+
$size = (int)$file_size;
32+
33+
if ($this->last_byte === null) {
34+
return $size - 1;
35+
}
36+
37+
return $this->last_byte;
38+
}
39+
40+
public function getLength($file_size)
41+
{
42+
$size = (int)$file_size;
43+
44+
return $this->getLastBytePosition($size) - $this->getFirstBytePosition($size) + 1;
45+
}
46+
47+
public function getContentRangeHeader($file_size)
48+
{
49+
return
50+
'bytes ' . $this->getFirstBytePosition($file_size) . '-' . $this->getLastBytePosition($file_size) . '/' .
51+
$file_size;
52+
}
53+
}

tests/PHPCurlClass/server.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
<?php
2+
3+
require_once 'ContentRangeServer.php';
4+
require_once 'RangeHeader.php';
25
require_once 'Helper.php';
36

47
use \Helper\Test;
@@ -257,6 +260,11 @@
257260
header('ETag: ' . md5($str));
258261
echo $str;
259262
exit;
263+
} elseif ($test === 'download_file_range') {
264+
$unsafe_file_path = $_GET['file_path'];
265+
$server = new ContentRangeServer\ContentRangeServer();
266+
$server->serve($unsafe_file_path);
267+
exit;
260268
} elseif ($test === 'timeout') {
261269
$unsafe_seconds = $_GET['seconds'];
262270
$start = time();

tests/run.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
set -x
22
php -S 127.0.0.1:8000 -t PHPCurlClass/ &> /dev/null &
33
pid="${!}"
4+
extra_args="${@}"
45
phpunit \
56
--configuration phpunit.xml \
67
--debug \
7-
--verbose
8+
--verbose \
9+
${extra_args}
810
kill "${pid}"

0 commit comments

Comments
 (0)