Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
merged branch niklasf/binary-file-response (PR #4546)
This PR was merged into the master branch. Commits ------- 2f7bbbf [HttpFoundation] Added BinaryFileResponse. Discussion ---------- [2.2] [HttpFoundation] Added BinaryFileResponse. Another stab at #3602, based on @stealth35's code at https://gist.github.com/1472230. - Move things around a little, clean things up, looking how it has been done in StreamedResponse. - Add tests. - Make functions chainable. - Add a flag whether or not to trust the X-Sendfile-Type header. --------------------------------------------------------------------------- by Partugal at 2012-06-10T19:56:43Z What about support X-Accel-Redirect (nginx)? --------------------------------------------------------------------------- by niklasf at 2012-06-10T20:41:10Z @Partugal: So we support X-Sendfile-Type to pick the X-Sendfile header. What else would be needed to support X-Accel-Redirect (which we should definitely do)? --------------------------------------------------------------------------- by Partugal at 2012-06-10T21:29:41Z @niklasf Because nginx not use full file path, this need X-Accel-Mapping header (http://rack.rubyforge.org/doc/Rack/Sendfile.html) --------------------------------------------------------------------------- by niklasf at 2012-06-10T22:45:38Z @Partugal: Alright. Doing such a substitution now. Also added a test for that. --------------------------------------------------------------------------- by stealth35 at 2012-06-11T07:47:35Z I think the MIME should be base on the extensions map, for an example with `xlsx` that send an `application/zip` or a `xlsx` file MIME is `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` Client to server : Reverve MIME => libmagic Server to client : MIME => MIME map --------------------------------------------------------------------------- by niklasf at 2012-06-11T14:40:00Z @partugal: Thanks! Also added tests. Any e-mail you want to have in your credits? --------------------------------------------------------------------------- by niklasf at 2012-06-11T14:41:39Z @stealth35: Yeah ... makes sense. How would I get that information? --------------------------------------------------------------------------- by stealth35 at 2012-06-11T14:47:36Z use the `Symfony\Component\HttpFoundation\File\Mimetype\MimeTypeExtensionGuesser` it's the same map as Apache and if the extension don't exists use `$this->getMimeType` and finaly `application/octet-stream` --------------------------------------------------------------------------- by Partugal at 2012-06-11T15:46:41Z @niklasf Thanks you for your work If needed you may use linniksa@gmail.com --------------------------------------------------------------------------- by niklasf at 2012-06-14T10:58:19Z @stealth35: Sorry. I have to ask again. - So the first step would be using the map in `MimeTypeExtensionGuesser`? I don't see how I can access that, because the `guess()` method it has, is for guessing extensions from mime types, not the reverse. - Then, by `$this->getMimeType` you mean the getMimeType() method of the file? Sounds good. - `application/octet-stream` as the fallback. Alright. --------------------------------------------------------------------------- by stealth35 at 2012-06-14T11:00:33Z Yeah sorry `MimeTypeExtensionGuesser` is for getting an extension with the Mime, forget about this, i'll take care aboute all MIME intégration later --------------------------------------------------------------------------- by niklasf at 2012-06-14T13:12:22Z @stealth35: Awesome. Thanks a lot. --------------------------------------------------------------------------- by jalliot at 2012-08-07T20:53:54Z @niklasf You should backport the changes from 532334d and 3f51bc0 --------------------------------------------------------------------------- by niklasf at 2012-08-07T21:07:10Z @jalliot Thanks. Fixed.
- Loading branch information
Showing
2 changed files
with
383 additions
and
0 deletions.
There are no files selected for viewing
259 changes: 259 additions & 0 deletions
259
src/Symfony/Component/HttpFoundation/BinaryFileResponse.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,259 @@ | ||
<?php | ||
|
||
/** | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\HttpFoundation; | ||
|
||
use Symfony\Component\HttpFoundation\File\File; | ||
use Symfony\Component\HttpFoundation\File\Exception\FileException; | ||
|
||
/** | ||
* BinaryFileResponse represents an HTTP response delivering a file. | ||
* | ||
* @author Niklas Fiekas <niklas.fiekas@tu-clausthal.de> | ||
* @author stealth35 <stealth35-php@live.fr> | ||
* @author Igor Wiedler <igor@wiedler.ch> | ||
* @author Jordan Alliot <jordan.alliot@gmail.com> | ||
* @author Sergey Linnik <linniksa@gmail.com> | ||
*/ | ||
class BinaryFileResponse extends Response | ||
{ | ||
protected static $trustXSendfileTypeHeader = false; | ||
|
||
protected $file; | ||
protected $offset; | ||
protected $maxlen; | ||
|
||
/** | ||
* Constructor. | ||
* | ||
* @param SplFileInfo|string $file The file to stream | ||
* @param integer $status The response status code | ||
* @param array $headers An array of response headers | ||
* @param boolean $public Files are public by default | ||
* @param null|string $contentDisposition The type of Content-Disposition to set automatically with the filename | ||
* @param boolean $autoEtag Whether the ETag header should be automatically set | ||
* @param boolean $autoLastModified Whether the Last-Modified header should be automatically set | ||
*/ | ||
public function __construct($file, $status = 200, $headers = array(), $public = true, $contentDisposition = null, $autoEtag = false, $autoLastModified = true) | ||
{ | ||
parent::__construct(null, $status, $headers); | ||
|
||
$this->setFile($file, $contentDisposition, $autoEtag, $autoLastModified); | ||
|
||
if ($public) { | ||
$this->setPublic(); | ||
} | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public static function create($file = null, $status = 200, $headers = array(), $public = true, $contentDisposition = null, $autoEtag = false, $autoLastModified = true) | ||
{ | ||
return new static($file, $status, $headers, $public, $contentDisposition, $autoEtag, $autoLastModified); | ||
} | ||
|
||
/** | ||
* Sets the file to stream. | ||
* | ||
* @param SplFileInfo|string $file The file to stream | ||
*/ | ||
public function setFile($file, $contentDisposition = null, $autoEtag = false, $autoLastModified = true) | ||
{ | ||
$file = new File((string) $file); | ||
|
||
if (!$file->isReadable()) { | ||
throw new FileException('File must be readable.'); | ||
} | ||
|
||
$this->file = $file; | ||
|
||
if ($autoEtag) { | ||
$this->setAutoEtag(); | ||
} | ||
|
||
if ($autoLastModified) { | ||
$this->setAutoLastModified(); | ||
} | ||
|
||
if ($contentDisposition) { | ||
$this->setContentDisposition($contentDisposition); | ||
} | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* Gets the file. | ||
* | ||
* @return File The file to stream | ||
*/ | ||
public function getFile() | ||
{ | ||
return $this->file; | ||
} | ||
|
||
/** | ||
* Automatically sets the Last-Modified header according the file modification date. | ||
*/ | ||
public function setAutoLastModified() | ||
{ | ||
$this->setLastModified(\DateTime::createFromFormat('U', $this->file->getMTime())); | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* Automatically sets the ETag header according to the checksum of the file. | ||
*/ | ||
public function setAutoEtag() | ||
{ | ||
$this->setEtag(sha1_file($this->file->getPathname())); | ||
This comment has been minimized.
Sorry, something went wrong.
This comment has been minimized.
Sorry, something went wrong.
niklasf
Contributor
|
||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* Sets the Content-Disposition header with the given filename. | ||
* | ||
* @param string $disposition ResponseHeaderBag::DISPOSITION_INLINE or ResponseHeaderBag::DISPOSITION_ATTACHMENT | ||
* @param string $filename Optionally use this filename instead of the real name of the file | ||
* @param string $filenameFallback A fallback filename, containing only ASCII characters. Defaults to an automatically encoded filename | ||
*/ | ||
public function setContentDisposition($disposition, $filename = '', $filenameFallback = '') | ||
{ | ||
if ($filename === '') { | ||
$filename = $this->file->getFilename(); | ||
} | ||
|
||
$dispositionHeader = $this->headers->makeDisposition($disposition, $filename, $filenameFallback); | ||
$this->headers->set('Content-Disposition', $dispositionHeader); | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function prepare(Request $request) | ||
{ | ||
$this->headers->set('Content-Length', $this->file->getSize()); | ||
$this->headers->set('Accept-Ranges', 'bytes'); | ||
$this->headers->set('Content-Transfer-Encoding', 'binary'); | ||
|
||
if (!$this->headers->has('Content-Type')) { | ||
$this->headers->set('Content-Type', $this->file->getMimeType() ?: 'application/octet-stream'); | ||
} | ||
|
||
if ('HTTP/1.0' != $request->server->get('SERVER_PROTOCOL')) { | ||
$this->setProtocolVersion('1.1'); | ||
} | ||
|
||
$this->offset = 0; | ||
$this->maxlen = -1; | ||
|
||
if (self::$trustXSendfileTypeHeader && $request->headers->has('X-Sendfile-Type')) { | ||
// Use X-Sendfile, do not send any content. | ||
$type = $request->headers->get('X-Sendfile-Type'); | ||
$path = $this->file->getRealPath(); | ||
if (strtolower($type) == 'x-accel-redirect') { | ||
// Do X-Accel-Mapping substitutions. | ||
foreach (explode(',', $request->headers->get('X-Accel-Mapping', '')) as $mapping) { | ||
$mapping = explode('=', $mapping, 2); | ||
|
||
if (2 == count($mapping)) { | ||
$location = trim($mapping[0]); | ||
$pathPrefix = trim($mapping[1]); | ||
|
||
if (substr($path, 0, strlen($pathPrefix)) == $pathPrefix) { | ||
$path = $location . substr($path, strlen($pathPrefix)); | ||
break; | ||
} | ||
} | ||
} | ||
} | ||
$this->headers->set($type, $path); | ||
$this->maxlen = 0; | ||
} elseif ($request->headers->has('Range')) { | ||
// Process the range headers. | ||
if (!$request->headers->has('If-Range') || $this->getEtag() == $request->headers->get('If-Range')) { | ||
$range = $request->headers->get('Range'); | ||
|
||
list($start, $end) = array_map('intval', explode('-', substr($range, 6), 2)) + array(0); | ||
|
||
if ('' !== $end) { | ||
$this->maxlen = $end - $start; | ||
} else { | ||
$end = $this->file->getSize() - 1; | ||
} | ||
|
||
$this->offset = $start; | ||
|
||
$this->setStatusCode(206); | ||
$this->headers->set('Content-Range', sprintf('bytes %s-%s/%s', $start, $end, $this->file->getSize())); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Sends the file. | ||
*/ | ||
public function sendContent() | ||
{ | ||
if (!$this->isSuccessful()) { | ||
parent::sendContent(); | ||
|
||
return; | ||
} | ||
|
||
if (0 === $this->maxlen) { | ||
return; | ||
} | ||
|
||
$out = fopen('php://output', 'wb'); | ||
$file = fopen($this->file->getPathname(), 'rb'); | ||
|
||
stream_copy_to_stream($file, $out, $this->maxlen, $this->offset); | ||
|
||
fclose($out); | ||
fclose($file); | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
* | ||
* @throws \LogicException when the content is not null | ||
*/ | ||
public function setContent($content) | ||
{ | ||
if (null !== $content) { | ||
throw new \LogicException('The content cannot be set on a BinaryFileResponse instance.'); | ||
} | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
* | ||
* @return false | ||
*/ | ||
public function getContent() | ||
{ | ||
return false; | ||
} | ||
|
||
/** | ||
* Trust X-Sendfile-Type header. | ||
*/ | ||
public static function trustXSendfileTypeHeader() | ||
{ | ||
self::$trustXSendfileTypeHeader = true; | ||
} | ||
} |
124 changes: 124 additions & 0 deletions
124
src/Symfony/Component/HttpFoundation/Tests/BinaryFileResponseTest.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\HttpFoundation\Tests; | ||
|
||
use Symfony\Component\HttpFoundation\BinaryFileResponse; | ||
use Symfony\Component\HttpFoundation\Request; | ||
use Symfony\Component\HttpFoundation\ResponseHeaderBag; | ||
|
||
class BinaryFileResponseTest extends \PHPUnit_Framework_TestCase | ||
{ | ||
public function testConstruction() | ||
{ | ||
$response = new BinaryFileResponse('README.md', 404, array('X-Header' => 'Foo'), true, null, true, true); | ||
$this->assertEquals(404, $response->getStatusCode()); | ||
$this->assertEquals('Foo', $response->headers->get('X-Header')); | ||
$this->assertTrue($response->headers->has('ETag')); | ||
$this->assertTrue($response->headers->has('Last-Modified')); | ||
$this->assertFalse($response->headers->has('Content-Disposition')); | ||
|
||
$response = BinaryFileResponse::create('README.md', 404, array(), true, ResponseHeaderBag::DISPOSITION_INLINE); | ||
$this->assertEquals(404, $response->getStatusCode()); | ||
$this->assertFalse($response->headers->has('ETag')); | ||
$this->assertEquals('inline; filename="README.md"', $response->headers->get('Content-Disposition')); | ||
} | ||
|
||
/** | ||
* @expectedException \LogicException | ||
*/ | ||
public function testSetContent() | ||
{ | ||
$response = new BinaryFileResponse('README.md'); | ||
$response->setContent('foo'); | ||
} | ||
|
||
public function testGetContent() | ||
{ | ||
$response = new BinaryFileResponse('README.md'); | ||
$this->assertFalse($response->getContent()); | ||
} | ||
|
||
public function testRequests() | ||
{ | ||
$response = BinaryFileResponse::create('src/Symfony/Component/HttpFoundation/Tests/File/Fixtures/test.gif')->setAutoEtag(); | ||
|
||
// do a request to get the ETag | ||
$request = Request::create('/'); | ||
$response->prepare($request); | ||
$etag = $response->headers->get('ETag'); | ||
|
||
// prepare a request for a range of the testing file | ||
$request = Request::create('/'); | ||
$request->headers->set('If-Range', $etag); | ||
$request->headers->set('Range', 'bytes=1-4'); | ||
|
||
$this->expectOutputString('IF8'); | ||
$response = clone $response; | ||
$response->prepare($request); | ||
$response->sendContent(); | ||
|
||
$this->assertEquals('binary', $response->headers->get('Content-Transfer-Encoding')); | ||
} | ||
|
||
public function testXSendfile() | ||
{ | ||
$request = Request::create('/'); | ||
$request->headers->set('X-Sendfile-Type', 'X-Sendfile'); | ||
|
||
BinaryFileResponse::trustXSendfileTypeHeader(); | ||
$response = BinaryFileResponse::create('README.md'); | ||
$response->prepare($request); | ||
|
||
$this->expectOutputString(''); | ||
$response->sendContent(); | ||
|
||
$this->assertContains('README.md', $response->headers->get('X-Sendfile')); | ||
} | ||
|
||
/** | ||
* @dataProvider getSampleXAccelMappings | ||
*/ | ||
public function testXAccelMapping($realpath, $mapping, $virtual) | ||
{ | ||
$request = Request::create('/'); | ||
$request->headers->set('X-Sendfile-Type', 'X-Accel-Redirect'); | ||
$request->headers->set('X-Accel-Mapping', $mapping); | ||
|
||
$file = $this->getMockBuilder('Symfony\Component\HttpFoundation\File\File') | ||
->disableOriginalConstructor() | ||
->getMock(); | ||
$file->expects($this->any()) | ||
->method('getRealPath') | ||
->will($this->returnValue($realpath)); | ||
$file->expects($this->any()) | ||
->method('isReadable') | ||
->will($this->returnValue(true)); | ||
|
||
BinaryFileResponse::trustXSendFileTypeHeader(); | ||
$response = new BinaryFileResponse('README.md'); | ||
$reflection = new \ReflectionObject($response); | ||
$property = $reflection->getProperty('file'); | ||
$property->setAccessible(true); | ||
$property->setValue($response, $file); | ||
|
||
$response->prepare($request); | ||
$this->assertEquals($virtual, $response->headers->get('X-Accel-Redirect')); | ||
} | ||
|
||
public function getSampleXAccelMappings() | ||
{ | ||
return array( | ||
array('/var/www/var/www/files/foo.txt', '/files/=/var/www/', '/files/var/www/files/foo.txt'), | ||
array('/home/foo/bar.txt', '/files/=/var/www/,/baz/=/home/foo/', '/baz/bar.txt'), | ||
); | ||
} | ||
} |
This will fail on large (>=2Gb) files, due to PHP's limitations in that regard. Also, depending on the algorithm used, this might be a performance problem. It would therefor be very nice if the hashing strategy is injectable (or at least be overridable).