diff --git a/phpBB/download/file.php b/phpBB/download/file.php index 891352f00c..68a4afe03c 100644 --- a/phpBB/download/file.php +++ b/phpBB/download/file.php @@ -246,7 +246,7 @@ if (!download_allowed()) $download_mode = (int) $extensions[$attachment['extension']]['download_mode']; // Fetching filename here to prevent sniffing of filename -$sql = 'SELECT attach_id, is_orphan, in_message, post_msg_id, extension, physical_filename, real_filename, mimetype, filetime +$sql = 'SELECT attach_id, is_orphan, in_message, post_msg_id, extension, physical_filename, real_filename, mimetype, filesize, filetime FROM ' . ATTACHMENTS_TABLE . " WHERE attach_id = $download_id"; $result = $db->sql_query_limit($sql, 1); @@ -275,7 +275,7 @@ if ($thumbnail) { $attachment['physical_filename'] = 'thumb_' . $attachment['physical_filename']; } -else if (($display_cat == ATTACHMENT_CATEGORY_NONE/* || $display_cat == ATTACHMENT_CATEGORY_IMAGE*/) && !$attachment['is_orphan']) +else if (($display_cat == ATTACHMENT_CATEGORY_NONE/* || $display_cat == ATTACHMENT_CATEGORY_IMAGE*/) && !$attachment['is_orphan'] && !phpbb_http_byte_range($attachment['filesize'])) { // Update download count $sql = 'UPDATE ' . ATTACHMENTS_TABLE . ' diff --git a/phpBB/includes/functions_download.php b/phpBB/includes/functions_download.php index 87bf7a91a6..94d851e383 100644 --- a/phpBB/includes/functions_download.php +++ b/phpBB/includes/functions_download.php @@ -157,6 +157,16 @@ function send_file_to_browser($attachment, $upload_dir, $category) trigger_error('UNABLE_TO_DELIVER_FILE'); } + // Make sure the database record for the filesize is correct + if ($size > 0 && $size != $attachment['filesize']) + { + // Update database record + $sql = 'UPDATE ' . ATTACHMENTS_TABLE . ' + SET filesize = ' . (int) $size . ' + WHERE attach_id = ' . (int) $attachment['attach_id']; + $db->sql_query($sql); + } + // Now the tricky part... let's dance header('Pragma: public'); @@ -226,6 +236,16 @@ function send_file_to_browser($attachment, $upload_dir, $category) if ($fp !== false) { + // Deliver file partially if requested + if ($range = phpbb_http_byte_range($size)) + { + fseek($fp, $range['byte_pos_start']); + + send_status_line(206, 'Partial Content'); + header('Content-Range: bytes ' . $range['byte_pos_start'] . '-' . $range['byte_pos_end'] . '/' . $range['bytes_total']); + header('Content-Length: ' . $range['bytes_requested']); + } + while (!feof($fp)) { echo fread($fp, 8192); @@ -407,3 +427,151 @@ function file_gc() $db->sql_close(); exit; } + +/** +* HTTP range support (RFC 2616 Section 14.35) +* +* Allows browsers to request partial file content +* in case a download has been interrupted. +* +* @param int $filesize the size of the file in bytes we are about to deliver +* +* @return mixed false if the whole file has to be delivered +* associative array on success +*/ +function phpbb_http_byte_range($filesize) +{ + // Only call find_range_request() once. + static $request_array; + + if (!$filesize) + { + return false; + } + + if (!isset($request_array)) + { + $request_array = phpbb_find_range_request(); + } + + return (empty($request_array)) ? false : phpbb_parse_range_request($request_array, $filesize); +} + +/** +* Searches for HTTP range request in super globals. +* +* @return mixed false if no request found +* array of strings containing the requested ranges otherwise +* e.g. array(0 => '0-0', 1 => '123-125') +*/ +function phpbb_find_range_request() +{ + $globals = array( + array('_SERVER', 'HTTP_RANGE'), + array('_ENV', 'HTTP_RANGE'), + ); + + foreach ($globals as $array) + { + $global = $array[0]; + $key = $array[1]; + + // Make sure range request starts with "bytes=" + if (isset($GLOBALS[$global][$key]) && strpos($GLOBALS[$global][$key], 'bytes=') === 0) + { + // Strip leading 'bytes=' + // Multiple ranges can be separated by a comma + return explode(',', substr($GLOBALS[$global][$key], 6)); + } + } + + return false; +} + +/** +* Analyses a range request array. +* +* A range request can contain multiple ranges, +* we however only handle the first request and +* only support requests from a given byte to the end of the file. +* +* @param array $request_array array of strings containing the requested ranges +* @param int $filesize the full size of the file in bytes that has been requested +* +* @return mixed false if the whole file has to be delivered +* associative array on success +* byte_pos_start the first byte position, can be passed to fseek() +* byte_pos_end the last byte position +* bytes_requested the number of bytes requested +* bytes_total the full size of the file +*/ +function phpbb_parse_range_request($request_array, $filesize) +{ + // Go through all ranges + foreach ($request_array as $range_string) + { + $range = explode('-', trim($range_string)); + + // "-" is invalid, "0-0" however is valid and means the very first byte. + if (sizeof($range) != 2 || $range[0] === '' && $range[1] === '') + { + continue; + } + + if ($range[0] === '') + { + // Return last $range[1] bytes. + + if (!$range[1]) + { + continue; + } + + if ($range[1] >= $filesize) + { + return false; + } + + $first_byte_pos = $filesize - (int) $range[1]; + $last_byte_pos = $filesize - 1; + } + else + { + // Return bytes from $range[0] to $range[1] + + $first_byte_pos = (int) $range[0]; + $last_byte_pos = (int) $range[1]; + + if ($last_byte_pos && $last_byte_pos < $first_byte_pos) + { + // The requested range contains 0 bytes. + continue; + } + + if ($first_byte_pos >= $filesize) + { + // Requested range not satisfiable + return false; + } + + // Adjust last-byte-pos if it is absent or greater than the content. + if ($range[1] === '' || $last_byte_pos >= $filesize) + { + $last_byte_pos = $filesize - 1; + } + } + + // We currently do not support range requests that end before the end of the file + if ($last_byte_pos != $filesize - 1) + { + continue; + } + + return array( + 'byte_pos_start' => $first_byte_pos, + 'byte_pos_end' => $last_byte_pos, + 'bytes_requested' => $last_byte_pos - $first_byte_pos + 1, + 'bytes_total' => $filesize, + ); + } +} diff --git a/tests/all_tests.php b/tests/all_tests.php index feaaa1f0a1..4c2fa891a2 100644 --- a/tests/all_tests.php +++ b/tests/all_tests.php @@ -26,6 +26,7 @@ require_once 'dbal/all_tests.php'; require_once 'regex/all_tests.php'; require_once 'network/all_tests.php'; require_once 'random/all_tests.php'; +require_once 'download/all_tests.php'; // exclude the test directory from code coverage reports if (version_compare(PHPUnit_Runner_Version::id(), '3.5.0') >= 0) @@ -59,6 +60,7 @@ class phpbb_all_tests $suite->addTest(phpbb_regex_all_tests::suite()); $suite->addTest(phpbb_network_all_tests::suite()); $suite->addTest(phpbb_random_all_tests::suite()); + $suite->addTest(phpbb_download_all_tests::suite()); return $suite; } diff --git a/tests/download/all_tests.php b/tests/download/all_tests.php new file mode 100644 index 0000000000..21305a887c --- /dev/null +++ b/tests/download/all_tests.php @@ -0,0 +1,40 @@ +addTestSuite('phpbb_download_http_byte_range_test'); + + return $suite; + } +} + +if (PHPUnit_MAIN_METHOD == 'phpbb_download_all_tests::main') +{ + phpbb_regex_all_tests::main(); +} diff --git a/tests/download/http_byte_range.php b/tests/download/http_byte_range.php new file mode 100644 index 0000000000..cc42dee353 --- /dev/null +++ b/tests/download/http_byte_range.php @@ -0,0 +1,62 @@ +assertEquals(false, phpbb_find_range_request()); + unset($_SERVER['HTTP_RANGE']); + + $_ENV['HTTP_RANGE'] = 'bztes='; + $this->assertEquals(false, phpbb_find_range_request()); + unset($_ENV['HTTP_RANGE']); + + $_SERVER['HTTP_RANGE'] = 'bytes=0-0,123-125'; + $this->assertEquals(array('0-0', '123-125'), phpbb_find_range_request()); + unset($_SERVER['HTTP_RANGE']); + } + + /** + * @dataProvider parse_range_request_data() + */ + public function test_parse_range_request($request_array, $filesize, $expected) + { + $this->assertEquals($expected, phpbb_parse_range_request($request_array, $filesize)); + } + + public function parse_range_request_data() + { + return array( + // Does not read until the end of file. + array( + array('3-4'), + 10, + false, + ), + + // Valid request, handle second range. + array( + array('0-0', '120-125'), + 125, + array( + 'byte_pos_start' => 120, + 'byte_pos_end' => 124, + 'bytes_requested' => 5, + 'bytes_total' => 125, + ) + ), + ); + } +}