-
-
Notifications
You must be signed in to change notification settings - Fork 145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Streaming body parser: Multipart #200
Closed
WyriHaximus
wants to merge
11
commits into
reactphp:master
from
WyriHaximus:streaming-body-parser-multipart
Closed
Changes from 9 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
cb454da
Multipart parser
WyriHaximus 6692b63
Removed commented out line
WyriHaximus 72b13b6
PHP 5.3 fixes
WyriHaximus a1049c7
Use EventEmitter instead of EventEmitterTrait
WyriHaximus 8bb666c
PHP 5.3 array notation
WyriHaximus 82a7572
PHP 5.3 doesn't support (new Class())->method()
WyriHaximus 4040c75
PHP 5.3 doesn't have the callable typehint working as expected
WyriHaximus 24ae38f
No done in older promise versions
WyriHaximus 64a80d1
PHP 5.3 compatibility fixes
WyriHaximus bbe712c
Fixed typo as pointed out by @jsor at https://github.com/reactphp/htt…
WyriHaximus d0d2ae3
Removed promise usage for cancel, as pointed out by @jsor at https://…
WyriHaximus File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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,319 @@ | ||
<?php | ||
|
||
namespace React\Http\StreamingBodyParser; | ||
|
||
use Evenement\EventEmitter; | ||
use Psr\Http\Message\RequestInterface; | ||
use React\Http\HttpBodyStream; | ||
use React\Http\UploadedFile; | ||
use React\Promise\CancellablePromiseInterface; | ||
use React\Promise\Deferred; | ||
use React\Stream\ThroughStream; | ||
|
||
final class MultipartParser extends EventEmitter | ||
{ | ||
/** | ||
* @var string | ||
*/ | ||
protected $buffer = ''; | ||
|
||
/** | ||
* @var string | ||
*/ | ||
protected $ending = ''; | ||
|
||
/** | ||
* @var int | ||
*/ | ||
protected $endingSize = 0; | ||
|
||
/** | ||
* @var string | ||
*/ | ||
protected $boundary; | ||
|
||
/** | ||
* @var RequestInterface | ||
*/ | ||
protected $request; | ||
|
||
/** | ||
* @var HttpBodyStream | ||
*/ | ||
protected $body; | ||
|
||
/** | ||
* @var CancellablePromiseInterface | ||
*/ | ||
protected $promise; | ||
|
||
/** | ||
* @var callable | ||
*/ | ||
protected $onDataCallable; | ||
|
||
public function __construct(RequestInterface $request) | ||
{ | ||
$this->onDataCallable = array($this, 'onData'); | ||
$deferred = new Deferred(function () { | ||
$this->body->removeListener('data', $this->onDataCallable); | ||
$this->body->close(); | ||
}); | ||
$this->promise = $deferred->promise(); | ||
$this->request = $request; | ||
$this->body = $this->request->getBody(); | ||
|
||
$dataMethod = $this->determineOnDataMethod(); | ||
$this->setOnDataListener(array($this, $dataMethod)); | ||
} | ||
|
||
protected function determineOnDataMethod() | ||
{ | ||
if (!$this->request->hasHeader('content-type')) { | ||
return 'findBoundary'; | ||
} | ||
|
||
$contentType = $this->request->getHeaderLine('content-type'); | ||
preg_match('/boundary="?(.*)"?$/', $contentType, $matches); | ||
if (isset($matches[1])) { | ||
$this->setBoundary($matches[1]); | ||
return 'onData'; | ||
} | ||
|
||
return 'findBoundary'; | ||
} | ||
|
||
protected function setBoundary($boundary) | ||
{ | ||
$this->boundary = $boundary; | ||
$this->ending = $this->boundary . "--\r\n"; | ||
$this->endingSize = strlen($this->ending); | ||
} | ||
|
||
public function findBoundary($data) | ||
{ | ||
$this->buffer .= $data; | ||
|
||
if (substr($this->buffer, 0, 3) === '---' && strpos($this->buffer, "\r\n") !== false) { | ||
$boundary = substr($this->buffer, 2, strpos($this->buffer, "\r\n")); | ||
$boundary = substr($boundary, 0, -2); | ||
$this->setBoundary($boundary); | ||
$this->setOnDataListener(array($this, 'onData')); | ||
} | ||
} | ||
|
||
public function onData($data) | ||
{ | ||
$this->buffer .= $data; | ||
$ending = strpos($this->buffer, $this->ending) == strlen($this->buffer) - $this->endingSize; | ||
|
||
if ( | ||
strrpos($this->buffer, $this->boundary) < strrpos($this->buffer, "\r\n\r\n") || $ending | ||
) { | ||
$this->parseBuffer(); | ||
} | ||
|
||
if ($ending) { | ||
$this->emit('end'); | ||
} | ||
} | ||
|
||
protected function parseBuffer() | ||
{ | ||
$chunks = preg_split('/-+' . $this->boundary . '/', $this->buffer); | ||
$this->buffer = array_pop($chunks); | ||
foreach ($chunks as $chunk) { | ||
$chunk = $this->stripTrainingEOL($chunk); | ||
$this->parseChunk($chunk); | ||
} | ||
|
||
$split = explode("\r\n\r\n", $this->buffer, 2); | ||
if (count($split) <= 1) { | ||
return; | ||
} | ||
|
||
$chunks = preg_split('/-+' . $this->boundary . '/', $split[0], -1, PREG_SPLIT_NO_EMPTY); | ||
$headers = $this->parseHeaders(trim($chunks[0])); | ||
if (isset($headers['content-disposition']) && $this->headerStartsWith($headers['content-disposition'], 'filename')) { | ||
$this->parseFile($headers, $split[1]); | ||
$this->buffer = ''; | ||
} | ||
} | ||
|
||
protected function parseChunk($chunk) | ||
{ | ||
if ($chunk === '') { | ||
return; | ||
} | ||
|
||
list ($header, $body) = explode("\r\n\r\n", $chunk, 2); | ||
$headers = $this->parseHeaders($header); | ||
|
||
if (!isset($headers['content-disposition'])) { | ||
return; | ||
} | ||
|
||
if ($this->headerStartsWith($headers['content-disposition'], 'filename')) { | ||
$this->parseFile($headers, $body, false); | ||
return; | ||
} | ||
|
||
if ($this->headerStartsWith($headers['content-disposition'], 'name')) { | ||
$this->parsePost($headers, $body); | ||
return; | ||
} | ||
} | ||
|
||
protected function parseFile($headers, $body, $streaming = true) | ||
{ | ||
if ( | ||
!$this->headerContains($headers['content-disposition'], 'name=') || | ||
!$this->headerContains($headers['content-disposition'], 'filename=') | ||
) { | ||
return; | ||
} | ||
|
||
$stream = new ThroughStream(); | ||
$this->emit('file', array( | ||
$this->getFieldFromHeader($headers['content-disposition'], 'name'), | ||
new UploadedFile( | ||
new HttpBodyStream($stream, null), | ||
0, | ||
UPLOAD_ERR_OK, | ||
$this->getFieldFromHeader($headers['content-disposition'], 'filename'), | ||
$headers['content-type'][0] | ||
), | ||
$headers, | ||
)); | ||
|
||
if (!$streaming) { | ||
$stream->end($body); | ||
return; | ||
} | ||
|
||
$this->setOnDataListener($this->chunkStreamFunc($stream)); | ||
$stream->write($body); | ||
} | ||
|
||
protected function chunkStreamFunc(ThroughStream $stream) | ||
{ | ||
$that = $this; | ||
$boundary = $this->boundary; | ||
$buffer = ''; | ||
$func = function($data) use (&$buffer, $stream, $that, $boundary) { | ||
$buffer .= $data; | ||
if (strpos($buffer, $boundary) !== false) { | ||
$chunks = preg_split('/-+' . $boundary . '/', $buffer); | ||
$chunk = array_shift($chunks); | ||
$chunk = $that->stripTrainingEOL($chunk); | ||
$stream->end($chunk); | ||
|
||
$that->setOnDataListener(array($that, 'onData')); | ||
|
||
if (count($chunks) == 1) { | ||
array_unshift($chunks, ''); | ||
} | ||
|
||
$that->onData(implode('-' . $boundary, $chunks)); | ||
return; | ||
} | ||
|
||
if (strlen($buffer) >= strlen($boundary) * 3) { | ||
$stream->write($buffer); | ||
$buffer = ''; | ||
} | ||
}; | ||
return $func; | ||
} | ||
|
||
protected function parsePost($headers, $body) | ||
{ | ||
foreach ($headers['content-disposition'] as $part) { | ||
if (strpos($part, 'name') === 0) { | ||
preg_match('/name="?(.*)"$/', $part, $matches); | ||
$this->emit('post', array( | ||
$matches[1], | ||
$body, | ||
$headers, | ||
)); | ||
} | ||
} | ||
} | ||
|
||
protected function parseHeaders($header) | ||
{ | ||
$headers = array(); | ||
|
||
foreach (explode("\r\n", trim($header)) as $line) { | ||
list($key, $values) = explode(':', $line, 2); | ||
$key = trim($key); | ||
$key = strtolower($key); | ||
$values = explode(';', $values); | ||
$values = array_map('trim', $values); | ||
$headers[$key] = $values; | ||
} | ||
|
||
return $headers; | ||
} | ||
|
||
protected function headerStartsWith(array $header, $needle) | ||
{ | ||
foreach ($header as $part) { | ||
if (strpos($part, $needle) === 0) { | ||
return true; | ||
} | ||
} | ||
|
||
return false; | ||
} | ||
|
||
protected function headerContains(array $header, $needle) | ||
{ | ||
foreach ($header as $part) { | ||
if (strpos($part, $needle) !== false) { | ||
return true; | ||
} | ||
} | ||
|
||
return false; | ||
} | ||
|
||
protected function getFieldFromHeader(array $header, $field) | ||
{ | ||
foreach ($header as $part) { | ||
if (strpos($part, $field) === 0) { | ||
preg_match('/' . $field . '="?(.*)"$/', $part, $matches); | ||
return $matches[1]; | ||
} | ||
} | ||
|
||
return ''; | ||
} | ||
|
||
/** | ||
* @internal | ||
*/ | ||
public function setOnDataListener($callable) | ||
{ | ||
$this->body->removeListener('data', $this->onDataCallable); | ||
$this->onDataCallable = $callable; | ||
$this->body->on('data', $this->onDataCallable); | ||
} | ||
|
||
public function cancel() | ||
{ | ||
$this->promise->cancel(); | ||
} | ||
|
||
/** | ||
* @internal | ||
*/ | ||
public function stripTrainingEOL($chunk) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Typo: |
||
{ | ||
if (substr($chunk, -2) === "\r\n") { | ||
return substr($chunk, 0, -2); | ||
} | ||
|
||
return $chunk; | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the promise property really needed? Looks like it's only used in
MultipartParser::cancel()
. I think this is unecessary andcan be just called directly there.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not really, fixed it 👍