From d42a05475a1fec48de72fdee961de66c1af2279a Mon Sep 17 00:00:00 2001 From: Aaron Schulz Date: Sun, 20 Apr 2014 01:40:06 -0700 Subject: [PATCH] Added Range support to FileBackend::streamFile() * Added HTTP options headers parameter to streamFile(). * Refactored doStreamFile() to either call StreamFile::stream() or delagate that to the subclass. SwiftFileBackend now relays the full Swift response rather than manually making the headers. This also makes Range headers easy to support. * Made use of this in img_auth.php for performance on private wikis. * Elimate stat call in streamFile() for Swift if "headers" is empty. * Refactored StreamFile a bit to inject request headers instead of using the $_SERVER global. A header options parameter is used instead, which also supports Range. * Removed now unused prepareForStream(). * Cleaned up streamFile() unit tests. Change-Id: I2ccbcbca6caabb8cf65bd6b3084cede2e6ea628a --- img_auth.php | 10 +- includes/StreamFile.php | 208 ++++++++++++------ includes/filebackend/FileBackend.php | 12 +- includes/filebackend/FileBackendStore.php | 48 ++-- includes/filebackend/MemoryFileBackend.php | 15 -- includes/filebackend/SwiftFileBackend.php | 34 ++- includes/filerepo/FileRepo.php | 5 +- includes/libs/MultiHttpClient.php | 6 + .../includes/filebackend/FileBackendTest.php | 66 +++++- 9 files changed, 283 insertions(+), 121 deletions(-) diff --git a/img_auth.php b/img_auth.php index d63618817a..fa1609f963 100644 --- a/img_auth.php +++ b/img_auth.php @@ -162,13 +162,21 @@ function wfImageAuthMain() { } } + $options = []; // HTTP header options + if ( isset( $_SERVER['HTTP_RANGE'] ) ) { + $options['range'] = $_SERVER['HTTP_RANGE']; + } + if ( isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) { + $options['if-modified-since'] = $_SERVER['HTTP_IF_MODIFIED_SINCE']; + } + if ( $request->getCheck( 'download' ) ) { $headers[] = 'Content-Disposition: attachment'; } // Stream the requested file wfDebugLog( 'img_auth', "Streaming `" . $filename . "`." ); - $repo->streamFile( $filename, $headers ); + $repo->streamFile( $filename, $headers, $options ); } /** diff --git a/includes/StreamFile.php b/includes/StreamFile.php index 8d0b8f1727..0fc79802f3 100644 --- a/includes/StreamFile.php +++ b/includes/StreamFile.php @@ -24,8 +24,10 @@ * Functions related to the output of file content */ class StreamFile { - const READY_STREAM = 1; - const NOT_MODIFIED = 2; + // Do not send any HTTP headers unless requested by caller (e.g. body only) + const STREAM_HEADLESS = 1; + // Do not try to tear down any PHP output buffers + const STREAM_ALLOW_OB = 2; /** * Stream a file to the browser, adding all the headings and fun stuff. @@ -33,107 +35,183 @@ class StreamFile { * and Content-Disposition. * * @param string $fname Full name and path of the file to stream - * @param array $headers Any additional headers to send + * @param array $headers Any additional headers to send if the file exists * @param bool $sendErrors Send error messages if errors occur (like 404) + * @param array $optHeaders HTTP request header map (e.g. "range") (use lowercase keys) + * @param integer $flags Bitfield of STREAM_* constants * @throws MWException * @return bool Success */ - public static function stream( $fname, $headers = [], $sendErrors = true ) { + public static function stream( + $fname, $headers = [], $sendErrors = true, $optHeaders = [], $flags = 0 + ) { + $section = new ProfileSection( __METHOD__ ); if ( FileBackend::isStoragePath( $fname ) ) { // sanity throw new MWException( __FUNCTION__ . " given storage path '$fname'." ); } - MediaWiki\suppressWarnings(); - $stat = stat( $fname ); - MediaWiki\restoreWarnings(); - - $res = self::prepareForStream( $fname, $stat, $headers, $sendErrors ); - if ( $res == self::NOT_MODIFIED ) { - $ok = true; // use client cache - } elseif ( $res == self::READY_STREAM ) { - $ok = readfile( $fname ); - } else { - $ok = false; // failed + // Don't stream it out as text/html if there was a PHP error + if ( ( ( $flags & self::STREAM_HEADLESS ) == 0 || $headers ) && headers_sent() ) { + echo "Headers already sent, terminating.\n"; + return false; } - return $ok; - } + $headerFunc = ( $flags & self::STREAM_HEADLESS ) + ? function ( $header ) { + // no-op + } + : function ( $header ) { + is_int( $header ) ? HttpStatus::header( $header ) : header( $header ); + }; + + MediaWiki\suppressWarnings(); + $info = stat( $fname ); + MediaWiki\restoreWarnings(); - /** - * Call this function used in preparation before streaming a file. - * This function does the following: - * (a) sends Last-Modified, Content-type, and Content-Disposition headers - * (b) cancels any PHP output buffering and automatic gzipping of output - * (c) sends Content-Length header based on HTTP_IF_MODIFIED_SINCE check - * - * @param string $path Storage path or file system path - * @param array|bool $info File stat info with 'mtime' and 'size' fields - * @param array $headers Additional headers to send - * @param bool $sendErrors Send error messages if errors occur (like 404) - * @return int|bool READY_STREAM, NOT_MODIFIED, or false on failure - */ - public static function prepareForStream( - $path, $info, $headers = [], $sendErrors = true - ) { if ( !is_array( $info ) ) { if ( $sendErrors ) { - HttpStatus::header( 404 ); - header( 'Cache-Control: no-cache' ); - header( 'Content-Type: text/html; charset=utf-8' ); - $encFile = htmlspecialchars( $path ); - $encScript = htmlspecialchars( $_SERVER['SCRIPT_NAME'] ); - echo " -

