From 5cb05e956a3975a53d3bdf276c4e99dd7d43fec7 Mon Sep 17 00:00:00 2001 From: Zach Borboa Date: Thu, 10 Nov 2016 20:34:18 -0800 Subject: [PATCH] Fix #405: Support resume for Curl::download() --- src/Curl/Curl.php | 22 +++++- tests/PHPCurlClass/ContentRangeServer.php | 42 +++++++++++ tests/PHPCurlClass/Helper.php | 10 +++ tests/PHPCurlClass/PHPCurlClassTest.php | 91 +++++++++++++++++++++++ tests/PHPCurlClass/RangeHeader.php | 53 +++++++++++++ tests/PHPCurlClass/server.php | 7 ++ 6 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 tests/PHPCurlClass/ContentRangeServer.php create mode 100644 tests/PHPCurlClass/RangeHeader.php diff --git a/src/Curl/Curl.php b/src/Curl/Curl.php index 47c4914..9f8b45c 100644 --- a/src/Curl/Curl.php +++ b/src/Curl/Curl.php @@ -305,7 +305,27 @@ class Curl $fh = tmpfile(); } else { $filename = $mixed_filename; - $fh = fopen($filename, 'wb'); + + // Use a temporary file when downloading. Not using a temporary file can cause an error when an existing + // file has already fully completed downloading and a new download is started with the same destination save + // path. The download request will include header "Range: bytes=$filesize-" which is syntactically valid, + // but unsatisfiable. + $download_filename = $filename . '.pccdownload'; + + $mode = 'wb'; + // Attempt to resume download only when a temporary download file exists and is not empty. + if (file_exists($download_filename) && $filesize = filesize($download_filename)) { + $mode = 'ab'; + $first_byte_position = $filesize; + $range = $first_byte_position . '-'; + $this->setOpt(CURLOPT_RANGE, $range); + } + $fh = fopen($download_filename, $mode); + + // Move the downloaded temporary file to the destination save path. + $this->downloadCompleteFunction = function ($fh) use ($download_filename, $filename) { + rename($download_filename, $filename); + }; } $this->setOpt(CURLOPT_FILE, $fh); diff --git a/tests/PHPCurlClass/ContentRangeServer.php b/tests/PHPCurlClass/ContentRangeServer.php new file mode 100644 index 0000000..796d2c1 --- /dev/null +++ b/tests/PHPCurlClass/ContentRangeServer.php @@ -0,0 +1,42 @@ +getLength($filesize)); + header('Content-Range: ' . $range->getContentRangeHeader($filesize)); + + $start = $range->getFirstBytePosition($filesize); + if ($start > 0) { + fseek($fp, $start, SEEK_SET); + } + + $length = $range->getLength($filesize); + $chunk_size = 4096; + while ($length) { + $read = $length > $chunk_size ? $chunk_size : $length; + $length -= $read; + echo fread($fp, $read); + } + } + + fclose($fp); + } +} diff --git a/tests/PHPCurlClass/Helper.php b/tests/PHPCurlClass/Helper.php index 33c5ea8..caa7739 100644 --- a/tests/PHPCurlClass/Helper.php +++ b/tests/PHPCurlClass/Helper.php @@ -1,4 +1,5 @@ assertFalse(file_exists($upload_file_path)); } + public function testDownloadRange() + { + // Create and upload a file. + $filename = Helper\get_png(); + $uploaded_file_path = Helper\upload_file_to_server($filename); + + $filesize = filesize($filename); + + foreach (array( + false, + 0, + 1, + 2, + 3, + 5, + 10, + 25, + 50, + $filesize - 3, + $filesize - 2, + $filesize - 1, + ) as $length) { + + $source = Test::TEST_URL; + $destination = Helper\get_tmp_file_path(); + + // Start with no file. + if ($length === false) { + $this->assertFalse(file_exists($destination)); + + // Start with $length bytes of file. + } else { + + // Simulate resuming partially downloaded temporary file. + $partial_filename = $destination . '.pccdownload'; + + if ($length === 0) { + $partial_content = ''; + } else { + $file = fopen($filename, 'rb'); + $partial_content = fread($file, $length); + fclose($file); + } + + // Partial content size should be $length bytes large for testing resume download behavior. + if ($length <= $filesize) { + $this->assertEquals($length, strlen($partial_content)); + + // Partial content should not be larger than the original file size. + } else { + $this->assertEquals($filesize, strlen($partial_content)); + } + + file_put_contents($partial_filename, $partial_content); + $this->assertEquals(strlen($partial_content), strlen(file_get_contents($partial_filename))); + } + + // Download (the remaining bytes of) the file. + $curl = new Curl(); + $curl->setHeader('X-DEBUG-TEST', 'download_file_range'); + $curl->download($source . '?' . http_build_query(array( + 'file_path' => $uploaded_file_path, + )), $destination); + + clearstatcache(); + + $expected_bytes_downloaded = $filesize - min($length, $filesize); + $bytes_downloaded = $curl->responseHeaders['content-length']; + if ($length === false || $length === 0) { + $expected_http_status_code = 200; // 200 OK + $this->assertEquals($expected_bytes_downloaded, $bytes_downloaded); + } elseif ($length >= $filesize) { + $expected_http_status_code = 416; // 416 Requested Range Not Satisfiable + } else { + $expected_http_status_code = 206; // 206 Partial Content + $this->assertEquals($expected_bytes_downloaded, $bytes_downloaded); + } + $this->assertEquals($expected_http_status_code, $curl->httpStatusCode); + $this->assertEquals($filesize, filesize($destination)); + + unlink($destination); + $this->assertFalse(file_exists($destination)); + } + + // Remove server file. + Helper\remove_file_from_server($uploaded_file_path); + + unlink($filename); + $this->assertFalse(file_exists($filename)); + } + public function testMaxFilesize() { $tests = array( diff --git a/tests/PHPCurlClass/RangeHeader.php b/tests/PHPCurlClass/RangeHeader.php new file mode 100644 index 0000000..ff0c1cf --- /dev/null +++ b/tests/PHPCurlClass/RangeHeader.php @@ -0,0 +1,53 @@ +first_byte = isset($matches['1']) ? (int)$matches['1'] : null; + $this->last_byte = isset($matches['2']) ? (int)$matches['2'] : null; + } + + public function getFirstBytePosition($file_size) + { + $size = (int)$file_size; + + if ($this->first_byte === null) { + return $size - 1 - $this->last_byte; + } + + return $this->first_byte; + } + + public function getLastBytePosition($file_size) + { + $size = (int)$file_size; + + if ($this->last_byte === null) { + return $size - 1; + } + + return $this->last_byte; + } + + public function getLength($file_size) + { + $size = (int)$file_size; + + return $this->getLastBytePosition($size) - $this->getFirstBytePosition($size) + 1; + } + + public function getContentRangeHeader($file_size) + { + return + 'bytes ' . $this->getFirstBytePosition($file_size) . '-' . $this->getLastBytePosition($file_size) . '/' . + $file_size; + } +} diff --git a/tests/PHPCurlClass/server.php b/tests/PHPCurlClass/server.php index 43d63d2..31d4aac 100644 --- a/tests/PHPCurlClass/server.php +++ b/tests/PHPCurlClass/server.php @@ -1,4 +1,7 @@ serve($unsafe_file_path); + exit; } elseif ($test === 'timeout') { $unsafe_seconds = $_GET['seconds']; $start = time();