Added Range support to FileBackend::streamFile()
authorAaron Schulz <aschulz@wikimedia.org>
Sun, 20 Apr 2014 08:40:06 +0000 (01:40 -0700)
committerAaron Schulz <aschulz@wikimedia.org>
Sat, 4 Jun 2016 12:43:43 +0000 (05:43 -0700)
* 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
includes/StreamFile.php
includes/filebackend/FileBackend.php
includes/filebackend/FileBackendStore.php
includes/filebackend/MemoryFileBackend.php
includes/filebackend/SwiftFileBackend.php
includes/filerepo/FileRepo.php
includes/libs/MultiHttpClient.php
tests/phpunit/includes/filebackend/FileBackendTest.php

index d636188..fa1609f 100644 (file)
@@ -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 );
 }
 
 /**
index 8d0b8f1..0fc7980 100644 (file)
  * 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 "<html><body>
-                                       <h1>File not found</h1>
-                                       <p>Although this PHP script ($encScript) exists, the file requested for output
-                                       ($encFile) does not.</p>
-                                       </body></html>
-                                       ";
+                               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 "<!DOCTYPE html><html><body>
+                       <h1>File not found</h1>
+                       <p>Although this PHP script ($encScript) exists, the file requested for output
+                       ($encFile) does not.</p>
+                       </body></html>
+                       ";
+       }
 
-               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';
        }
 
        /**
index 03974f7..10183f4 100644 (file)
@@ -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 );
index 4d9587e..a29119c 100644 (file)
@@ -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'] );
                }
 
index 6e32c62..e2c1ede 100644 (file)
@@ -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 ) {
index 0f7e4b5..2adf934 100644 (file)
@@ -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 );
                }
index 9ad2428..bfdf817 100644 (file)
@@ -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 );
        }
index 331f2d5..c015b12 100644 (file)
@@ -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 ) ) {
index 4aeddc6..254cfbd 100644 (file)
@@ -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( '#<h1>File not found</h1>#', $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 ],
                ];
        }