diff --git a/Tests/outputdlTest.php b/Tests/outputdlTest.php new file mode 100644 index 0000000..e3693fe --- /dev/null +++ b/Tests/outputdlTest.php @@ -0,0 +1,200 @@ + + */ + +/** Test the outputdl.php file */ +class test_outputdl extends PHPUnit_Framework_TestCase +{ + public function test_outputdl_init () + { + exec ("rm -f /tmp/testDFWoutputDL*"); + file_put_contents ("/tmp/testDFWoutputDL", + str_repeat ( + "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n", + 1000) + ); + symlink ("/etc/passwd", "/tmp/testDFWoutputDL2"); + symlink ("/tmp/testDFWoutputDL", "/tmp/testDFWoutputDL3"); + } + + public function test_outputdl_1 () + { + // Check the full download content + $outputdl = new outputdl (); + $this->expectOutputString (str_repeat ( + "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n", + 1000)); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + } + + public function test_outputdl_Announce_1 () + { + // Check the announce of Resume mode Enabled + $outputdl = new outputdl (); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + $res = $outputdl->headers (); + $this->assertSame (in_array ("Accept-Ranges: bytes", $res), true); + } + + public function test_outputdl_Announce_2 () + { + // Check the announce of Resume mode Disabled + $outputdl = new outputdl (); + $outputdl->resumeAllow (false); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + $res = $outputdl->headers (); + $this->assertSame (in_array ("Accept-Ranges: none", $res), true); + } + + public function test_outputdl_Partial_1 () + { + // Check the content get with provided range + $outputdl = new outputdl (); + $_SERVER["HTTP_RANGE"] = "bytes=3-9"; + $this->expectOutputString ("4567890"); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + unset ($_SERVER["HTTP_RANGE"]); + } + + public function test_outputdl_Partial_2 () + { + // Check the content get with provided range + $outputdl = new outputdl (); + $_SERVER["HTTP_RANGE"] = "bytes=3-"; + $this->expectOutputString (substr (str_repeat ( + "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n", + 1000), 3)); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + unset ($_SERVER["HTTP_RANGE"]); + } + + public function test_outputdl_Partial_3 () + { + // Check the content get with provided range + $outputdl = new outputdl (); + $_SERVER["HTTP_RANGE"] = "bytes=0-"; + $this->expectOutputString (str_repeat ( + "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n", + 1000)); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + unset ($_SERVER["HTTP_RANGE"]); + } + + public function test_outputdl_Partial_4 () + { + // Check the content get with provided range + $outputdl = new outputdl (); + $_SERVER["HTTP_RANGE"] = "bytes=-5"; + $this->expectOutputString ("wxyz\n"); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + unset ($_SERVER["HTTP_RANGE"]); + } + + public function test_outputdl_Partial_5 () + { + // Check the content get with provided range + $outputdl = new outputdl (); + $_SERVER["HTTP_RANGE"] = "bytes=0-3,6-9"; + $this->expectOutputString ("--Qm91bmRhcnk=\r +Content-Range: bytes 0-3/63000\r +Content-Type: application/octet-stream\r +\r +1234\r +--Qm91bmRhcnk=\r +Content-Range: bytes 6-9/63000\r +Content-Type: application/octet-stream\r +\r +7890\r +--Qm91bmRhcnk=--\r +"); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + unset ($_SERVER["HTTP_RANGE"]); + } + + public function test_outputdl_PartialWrong_1 () + { + // Check the invalid provided range + unset ($_SERVER["HTTP_RANGE"]); + $outputdl = new outputdl (); + $_SERVER["HTTP_RANGE"] = "bytes=99999-"; + $this->expectException ("Exception", "Invalid range provided", 416); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + } + + public function test_outputdl_PartialWrong_2 () + { + // Check the invalid provided range + unset ($_SERVER["HTTP_RANGE"]); + $outputdl = new outputdl (); + $_SERVER["HTTP_RANGE"] = "bytes=9-3"; + $this->expectException ("Exception", "Invalid range provided", 416); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + } + + public function test_outputdl_PartialWrong_3 () + { + // Check the invalid provided range + unset ($_SERVER["HTTP_RANGE"]); + $outputdl = new outputdl (); + $_SERVER["HTTP_RANGE"] = "bytes=9-999999"; + $this->expectException ("Exception", "Invalid range provided", 416); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + } + + public function test_outputdl_PartialWrong_4 () + { + // Check the invalid provided range + unset ($_SERVER["HTTP_RANGE"]); + $outputdl = new outputdl (); + $_SERVER["HTTP_RANGE"] = "bytes=-"; + $this->expectException ("Exception", "Invalid range provided", 416); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + } + + public function test_outputdl_invalidBase_1 () + { + // Check the base comparison : OK + unset ($_SERVER["HTTP_RANGE"]); + $outputdl = new outputdl (); + $outputdl->base ("/tmp"); + $this->expectOutputString (str_repeat ( + "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n", + 1000)); + $outputdl->downloadFile ("/tmp/testDFWoutputDL"); + } + + public function test_outputdl_invalidBase_2 () + { + // Check the base comparison : BAD + unset ($_SERVER["HTTP_RANGE"]); + $outputdl = new outputdl (); + $outputdl->base ("/tmp"); + $this->expectException ("Exception", + "Invalid file to download : out of base", 406); + $outputdl->downloadFile ("../../../../../..//etc/passwd"); + } + + public function test_outputdl_invalidFile_1 () + { + // Check the base comparison : BAD Symlink + unset ($_SERVER["HTTP_RANGE"]); + $outputdl = new outputdl (); + $outputdl->base ("/tmp"); + $this->expectException ("Exception", + "Invalid file to download : not a file", 406); + $outputdl->downloadFile ("/tmp/testDFWoutputDL3"); + } + + public function test_outputdl_invalidFile_2 () + { + // Check the base comparison : Non existing + unset ($_SERVER["HTTP_RANGE"]); + $outputdl = new outputdl (); + $outputdl->base ("/tmp"); + $this->expectException ("Exception", + "Invalid file to download : file doesn't exists", 404); + $outputdl->downloadFile ("/tmp/testDFWoutputNON"); + } +} diff --git a/outputdl.php b/outputdl.php new file mode 100644 index 0000000..a445981 --- /dev/null +++ b/outputdl.php @@ -0,0 +1,231 @@ + + */ + +/** This class allow a program to download a specific file from the filesystem, + * without using too much memory. In also allow to manage the resuming of a + * paused transfert by getting the range of download. + */ +class outputdl +{ + /** Allow or deny the resuming of the HTTP transferts + */ + private $resumeAllow = true; + + /** The base dir used as root + */ + private $base = "/"; + + /** Store the headers to allow the unit tests to get them + */ + private $headers = array (); + + /** Headers sent + */ + private $headersSent = false; + + /** Get/Set Allow/Deny the resuming of transferts + * @param boolean|null $resumeAllow True : resume OK, false : resume forbid + * @return $this|boolean + */ + public function resumeAllow ($resumeAllow = null) + // {{{ + { + if ($resumeAllow === null) + return $this->resumeAllow; + $this->resumeAllow = !! $resumeAllow; + return $this; + } + // }}} + + /** Get/Set Base of filesystem + * The base directory is use to secure the download. A user can not request + * a file outside the base. Example : if the base is /var/lib/files, the + * user can not request /etc/passwd file (out of scope). If not defined, + * all the filesystem is allowed + * @param string|null $base The filesystem base + * @return $this|string + */ + public function base ($base = null) + // {{{ + { + if ($base === null) + return $this->base; + $this->base = $base; + return $this; + } + // }}} + + /** Get headers from headers list + * The headers can be tested too + * @return array + */ + public function headers () + // {{{ + { + return $this->headers; + } + // }}} + + /** Add a new header and send it if possible + * @param string $header The header to send + * @return $this + */ + private function header ($header) + // {{{ + { + if (! headers_sent()) + header ($header); + $this->headers[] = $header; + return $this; + } + // }}} + + /** Download a file with management of Partial Download (like resume) + * Manage the HTTP headers to allow to resume the download if it is allowed + * Do not go throw the renderer, exit at end of transfert + * @param string $path The path to download + * @param string|null $filename The filename to send to the browser + */ + public function downloadFile ($path, $filename = null) + // {{{ + { + if (!file_exists ($path)) + throw new \Exception (dgettext ("domframework", + "Invalid file to download : file doesn't exists"), 404); + if (is_link ($path) || is_dir ($path)) + throw new \Exception (dgettext ("domframework", + "Invalid file to download : not a file"), 406); + $path = realpath ($path); + if (substr ($path, 0, strlen ($this->base)) !== $this->base) + throw new \Exception (dgettext ("domframework", + "Invalid file to download : out of base"), 406); + if (! is_file ($path)) + throw new \Exception (dgettext ("domframework", + "Invalid file to download : not a file"), 406); + if (! is_readable ($path)) + throw new \Exception (dgettext ("domframework", + "Invalid file to download : file not readable"), 406); + ini_set ('display_errors', 0); + ini_set ('display_startup_errors', 0); + if (! defined ("PHPUNIT") && ob_get_level ()) + ob_end_clean (); + + if ($filename === null) + $filename = basename ($path); + $this->header ('Content-Description: File Transfer'); + $this->header ('Content-Type: application/octet-stream'); + $this->header ('Content-Disposition: attachment; filename="'.$filename.'"'); + $this->header ('Content-Transfer-Encoding: binary'); + //header ('Expires: 0'); + //header ('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + //header ('Pragma: public'); + $filesize = filesize ($path); + if (! $this->resumeAllow) + { + $this->header ('Accept-Ranges: none'); + } + else + { + $this->header ('Accept-Ranges: bytes'); + if (isset ($_SERVER["HTTP_RANGE"]) && + strtolower (substr ($_SERVER["HTTP_RANGE"], 0, 6)) === "bytes=") + { + $boundary = "Qm91bmRhcnk="; + $rangeTxt = trim (substr ($_SERVER["HTTP_RANGE"], 6)); + $ranges = explode (",", $rangeTxt); + if (count ($ranges) > 1) + $this->header ( + "Content-Type: multipart/byteranges; boundary=$boundary"); + foreach ($ranges as $nb => $range) + { + if (trim ($range) === "-") + throw new \Exception ("Invalid range provided", 416); + @list ($start, $stop) = explode ("-", trim ($range)); + if ($stop === null || $stop === "") + $stop = $filesize; + if ($stop && ($start === null || $start === "")) + { + $start = $filesize - $stop; + $stop = $filesize; + } + $start = intval ($start); + $stop = intval ($stop); + if ($start > $stop) + throw new \Exception ("Invalid range provided", 416); + if ($start < 0 || $stop > $filesize || $start > $filesize) + throw new \Exception ("Invalid range provided", 416); + if (count ($ranges) > 1) + { + if ($nb > 0) + echo "\r\n"; + echo "--".$boundary."\r\n"; + echo "Content-Range: bytes $start-$stop/$filesize\r\n"; + echo "Content-Type: application/octet-stream\r\n"; + echo "\r\n"; + } + else + { + $this->header ("Content-Range: bytes $start-$stop/$filesize"); + } + http_response_code (206); + $this->downloadFileRange ($path, $start, $stop); + + } + if (count ($ranges) > 1) + { + if ($nb > 0) + echo "\r\n"; + echo "--".$boundary."--\r\n"; + } + if (! defined ("PHPUNIT")) + exit; + return; + } + } + // No range or error : send all the file + $this->header ('Content-Length: '. $filesize); + $this->downloadFileRange ($path, 0, $filesize); + if (! defined ("PHPUNIT")) + exit; + return; + } + // }}} + + /** Download the file. Do not go through the renderer + * @param string $path The path to download + * @param integer $start The start range + * @param integer $stop The stop range + */ + private function downloadFileRange ($path, $start, $stop) + // {{{ + { + $file = realpath ($path); + $chunksize = 10*1024*1024; // how many bytes per chunk + $start = intval ($start); + $stop = intval ($stop); + $handle = fopen ($file, 'rb'); + if ($handle === false) + { + die ("error"); + } + if (fseek ($handle, $start) === false) + die ("Can not seek"); + $size = $start; + while (! feof ($handle)) + { + $block = fread ($handle, $chunksize); + if ($size + $chunksize > $stop) + $block = substr ($block, 0, $stop - $size + 1); + $size += strlen ($block); + echo $block; + flush (); + if ($size >= $stop) + break; + } + fclose($handle); + } + // }}} +}