File not found

-

Although this PHP script ($encScript) exists, the file requested for output - ($encFile) does not.

- - "; + self::send404Message( $fname, $flags ); } return false; } - // Sent Last-Modified HTTP header for client-side caching - header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $info['mtime'] ) ); + // Send Last-Modified HTTP header for client-side caching + $headerFunc( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $info['mtime'] ) ); - // Cancel output buffering and gzipping if set - wfResetOutputBuffers(); + if ( ( $flags & self::STREAM_ALLOW_OB ) == 0 ) { + // Cancel output buffering and gzipping if set + wfResetOutputBuffers(); + } - $type = self::contentTypeFromPath( $path ); + $type = self::contentTypeFromPath( $fname ); if ( $type && $type != 'unknown/unknown' ) { - header( "Content-type: $type" ); + $headerFunc( "Content-type: $type" ); } else { // Send a content type which is not known to Internet Explorer, to // avoid triggering IE's content type detection. Sending a standard // unknown content type here essentially gives IE license to apply // whatever content type it likes. - header( 'Content-type: application/x-wiki' ); + $headerFunc( 'Content-type: application/x-wiki' ); } - // Don't stream it out as text/html if there was a PHP error - if ( headers_sent() ) { - echo "Headers already sent, terminating.\n"; - return false; + // Don't send if client has up to date cache + if ( isset( $optHeaders['if-modified-since'] ) ) { + $modsince = preg_replace( '/;.*$/', '', $optHeaders['if-modified-since'] ); + if ( wfTimestamp( TS_UNIX, $info['mtime'] ) <= strtotime( $modsince ) ) { + ini_set( 'zlib.output_compression', 0 ); + $headerFunc( 304 ); + return true; // ok + } } // Send additional headers foreach ( $headers as $header ) { - header( $header ); + header( $header ); // always use header(); specifically requested } - // Don't send if client has up to date cache - if ( !empty( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) { - $modsince = preg_replace( '/;.*$/', '', $_SERVER['HTTP_IF_MODIFIED_SINCE'] ); - if ( wfTimestamp( TS_UNIX, $info['mtime'] ) <= strtotime( $modsince ) ) { - ini_set( 'zlib.output_compression', 0 ); - HttpStatus::header( 304 ); - return self::NOT_MODIFIED; // ok + if ( isset( $optHeaders['range'] ) ) { + $range = self::parseRange( $optHeaders['range'], $info['size'] ); + if ( is_array( $range ) ) { + $headerFunc( 206 ); + $headerFunc( 'Content-Length: ' . $range[2] ); + $headerFunc( "Content-Range: bytes {$range[0]}-{$range[1]}/{$info['size']}" ); + } elseif ( $range === 'invalid' ) { + if ( $sendErrors ) { + $headerFunc( 416 ); + $headerFunc( 'Cache-Control: no-cache' ); + $headerFunc( 'Content-Type: text/html; charset=utf-8' ); + $headerFunc( 'Content-Range: bytes */' . $info['size'] ); + } + return false; + } else { // unsupported Range request (e.g. multiple ranges) + $range = null; + $headerFunc( 'Content-Length: ' . $info['size'] ); + } + } else { + $range = null; + $headerFunc( 'Content-Length: ' . $info['size'] ); + } + + if ( is_array( $range ) ) { + $handle = fopen( $fname, 'rb' ); + if ( $handle ) { + $ok = true; + fseek( $handle, $range[0] ); + $remaining = $range[2]; + while ( $remaining > 0 && $ok ) { + $bytes = min( $remaining, 8 * 1024 ); + $data = fread( $handle, $bytes ); + $remaining -= $bytes; + $ok = ( $data !== false ); + print $data; + } + } else { + return false; } + } else { + return readfile( $fname ) !== false; // faster } - header( 'Content-Length: ' . $info['size'] ); + return true; + } + + /** + * Send out a standard 404 message for a file + * + * @param string $fname Full name and path of the file to stream + * @param integer $flags Bitfield of STREAM_* constants + * @since 1.24 + */ + public static function send404Message( $fname, $flags = 0 ) { + if ( ( $flags & self::STREAM_HEADLESS ) == 0 ) { + HttpStatus::header( 404 ); + header( 'Cache-Control: no-cache' ); + header( 'Content-Type: text/html; charset=utf-8' ); + } + $encFile = htmlspecialchars( $fname ); + $encScript = htmlspecialchars( $_SERVER['SCRIPT_NAME'] ); + echo " +

