* 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
}
}
+ $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 );
}
/**
* 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.
* 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';
}
/**
/**
* 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 );
$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;
}
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'] );
}
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 ) {
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 );
}
*
* @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 );
}
* 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
$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
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 ) ) {
$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 ] );
"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)" );
}
}
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
[ "$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 ],
];
}