add multipart parsing for PUT/PATCH/DELETE methods and comprehensive tests

pull/664/head
KnifeLemon 3 weeks ago
parent ca46fd041d
commit 106955a82e

@ -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;
}
}
}

@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace tests;
use flight\net\Request;
use flight\util\Collection;
use PHPUnit\Framework\TestCase;
class RequestBodyParserTest extends TestCase
{
protected function setUp(): void
{
$_SERVER = [];
$_REQUEST = [];
$_GET = [];
$_POST = [];
$_COOKIE = [];
$_FILES = [];
}
protected function tearDown(): void
{
unset($_REQUEST);
unset($_SERVER);
unset($_GET);
unset($_POST);
unset($_COOKIE);
unset($_FILES);
}
private function createRequestConfig(string $method, string $contentType, string $body, &$tmpfile = null): array
{
$tmpfile = tmpfile();
$stream_path = stream_get_meta_data($tmpfile)['uri'];
file_put_contents($stream_path, $body);
return [
'url' => '/',
'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);
}
}
Loading…
Cancel
Save