File not found

+

Although this PHP script ($encScript) exists, the file requested for output + ($encFile) does not.

+ + "; + } - return self::READY_STREAM; // ok + /** + * Convert a Range header value to an absolute (start, end) range tuple + * + * @param string $range Range header value + * @param integer $size File size + * @return array|string Returns error string on failure (start, end, length) + * @since 1.24 + */ + public static function parseRange( $range, $size ) { + $m = []; + if ( preg_match( '#^bytes=(\d*)-(\d*)$#', $range, $m ) ) { + list( , $start, $end ) = $m; + if ( $start === '' && $end === '' ) { + $absRange = [ 0, $size - 1 ]; + } elseif ( $start === '' ) { + $absRange = [ $size - $end, $size - 1 ]; + } elseif ( $end === '' ) { + $absRange = [ $start, $size - 1 ]; + } else { + $absRange = [ $start, $end ]; + } + if ( $absRange[0] >= 0 && $absRange[1] >= $absRange[0] ) { + if ( $absRange[0] < $size ) { + $absRange[1] = min( $absRange[1], $size - 1 ); // stop at EOF + $absRange[2] = $absRange[1] - $absRange[0] + 1; + return $absRange; + } elseif ( $absRange[0] == 0 && $size == 0 ) { + return 'unrecognized'; // the whole file should just be sent + } + } + return 'invalid'; + } + return 'unrecognized'; } /** diff --git a/includes/filebackend/FileBackend.php b/includes/filebackend/FileBackend.php index 03974f755a..10183f490d 100644 --- a/includes/filebackend/FileBackend.php +++ b/includes/filebackend/FileBackend.php @@ -1005,15 +1005,21 @@ abstract class FileBackend { /** * Stream the file at a storage path in the backend. + * * If the file does not exists, an HTTP 404 error will be given. * Appropriate HTTP headers (Status, Content-Type, Content-Length) * will be sent if streaming began, while none will be sent otherwise. * Implementations should flush the output buffer before sending data. * * @param array $params Parameters include: - * - src : source storage path - * - headers : list of additional HTTP headers to send on success - * - latest : use the latest available data + * - src : source storage path + * - headers : list of additional HTTP headers to send if the file exists + * - options : HTTP request header map with lower case keys (since 1.28). Supports: + * range : format is "bytes=(\d*-\d*)" + * if-modified-since : format is an HTTP date + * - headless : only include the body (and headers from "headers") (since 1.28) + * - latest : use the latest available data + * - allowOB : preserve any output buffers (since 1.28) * @return Status */ abstract public function streamFile( array $params ); diff --git a/includes/filebackend/FileBackendStore.php b/includes/filebackend/FileBackendStore.php index 4d9587ef3c..a29119c9cf 100644 --- a/includes/filebackend/FileBackendStore.php +++ b/includes/filebackend/FileBackendStore.php @@ -844,30 +844,19 @@ abstract class FileBackendStore extends FileBackend { $ps = Profiler::instance()->scopedProfileIn( __METHOD__ . "-{$this->name}" ); $status = Status::newGood(); - $info = $this->getFileStat( $params ); - if ( !$info ) { // let StreamFile handle the 404 - $status->fatal( 'backend-fail-notexists', $params['src'] ); - } - - // Set output buffer and HTTP headers for stream - $extraHeaders = isset( $params['headers'] ) ? $params['headers'] : []; - $res = StreamFile::prepareForStream( $params['src'], $info, $extraHeaders ); - if ( $res == StreamFile::NOT_MODIFIED ) { - // do nothing; client cache is up to date - } elseif ( $res == StreamFile::READY_STREAM ) { - $status = $this->doStreamFile( $params ); - if ( !$status->isOK() ) { - // Per bug 41113, nasty things can happen if bad cache entries get - // stuck in cache. It's also possible that this error can come up - // with simple race conditions. Clear out the stat cache to be safe. - $this->clearCache( [ $params['src'] ] ); - $this->deleteFileCache( $params['src'] ); - trigger_error( "Bad stat cache or race condition for file {$params['src']}." ); - } - } else { + // Always set some fields for subclass convenience + $params['options'] = isset( $params['options'] ) ? $params['options'] : []; + $params['headers'] = isset( $params['headers'] ) ? $params['headers'] : []; + + // Don't stream it out as text/html if there was a PHP error + if ( ( empty( $params['headless'] ) || $params['headers'] ) && headers_sent() ) { + print "Headers already sent, terminating.\n"; $status->fatal( 'backend-fail-stream', $params['src'] ); + return $status; } + $status->merge( $this->doStreamFile( $params ) ); + return $status; } @@ -879,10 +868,21 @@ abstract class FileBackendStore extends FileBackend { protected function doStreamFile( array $params ) { $status = Status::newGood(); + $flags = 0; + $flags |= !empty( $params['headless'] ) ? StreamFile::STREAM_HEADLESS : 0; + $flags |= !empty( $params['allowOB'] ) ? StreamFile::STREAM_ALLOW_OB : 0; + $fsFile = $this->getLocalReference( $params ); - if ( !$fsFile ) { - $status->fatal( 'backend-fail-stream', $params['src'] ); - } elseif ( !readfile( $fsFile->getPath() ) ) { + + if ( $fsFile ) { + $res = StreamFile::stream( $fsFile->getPath(), + $params['headers'], true, $params['options'], $flags ); + } else { + $res = false; + StreamFile::send404Message( $params['src'], $flags ); + } + + if ( !$res ) { $status->fatal( 'backend-fail-stream', $params['src'] ); } diff --git a/includes/filebackend/MemoryFileBackend.php b/includes/filebackend/MemoryFileBackend.php index 6e32c6292d..e2c1ede46d 100644 --- a/includes/filebackend/MemoryFileBackend.php +++ b/includes/filebackend/MemoryFileBackend.php @@ -183,21 +183,6 @@ class MemoryFileBackend extends FileBackendStore { return $tmpFiles; } - protected function doStreamFile( array $params ) { - $status = Status::newGood(); - - $src = $this->resolveHashKey( $params['src'] ); - if ( $src === null || !isset( $this->files[$src] ) ) { - $status->fatal( 'backend-fail-stream', $params['src'] ); - - return $status; - } - - print $this->files[$src]['data']; - - return $status; - } - protected function doDirectoryExists( $container, $dir, array $params ) { $prefix = rtrim( "$container/$dir", '/' ) . '/'; foreach ( $this->files as $path => $data ) { diff --git a/includes/filebackend/SwiftFileBackend.php b/includes/filebackend/SwiftFileBackend.php index 0f7e4b569e..2adf934a55 100644 --- a/includes/filebackend/SwiftFileBackend.php +++ b/includes/filebackend/SwiftFileBackend.php @@ -1045,32 +1045,62 @@ class SwiftFileBackend extends FileBackendStore { protected function doStreamFile( array $params ) { $status = Status::newGood(); + $flags = !empty( $params['headless'] ) ? StreamFile::STREAM_HEADLESS : 0; + list( $srcCont, $srcRel ) = $this->resolveStoragePathReal( $params['src'] ); if ( $srcRel === null ) { + StreamFile::send404Message( $params['src'], $flags ); $status->fatal( 'backend-fail-invalidpath', $params['src'] ); + + return $status; } $auth = $this->getAuthentication(); if ( !$auth || !is_array( $this->getContainerStat( $srcCont ) ) ) { + StreamFile::send404Message( $params['src'], $flags ); $status->fatal( 'backend-fail-stream', $params['src'] ); return $status; } - $handle = fopen( 'php://output', 'wb' ); + // If "headers" is set, we only want to send them if the file is there. + // Do not bother checking if the file exists if headers are not set though. + if ( $params['headers'] && !$this->fileExists( $params ) ) { + StreamFile::send404Message( $params['src'], $flags ); + $status->fatal( 'backend-fail-stream', $params['src'] ); + return $status; + } + + // Send the requested additional headers + foreach ( $params['headers'] as $header ) { + header( $header ); // aways send + } + + if ( empty( $params['allowOB'] ) ) { + // Cancel output buffering and gzipping if set + wfResetOutputBuffers(); + } + + $handle = fopen( 'php://output', 'wb' ); list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $this->http->run( [ 'method' => 'GET', 'url' => $this->storageUrl( $auth, $srcCont, $srcRel ), 'headers' => $this->authTokenHeaders( $auth ) - + $this->headersFromParams( $params ), + + $this->headersFromParams( $params ) + $params['options'], 'stream' => $handle, + 'flags' => [ 'relayResponseHeaders' => empty( $params['headless'] ) ] ] ); if ( $rcode >= 200 && $rcode <= 299 ) { // good } elseif ( $rcode === 404 ) { $status->fatal( 'backend-fail-stream', $params['src'] ); + // Per bug 41113, nasty things can happen if bad cache entries get + // stuck in cache. It's also possible that this error can come up + // with simple race conditions. Clear out the stat cache to be safe. + $this->clearCache( [ $params['src'] ] ); + $this->deleteFileCache( $params['src'] ); } else { $this->onError( $status, __METHOD__, $params, $rerr, $rcode, $rdesc ); } diff --git a/includes/filerepo/FileRepo.php b/includes/filerepo/FileRepo.php index 9ad24283c9..bfdf817c08 100644 --- a/includes/filerepo/FileRepo.php +++ b/includes/filerepo/FileRepo.php @@ -1586,12 +1586,13 @@ class FileRepo { * * @param string $virtualUrl * @param array $headers Additional HTTP headers to send on success + * @param array $optHeaders HTTP request headers (if-modified-since, range, ...) * @return Status * @since 1.27 */ - public function streamFileWithStatus( $virtualUrl, $headers = [] ) { + public function streamFileWithStatus( $virtualUrl, $headers = [], $optHeaders = [] ) { $path = $this->resolveToStoragePath( $virtualUrl ); - $params = [ 'src' => $path, 'headers' => $headers ]; + $params = [ 'src' => $path, 'headers' => $headers, 'options' => $optHeaders ]; return $this->backend->streamFile( $params ); } diff --git a/includes/libs/MultiHttpClient.php b/includes/libs/MultiHttpClient.php index 331f2d5e99..c015b122af 100644 --- a/includes/libs/MultiHttpClient.php +++ b/includes/libs/MultiHttpClient.php @@ -35,6 +35,8 @@ * use application/x-www-form-urlencoded (headers sent automatically) * - stream : resource to stream the HTTP response body to * - proxy : HTTP proxy to use + * - flags : map of boolean flags which supports: + * - relayResponseHeaders : write out header via header() * Request maps can use integer index 0 instead of 'method' and 1 instead of 'url'. * * @author Aaron Schulz @@ -172,6 +174,7 @@ class MultiHttpClient { $req['body'] = ''; $req['headers']['content-length'] = 0; } + $req['flags'] = isset( $req['flags'] ) ? $req['flags'] : []; $handles[$index] = $this->getCurlHandle( $req, $opts ); if ( count( $reqs ) > 1 ) { // https://github.com/guzzle/guzzle/issues/349 @@ -373,6 +376,9 @@ class MultiHttpClient { curl_setopt( $ch, CURLOPT_HEADERFUNCTION, function ( $ch, $header ) use ( &$req ) { + if ( !empty( $req['flags']['relayResponseHeaders'] ) ) { + header( $header ); + } $length = strlen( $header ); $matches = []; if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) { diff --git a/tests/phpunit/includes/filebackend/FileBackendTest.php b/tests/phpunit/includes/filebackend/FileBackendTest.php index 4aeddc6b6f..254cfbd677 100644 --- a/tests/phpunit/includes/filebackend/FileBackendTest.php +++ b/tests/phpunit/includes/filebackend/FileBackendTest.php @@ -1139,16 +1139,16 @@ class FileBackendTest extends MediaWikiTestCase { $this->tearDownFiles(); $this->doTestStreamFile( $path, $content, $alreadyExists ); $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestStreamFile( $path, $content, $alreadyExists ); + $this->tearDownFiles(); } private function doTestStreamFile( $path, $content ) { $backendName = $this->backendClass(); - // Test doStreamFile() directly to avoid header madness - $class = new ReflectionClass( $this->backend ); - $method = $class->getMethod( 'doStreamFile' ); - $method->setAccessible( true ); - if ( $content !== null ) { $this->prepare( [ 'dir' => dirname( $path ) ] ); $status = $this->create( [ 'dst' => $path, 'content' => $content ] ); @@ -1156,18 +1156,19 @@ class FileBackendTest extends MediaWikiTestCase { "Creation of file at $path succeeded ($backendName)." ); ob_start(); - $method->invokeArgs( $this->backend, [ [ 'src' => $path ] ] ); + $this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1 ] ); $data = ob_get_contents(); ob_end_clean(); $this->assertEquals( $content, $data, "Correct content streamed from '$path'" ); } else { // 404 case ob_start(); - $method->invokeArgs( $this->backend, [ [ 'src' => $path ] ] ); + $this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1 ] ); $data = ob_get_contents(); ob_end_clean(); - $this->assertEquals( '', $data, "Correct content streamed from '$path' ($backendName)" ); + $this->assertRegExp( '#

