diff --git a/composer.json b/composer.json index e73e29c7..a99878a2 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,8 @@ "react/socket": "^1.0 || ^0.8 || ^0.7 || ^0.6 || ^0.5", "react/stream": "^1.0 || ^0.7 || ^0.6 || ^0.5 || ^0.4.6", "react/promise": "^2.1 || ^1.2.1", - "evenement/evenement": "^2.0 || ^1.0" + "evenement/evenement": "^2.0 || ^1.0", + "react/promise-stream": "^0.1" }, "autoload": { "psr-4": { @@ -18,7 +19,6 @@ }, "require-dev": { "phpunit/phpunit": "^4.8.10||^5.0", - "react/promise-stream": "^0.1.1", "react/socket": "^1.0 || ^0.8 || ^0.7", "clue/block-react": "^1.1" } diff --git a/src/StreamingBodyParser/MultipartParser.php b/src/StreamingBodyParser/MultipartParser.php new file mode 100644 index 00000000..44aa53cd --- /dev/null +++ b/src/StreamingBodyParser/MultipartParser.php @@ -0,0 +1,307 @@ +onDataCallable = array($this, 'onData'); + $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->stripTrailingEOL($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->stripTrailingEOL($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->body->removeListener('data', $this->onDataCallable); + $this->body->close(); + } + + /** + * @internal + */ + public function stripTrailingEOL($chunk) + { + if (substr($chunk, -2) === "\r\n") { + return substr($chunk, 0, -2); + } + + return $chunk; + } +} diff --git a/tests/StreamingBodyParser/MultipartParserTest.php b/tests/StreamingBodyParser/MultipartParserTest.php new file mode 100644 index 00000000..470688d1 --- /dev/null +++ b/tests/StreamingBodyParser/MultipartParserTest.php @@ -0,0 +1,231 @@ + 'multipart/mixed; boundary=' . $boundary, + ), new HttpBodyStream($stream, 0), 1.1); + + $parser = new MultipartParser($request); + $parser->on('post', function ($key, $value) use (&$post) { + $post[$key] = $value; + }); + $parser->on('file', function ($name, UploadedFileInterface $file) use (&$files) { + $files[] = array($name, $file); + }); + + $data = "--$boundary\r\n"; + $data .= "Content-Disposition: form-data; name=\"users[one]\"\r\n"; + $data .= "\r\n"; + $data .= "single\r\n"; + $data .= "--$boundary\r\n"; + $data .= "Content-Disposition: form-data; name=\"users[two]\"\r\n"; + $data .= "\r\n"; + $data .= "second\r\n"; + $data .= "--$boundary--\r\n"; + + $stream->write($data); + + $this->assertEmpty($files); + $this->assertEquals( + array( + 'users[one]' => 'single', + 'users[two]' => 'second', + ), + $post + ); + } + + public function testFileUpload() + { + $files = array(); + $post = array(); + + $stream = new ThroughStream(); + $boundary = "---------------------------12758086162038677464950549563"; + + $request = new Request('POST', 'http://example.com/', array( + 'Content-Type' => 'multipart/form-data', + ), new HttpBodyStream($stream, 0), 1.1); + + $multipart = new MultipartParser($request); + + $multipart->on('post', function ($key, $value) use (&$post) { + $post[] = array($key => $value); + }); + $multipart->on('file', function ($name, UploadedFileInterface $file, $headers) use (&$files) { + Stream\buffer($file->getStream())->then(function ($buffer) use ($name, $file, $headers, &$files) { + $body = new BufferStream(strlen($buffer)); + $body->write($buffer); + $files[] = array( + $name, + new UploadedFile( + $body, + strlen($buffer), + $file->getError(), + $file->getClientFilename(), + $file->getClientMediaType() + ), + $headers, + ); + }, function ($t) { + throw $t; + }); + }); + + $file = base64_decode("R0lGODlhAQABAIAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="); + + $data = "--$boundary\r\n"; + $data .= "Content-Disposition: form-data; name=\"users[one]\"\r\n"; + $data .= "\r\n"; + $data .= "single\r\n"; + $data .= "--$boundary\r\n"; + $data .= "Content-Disposition: form-data; name=\"users[two]\"\r\n"; + $data .= "\r\n"; + $data .= "second\r\n"; + $stream->write($data); + $stream->write("--$boundary\r\n"); + $stream->write("Content-disposition: form-data; name=\"user\"\r\n"); + $stream->write("\r\n"); + $stream->write("single\r\n"); + $stream->write("--$boundary\r\n"); + $stream->write("content-Disposition: form-data; name=\"user2\"\r\n"); + $stream->write("\r\n"); + $stream->write("second\r\n"); + $stream->write("--$boundary\r\n"); + $stream->write("Content-Disposition: form-data; name=\"users[]\"\r\n"); + $stream->write("\r\n"); + $stream->write("first in array\r\n"); + $stream->write("--$boundary\r\n"); + $stream->write("Content-Disposition: form-data; name=\"users[]\"\r\n"); + $stream->write("\r\n"); + $stream->write("second in array\r\n"); + $stream->write("--$boundary\r\n"); + $stream->write("Content-Disposition: form-data; name=\"file\"; filename=\"Us er.php\"\r\n"); + $stream->write("Content-type: text/php\r\n"); + $stream->write("\r\n"); + $stream->write("write("\r\n"); + $line = "--$boundary"; + $lines = str_split($line, round(strlen($line) / 2)); + $stream->write($lines[0]); + $stream->write($lines[1]); + $stream->write("\r\n"); + $stream->write("Content-Disposition: form-data; name=\"files[]\"; filename=\"blank.gif\"\r\n"); + $stream->write("content-Type: image/gif\r\n"); + $stream->write("X-Foo-Bar: base64\r\n"); + $stream->write("\r\n"); + $stream->write($file . "\r\n"); + $stream->write("--$boundary\r\n"); + $stream->write("Content-Disposition: form-data; name=\"files[]\"; filename=\"User.php\"\r\n" . + "Content-Type: text/php\r\n" . + "\r\n" . + "write("\r\n" . + "--$boundary\r\n" . + "Content-Disposition: form-data; name=\"files[]\"; filename=\"Owner.php\"\r\n" . + "Content-Type: text/php\r\n" . + "\r\n" . + "assertEquals(6, count($post)); + $this->assertEquals( + array( + array('users[one]' => 'single'), + array('users[two]' => 'second'), + array('user' => 'single'), + array('user2' => 'second'), + array('users[]' => 'first in array'), + array('users[]' => 'second in array'), + ), + $post + ); + + $this->assertEquals(4, count($files)); + $this->assertEquals('file', $files[0][0]); + $this->assertEquals('Us er.php', $files[0][1]->getClientFilename()); + $this->assertEquals('text/php', $files[0][1]->getClientMediaType()); + $this->assertEquals("getStream()->getContents()); + $this->assertEquals(array( + 'content-disposition' => array( + 'form-data', + 'name="file"', + 'filename="Us er.php"', + ), + 'content-type' => array( + 'text/php', + ), + ), $files[0][2]); + + $this->assertEquals('files[]', $files[1][0]); + $this->assertEquals('blank.gif', $files[1][1]->getClientFilename()); + $this->assertEquals('image/gif', $files[1][1]->getClientMediaType()); + $this->assertEquals($file, $files[1][1]->getStream()->getContents()); + $this->assertEquals(array( + 'content-disposition' => array( + 'form-data', + 'name="files[]"', + 'filename="blank.gif"', + ), + 'content-type' => array( + 'image/gif', + ), + 'x-foo-bar' => array( + 'base64', + ), + ), $files[1][2]); + + $this->assertEquals('files[]', $files[2][0]); + $this->assertEquals('User.php', $files[2][1]->getClientFilename()); + $this->assertEquals('text/php', $files[2][1]->getClientMediaType()); + $this->assertEquals("getStream()->getContents()); + $this->assertEquals(array( + 'content-disposition' => array( + 'form-data', + 'name="files[]"', + 'filename="User.php"', + ), + 'content-type' => array( + 'text/php', + ), + ), $files[2][2]); + + $this->assertEquals('files[]', $files[3][0]); + $this->assertEquals('Owner.php', $files[3][1]->getClientFilename()); + $this->assertEquals('text/php', $files[3][1]->getClientMediaType()); + $this->assertEquals("getStream()->getContents()); + $this->assertEquals(array( + 'content-disposition' => array( + 'form-data', + 'name="files[]"', + 'filename="Owner.php"', + ), + 'content-type' => array( + 'text/php', + ), + ), $files[3][2]); + } +}