diff --git a/flight/net/Request.php b/flight/net/Request.php index 829356f..5d2b283 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -233,14 +233,9 @@ class Request $this->data->setData($data); } } - // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data - } elseif (in_array($this->method, ['PUT', 'DELETE', 'PATCH'], true) === true) { - $body = $this->getBody(); - if ($body !== '') { - $data = []; - parse_str($body, $data); - $this->data->setData($data); - } + // Check PUT, PATCH, DELETE for application/x-www-form-urlencoded data or multipart/form-data + } elseif (in_array($this->method, [ 'PUT', 'DELETE', 'PATCH' ], true) === true) { + $this->parseRequestBodyForHttpMethods(); } return $this; @@ -533,4 +528,204 @@ class Request return $fileArray; } + + /** + * Parse request body data for HTTP methods that don't natively support form data (PUT, DELETE, PATCH) + * @return void + */ + protected function parseRequestBodyForHttpMethods(): void { + $body = $this->getBody(); + + // Empty body + if ($body === '') { + return; + } + + // Check Content-Type for multipart/form-data + $contentType = strtolower(trim($this->type)); + $isMultipart = strpos($contentType, 'multipart/form-data') === 0; + $boundary = null; + + if ($isMultipart) { + // Extract boundary more safely + if (preg_match('/boundary=(["\']?)([^"\';,\s]+)\1/i', $this->type, $matches)) { + $boundary = $matches[2]; + } + + // If no boundary found, it's not valid multipart + if (empty($boundary)) { + $isMultipart = false; + } + } + + $data = []; + $file = []; + + // Parse application/x-www-form-urlencoded + if ($isMultipart === false) { + parse_str($body, $data); + $this->data->setData($data); + + return; + } + + // Parse multipart/form-data + $bodyParts = preg_split('/\\R?-+' . preg_quote($boundary, '/') . '/s', $body); + array_pop($bodyParts); // Remove last element (empty) + + foreach ($bodyParts as $bodyPart) { + if (empty($bodyPart)) { + continue; + } + + // Get the headers and value + [$header, $value] = preg_split('/\\R\\R/', $bodyPart, 2); + + // Check if the header is normal + if (strpos(strtolower($header), 'content-disposition') === false) { + continue; + } + + $value = ltrim($value, "\r\n"); + + /** + * Process Header + */ + $headers = []; + // split the headers + $headerParts = preg_split('/\\R/', $header); + foreach ($headerParts as $headerPart) { + if (strpos($headerPart, ':') === false) { + continue; + } + + // Process the header + [$headerKey, $headerValue] = explode(':', $headerPart, 2); + + $headerKey = strtolower(trim($headerKey)); + $headerValue = trim($headerValue); + + if (strpos($headerValue, ';') !== false) { + $headers[$headerKey] = []; + foreach (explode(';', $headerValue) as $headerValuePart) { + preg_match_all('/(\w+)=\"?([^";]+)\"?/', $headerValuePart, $headerMatches, PREG_SET_ORDER); + + foreach ($headerMatches as $headerMatch) { + $headerSubKey = strtolower($headerMatch[1]); + $headerSubValue = $headerMatch[2]; + + $headers[$headerKey][$headerSubKey] = $headerSubValue; + } + } + } else { + $headers[$headerKey] = $headerValue; + } + } + + /** + * Process Value + */ + if (!isset($headers['content-disposition']) || !isset($headers['content-disposition']['name'])) { + continue; + } + + $keyName = str_replace("[]", "", $headers['content-disposition']['name']); + + // if is not file + if (!isset($headers['content-disposition']['filename'])) { + if (isset($data[$keyName])) { + if (!is_array($data[$keyName])) { + $data[$keyName] = [$data[$keyName]]; + } + $data[$keyName][] = $value; + } + else { + $data[$keyName] = $value; + } + continue; + } + + $tmpFile = [ + 'name' => $headers['content-disposition']['filename'], + 'type' => $headers['content-type'] ?? 'application/octet-stream', + 'size' => mb_strlen($value, '8bit'), + 'tmp_name' => null, + 'error' => UPLOAD_ERR_OK, + ]; + + if ($tmpFile['size'] > $this->getUploadMaxFileSize()) { + $tmpFile['error'] = UPLOAD_ERR_INI_SIZE; + } else { + // Create a temporary file + $tmpName = tempnam(sys_get_temp_dir(), 'flight_tmp_'); + if ($tmpName === false) { + $tmpFile['error'] = UPLOAD_ERR_CANT_WRITE; + } else { + // Write the value to a temporary file + $bytes = file_put_contents($tmpName, $value); + + if ($bytes === false) { + $tmpFile['error'] = UPLOAD_ERR_CANT_WRITE; + } + else { + // delete the temporary file before ended script + register_shutdown_function(function () use ($tmpName): void { + if (file_exists($tmpName)) { + unlink($tmpName); + } + }); + + $tmpFile['tmp_name'] = $tmpName; + } + } + } + + foreach ($tmpFile as $key => $value) { + if (isset($file[$keyName][$key])) { + if (!is_array($file[$keyName][$key])) { + $file[$keyName][$key] = [$file[$keyName][$key]]; + } + $file[$keyName][$key][] = $value; + } else { + $file[$keyName][$key] = $value; + } + } + } + + $this->data->setData($data); + $this->files->setData($file); + } + + /** + * Get the maximum file size that can be uploaded. + * @return int The maximum file size in bytes. + */ + protected function getUploadMaxFileSize() { + $value = ini_get('upload_max_filesize'); + + $unit = strtolower(preg_replace('/[^a-zA-Z]/', '', $value)); + $value = preg_replace('/[^\d.]/', '', $value); + + switch ($unit) { + case 'p': // PentaByte + case 'pb': + $value *= 1024; + case 't': // Terabyte + case 'tb': + $value *= 1024; + case 'g': // Gigabyte + case 'gb': + $value *= 1024; + case 'm': // Megabyte + case 'mb': + $value *= 1024; + case 'k': // Kilobyte + case 'kb': + $value *= 1024; + case 'b': // Byte + return $value *= 1; + default: + return 0; + } + } } diff --git a/tests/RequestBodyParserTest.php b/tests/RequestBodyParserTest.php new file mode 100644 index 0000000..6b7ede3 --- /dev/null +++ b/tests/RequestBodyParserTest.php @@ -0,0 +1,286 @@ + '/', + 'base' => '/', + 'method' => $method, + 'referrer' => '', + 'ip' => '127.0.0.1', + 'ajax' => false, + 'scheme' => 'http', + 'user_agent' => 'Test', + 'type' => $contentType, + 'length' => strlen($body), + 'secure' => false, + 'accept' => '', + 'proxy_ip' => '', + 'host' => 'localhost', + 'servername' => 'localhost', + 'stream_path' => $stream_path, + 'data' => new Collection(), + 'query' => new Collection(), + 'cookies' => new Collection(), + 'files' => new Collection() + ]; + } + + private function assertUrlEncodedParsing(string $method): void + { + $body = 'foo=bar&baz=qux&key=value'; + $tmpfile = tmpfile(); + $stream_path = stream_get_meta_data($tmpfile)['uri']; + file_put_contents($stream_path, $body); + + $config = [ + 'url' => '/', + 'base' => '/', + 'method' => $method, + 'referrer' => '', + 'ip' => '127.0.0.1', + 'ajax' => false, + 'scheme' => 'http', + 'user_agent' => 'Test', + 'type' => 'application/x-www-form-urlencoded', + 'length' => strlen($body), + 'secure' => false, + 'accept' => '', + 'proxy_ip' => '', + 'host' => 'localhost', + 'servername' => 'localhost', + 'stream_path' => $stream_path, + 'data' => new Collection(), + 'query' => new Collection(), + 'cookies' => new Collection(), + 'files' => new Collection() + ]; + + $request = new Request($config); + + $expectedData = [ + 'foo' => 'bar', + 'baz' => 'qux', + 'key' => 'value' + ]; + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + private function createMultipartBody(string $boundary, array $fields, array $files = []): string + { + $body = ''; + + // Add form fields + foreach ($fields as $name => $value) { + if (is_array($value)) { + foreach ($value as $item) { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n"; + $body .= "\r\n"; + $body .= "{$item}\r\n"; + } + } else { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n"; + $body .= "\r\n"; + $body .= "{$value}\r\n"; + } + } + + // Add files + foreach ($files as $name => $file) { + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$name}\"; filename=\"{$file['filename']}\"\r\n"; + $body .= "Content-Type: {$file['type']}\r\n"; + $body .= "\r\n"; + $body .= "{$file['content']}\r\n"; + } + + $body .= "--{$boundary}--\r\n"; + + return $body; + } + + public function testParseUrlEncodedBodyForPutMethod(): void + { + $this->assertUrlEncodedParsing('PUT'); + } + + public function testParseUrlEncodedBodyForPatchMethod(): void + { + $this->assertUrlEncodedParsing('PATCH'); + } + + public function testParseUrlEncodedBodyForDeleteMethod(): void + { + $this->assertUrlEncodedParsing('DELETE'); + } + + public function testParseMultipartFormDataWithFiles(): void + { + $boundary = 'boundary123456789'; + $fields = ['title' => 'Test Document']; + $files = [ + 'file' => [ + 'filename' => 'file.txt', + 'type' => 'text/plain', + 'content' => 'This is test file content' + ] + ]; + + $body = $this->createMultipartBody($boundary, $fields, $files); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals(['title' => 'Test Document'], $request->data->getData()); + + $file = $request->getUploadedFiles()['file']; + $this->assertEquals('file.txt', $file->getClientFilename()); + $this->assertEquals('text/plain', $file->getClientMediaType()); + $this->assertEquals(strlen('This is test file content'), $file->getSize()); + $this->assertEquals(UPLOAD_ERR_OK, $file->getError()); + $this->assertNotNull($file->getTempName()); + + fclose($tmpfile); + } + + public function testParseMultipartFormDataWithQuotedBoundary(): void + { + $boundary = 'boundary123456789'; + $fields = ['foo' => 'bar']; + + $body = $this->createMultipartBody($boundary, $fields); + $config = $this->createRequestConfig('PATCH', "multipart/form-data; boundary=\"{$boundary}\"", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals($fields, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseMultipartFormDataWithArrayFields(): void + { + $boundary = 'boundary123456789'; + $fields = ['name[]' => ['foo', 'bar']]; + $expectedData = ['name' => ['foo', 'bar']]; + + $body = $this->createMultipartBody($boundary, $fields); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseEmptyBody(): void + { + $config = $this->createRequestConfig('PUT', 'application/x-www-form-urlencoded', '', $tmpfile); + $request = new Request($config); + + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseInvalidMultipartWithoutBoundary(): void + { + $originalData = ['foo foo' => 'bar bar', 'baz baz' => 'qux']; + $body = http_build_query($originalData); + $expectedData = ['foo_foo' => 'bar bar', 'baz_baz' => 'qux']; + + $config = $this->createRequestConfig('PUT', 'multipart/form-data', $body, $tmpfile); // no boundary + $request = new Request($config); + + // should fall back to URL encoding and parse correctly + $this->assertEquals($expectedData, $request->data->getData()); + + fclose($tmpfile); + } + + public function testParseMultipartWithLargeFile(): void + { + $boundary = 'boundary123456789'; + $largeContent = str_repeat('A', 10000); // 10KB content + $files = [ + 'file' => [ + 'filename' => 'large.txt', + 'type' => 'text/plain', + 'content' => $largeContent + ] + ]; + + $body = $this->createMultipartBody($boundary, [], $files); + $config = $this->createRequestConfig('PUT', "multipart/form-data; boundary={$boundary}", $body, $tmpfile); + $request = new Request($config); + + $file = $request->getUploadedFiles()['file']; + $this->assertArrayHasKey('file', $request->getUploadedFiles()); + $this->assertEquals('large.txt', $file->getClientFilename()); + $this->assertEquals(10000, $file->getSize()); + $this->assertEquals(UPLOAD_ERR_OK, $file->getError()); + $this->assertNotNull($file->getTempName()); + + fclose($tmpfile); + } + + public function testGetMethodDoesNotTriggerParsing(): void + { + $body = 'foo=bar&baz=qux&key=value'; + $config = $this->createRequestConfig('GET', 'application/x-www-form-urlencoded', $body, $tmpfile); + $request = new Request($config); + + // GET method should not trigger parsing + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } + + public function testPostMethodDoesNotTriggerParsing(): void + { + $body = 'foo=bar&baz=qux&key=value'; + $config = $this->createRequestConfig('POST', 'application/x-www-form-urlencoded', $body, $tmpfile); + $request = new Request($config); + + // POST method should not trigger this parsing (uses $_POST instead) + $this->assertEquals([], $request->data->getData()); + + fclose($tmpfile); + } +} \ No newline at end of file