File not found

#', $data, + "Correct content streamed from '$path' ($backendName)" ); } } @@ -1181,6 +1182,53 @@ class FileBackendTest extends MediaWikiTestCase { return $cases; } + public function testStreamFileRange() { + $this->backend = $this->singleBackend; + $this->tearDownFiles(); + $this->doTestStreamFileRange(); + $this->tearDownFiles(); + + $this->backend = $this->multiBackend; + $this->tearDownFiles(); + $this->doTestStreamFileRange(); + $this->tearDownFiles(); + } + + private function doTestStreamFileRange() { + $backendName = $this->backendClass(); + + $base = self::baseStorePath(); + $path = "$base/unittest-cont1/e/b/z/range_file.txt"; + $content = "0123456789ABCDEF"; + + $this->prepare( [ 'dir' => dirname( $path ) ] ); + $status = $this->create( [ 'dst' => $path, 'content' => $content ] ); + $this->assertGoodStatus( $status, + "Creation of file at $path succeeded ($backendName)." ); + + static $ranges = [ + 'bytes=0-0' => '0', + 'bytes=0-3' => '0123', + 'bytes=4-8' => '45678', + 'bytes=15-15' => 'F', + 'bytes=14-15' => 'EF', + 'bytes=-5' => 'BCDEF', + 'bytes=-1' => 'F', + 'bytes=10-16' => 'ABCDEF', + 'bytes=10-99' => 'ABCDEF', + ]; + + foreach ( $ranges as $range => $chunk ) { + ob_start(); + $this->backend->streamFile( [ 'src' => $path, 'headless' => 1, 'allowOB' => 1, + 'options' => [ 'range' => $range ] ] ); + $data = ob_get_contents(); + ob_end_clean(); + + $this->assertEquals( $chunk, $data, "Correct chunk streamed from '$path' for '$range'" ); + } + } + /** * @dataProvider provider_testGetFileContents * @covers FileBackend::getFileContents @@ -1516,7 +1564,7 @@ class FileBackendTest extends MediaWikiTestCase { [ "$base/unittest-cont1/e/a/z/some_file1.txt", true ], [ "$base/unittest-cont2/a/z/some_file2.txt", true ], # Specific to FS backend with no basePath field set - # array( "$base/unittest-cont3/a/z/some_file3.txt", false ), + # [ "$base/unittest-cont3/a/z/some_file3.txt", false ], ]; } -- 2.20.1