diff --git a/src/Driver/Userland/ConnectionHandler/ConnectionHandler.php b/src/Driver/Userland/ConnectionHandler/ConnectionHandler.php index 858ea34..a33c282 100644 --- a/src/Driver/Userland/ConnectionHandler/ConnectionHandler.php +++ b/src/Driver/Userland/ConnectionHandler/ConnectionHandler.php @@ -389,7 +389,11 @@ private function dispatchRequest($requestId) $this->requests[$requestId]['stdin'] ); - $response = $this->kernel->handleRequest($request); + try { + $response = $this->kernel->handleRequest($request); + } finally { + $request->cleanUploadedFiles(); + } if ($response instanceof ResponseInterface) { $this->sendResponse($requestId, $response); diff --git a/src/Http/Request.php b/src/Http/Request.php index d157302..4c4af0c 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -3,6 +3,7 @@ namespace PHPFastCGI\FastCGIDaemon\Http; use Symfony\Component\HttpFoundation\Request as HttpFoundationRequest; +use function Zend\Diactoros\createUploadedFile; use Zend\Diactoros\ServerRequest; use Zend\Diactoros\ServerRequestFactory; @@ -11,6 +12,21 @@ */ class Request implements RequestInterface { + /** + * @var int + */ + private static $bufferSize = 10485760; // 10 MB + + /** + * @var string + */ + private static $uploadDir = null; + + /** + * @var array + */ + private $uploadedFiles = []; + /** * @var array */ @@ -48,6 +64,39 @@ public function getParams() return $this->params; } + /** + * Remove all uploaded files + */ + public function cleanUploadedFiles(): void + { + foreach ($this->uploadedFiles as $file) { + @unlink($file['tmp_name']); + } + } + + /** + * Set a buffer size to read uploaded files + */ + public static function setBufferSize(int $size): void + { + static::$bufferSize = $size; + } + + public static function getBufferSize(): int + { + return static::$bufferSize; + } + + public static function setUploadDir(string $dir): void + { + static::$uploadDir = $dir; + } + + public static function getUploadDir(): string + { + return static::$uploadDir ?: sys_get_temp_dir(); + } + /** * {@inheritdoc} */ @@ -73,6 +122,15 @@ public function getPost() $requestMethod = $this->params['REQUEST_METHOD']; $contentType = $this->params['CONTENT_TYPE']; + if (strcasecmp($requestMethod, 'POST') === 0 && stripos($contentType, 'multipart/form-data') === 0) { + if (preg_match('/boundary=(?P[\'"]?)(.*)(?P=quote)/', $contentType, $matches)) { + list($postData, $this->uploadedFiles) = $this->parseMultipartFormData($this->stdin, $matches[2]); + parse_str($postData, $post); + + return $post; + } + } + if (strcasecmp($requestMethod, 'POST') === 0 && stripos($contentType, 'application/x-www-form-urlencoded') === 0) { $postData = stream_get_contents($this->stdin); rewind($this->stdin); @@ -84,6 +142,67 @@ public function getPost() return $post ?: []; } + private function parseMultipartFormData($stream, $boundary) { + $post = ""; + $files = []; + $fieldType = $fieldName = $filename = $mimeType = null; + $inHeader = $getContent = false; + + while (!feof($stream)) { + $getContent = $fieldName && !$inHeader; + $buffer = stream_get_line($stream, static::$bufferSize, "\n" . ($getContent ? '--'.$boundary : '')); + $buffer = trim($buffer, "\r"); + + // Find the empty line between headers and body + if ($inHeader && strlen($buffer) == 0) { + $inHeader = false; + + continue; + } + + if ($getContent) { + if ($fieldType === 'data') { + $post .= (isset($post[0]) ? '&' : '') . $fieldName . "=" . urlencode($buffer); + } elseif ($fieldType === 'file' && $filename) { + $tmpPath = tempnam($this->getUploadDir(), 'fastcgi_upload'); + $err = file_put_contents($tmpPath, $buffer); + $files[$fieldName] = [ + 'type' => $mimeType ?: 'application/octet-stream', + 'name' => $filename, + 'tmp_name' => $tmpPath, + 'error' => ($err === false) ? true : 0, + 'size' => filesize($tmpPath), + ]; + $filename = $mimeType = null; + } + $fieldName = $fieldType = null; + + continue; + } + + // Assert: We may be in the header, lets try to find 'Content-Disposition' and 'Content-Type'. + if (strpos($buffer, 'Content-Disposition') === 0) { + $inHeader = true; + if (preg_match('/name=\"([^\"]*)\"/', $buffer, $matches)) { + $fieldName = $matches[1]; + } + if (preg_match('/filename=\"([^\"]*)\"/', $buffer, $matches)) { + $filename = $matches[1]; + $fieldType = 'file'; + } else { + $fieldType = 'data'; + } + } elseif (strpos($buffer, 'Content-Type') === 0) { + $inHeader = true; + if (preg_match('/Content-Type: (.*)?/', $buffer, $matches)) { + $mimeType = trim($matches[1]); + } + } + } + + return [$post, $files]; + } + /** * {@inheritdoc} */ @@ -129,7 +248,12 @@ public function getServerRequest() $uri = ServerRequestFactory::marshalUriFromServer($server, $headers); $method = ServerRequestFactory::get('REQUEST_METHOD', $server, 'GET'); - $request = new ServerRequest($server, [], $uri, $method, $this->stdin, $headers); + $files = []; + foreach ($this->uploadedFiles as $file) { + $files[] = createUploadedFile($file); + } + + $request = new ServerRequest($server, $files, $uri, $method, $this->stdin, $headers); return $request ->withCookieParams($cookies) @@ -150,6 +274,6 @@ public function getHttpFoundationRequest() $post = $this->getPost(); $cookies = $this->getCookies(); - return new HttpFoundationRequest($query, $post, [], $cookies, [], $this->params, $this->stdin); + return new HttpFoundationRequest($query, $post, [], $cookies, $this->uploadedFiles, $this->params, $this->stdin); } } diff --git a/test/Http/RequestTest.php b/test/Http/RequestTest.php index 1fea209..6f51ab7 100644 --- a/test/Http/RequestTest.php +++ b/test/Http/RequestTest.php @@ -62,4 +62,66 @@ public function testRequest() $this->assertEquals($expectedCookies, $httpFoundationRequest->cookies->all()); $this->assertEquals($content, $httpFoundationRequest->getContent()); } + + public function testMultipartContent() + { + $expectedPost = ['foo' => 'A normal stream', 'baz' => 'string']; + + // Set up FastCGI params and content + $params = [ + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'REQUEST_METHOD' => 'POST', + 'content_type' => 'multipart/form-data; boundary="578de3b0e3c46.2334ba3"', + 'REQUEST_URI' => '/my-page', + ]; + + // Set up the FastCGI stdin data stream resource + $content = <<assertEquals($expectedPost, $request->getPost()); + $this->assertEquals($stream, $request->getStdin()); + + // Check the PSR server request + rewind($stream); + $serverRequest = $request->getServerRequest(); + $this->assertEquals($expectedPost, $serverRequest->getParsedBody()); + $this->assertCount(1, $serverRequest->getUploadedFiles()); + $this->assertEquals($content, $serverRequest->getBody()->__toString()); + + // Check the HttpFoundation request + rewind($stream); + $httpFoundationRequest = $request->getHttpFoundationRequest(); + $this->assertEquals($expectedPost, $httpFoundationRequest->request->all()); + $this->assertCount(1, $httpFoundationRequest->files->all()); + $this->assertEquals($content, $httpFoundationRequest->getContent()); + } }