From 77fe9cdc9926c410c1d1fdc43263530d877962be Mon Sep 17 00:00:00 2001 From: Brandon Antonio Lorenzo Date: Tue, 7 Oct 2025 09:06:36 -0600 Subject: [PATCH 1/5] Basic support for multipart and files --- src/Drivers/LaravelHttpServer.php | 39 +- src/Http/MultipartParser.php | 394 ++++++++++++++++++ src/Http/RequestBodyParser.php | 55 +++ tests/Fixtures/lorem-ipsum.txt | 9 + .../Drivers/Laravel/LaravelHttpServerTest.php | 126 ++++++ 5 files changed, 615 insertions(+), 8 deletions(-) create mode 100644 src/Http/MultipartParser.php create mode 100644 src/Http/RequestBodyParser.php create mode 100644 tests/Fixtures/lorem-ipsum.txt diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 2d668e22..5972b170 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -15,14 +15,18 @@ use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Foundation\Testing\Concerns\WithoutExceptionHandlingHandler; +use Illuminate\Http\Concerns\InteractsWithInput; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Uri; use Pest\Browser\Contracts\HttpServer; use Pest\Browser\Exceptions\ServerNotFoundException; use Pest\Browser\Execution; use Pest\Browser\GlobalState; +use Pest\Browser\Http\RequestBodyParser; use Psr\Log\NullLogger; +use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; use Symfony\Component\Mime\MimeTypes; use Throwable; @@ -48,6 +52,11 @@ final class LaravelHttpServer implements HttpServer */ private ?Throwable $lastThrowable = null; + /** + * A body parser for url-encoded and multipart requests + */ + private RequestBodyParser $requestBodyParser; + /** * Creates a new laravel http server instance. */ @@ -55,7 +64,7 @@ public function __construct( public readonly string $host, public readonly int $port, ) { - // + $this->requestBodyParser = new RequestBodyParser(); } /** @@ -235,20 +244,16 @@ private function handleRequest(AmpRequest $request): Response $kernel = app()->make(HttpKernel::class); - $contentType = $request->getHeader('content-type') ?? ''; $method = mb_strtoupper($request->getMethod()); $rawBody = (string) $request->getBody(); - $parameters = []; - if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'application/x-www-form-urlencoded')) { - parse_str($rawBody, $parameters); - } + [$post, $files] = $this->requestBodyParser->parseForm($request, $rawBody); $symfonyRequest = Request::create( $absoluteUrl, $method, - $parameters, + $post, $request->getCookies(), - [], // @TODO files... + $this->convertUploadedFiles($files), [], // @TODO server variables... $rawBody ); @@ -296,6 +301,24 @@ private function handleRequest(AmpRequest $request): Response ); } + /** + * Taken from Laravel because we can't manipulate the test flag + * + * @param array $files + * @return array + * + * @see InteractsWithInput + */ + private function convertUploadedFiles(array $files): array + { + // @phpstan-ignore-next-line + return array_map(function (array|SymfonyUploadedFile $file) { + return is_array($file) + ? $this->convertUploadedFiles($file) + : UploadedFile::createFromBase($file, true); + }, $files); + } + /** * Return an asset response. */ diff --git a/src/Http/MultipartParser.php b/src/Http/MultipartParser.php new file mode 100644 index 00000000..2b9390cd --- /dev/null +++ b/src/Http/MultipartParser.php @@ -0,0 +1,394 @@ +, "$_FILES": array} + */ + private array $superglobals = ['$_POST' => [], '$_FILES' => []]; + + private ?int $maxFileSize = null; + + /** + * Based on $maxInputVars and $maxFileUploads + */ + private int $maxMultipartBodyParts; + + /** + * ini setting "max_input_vars" + * + * Assume PHP default of 1000 here. + * + * @link http://php.net/manual/en/info.configuration.php#ini.max-input-vars + */ + private int $maxInputVars; + + /** + * ini setting "max_input_nesting_level" + * + * Assume PHP's default of 64 here. + * + * @link http://php.net/manual/en/info.configuration.php#ini.max-input-nesting-level + */ + private int $maxInputNestingLevel; + + /** + * ini setting "upload_max_filesize" + */ + private int|float $uploadMaxFilesize; + + /** + * ini setting "max_file_uploads" + * + * Additionally, setting "file_uploads = off" effectively sets this to zero. + */ + private int $maxFileUploads; + + private int $multipartBodyPartCount = 0; + + private int $postCount = 0; + + private int $filesCount = 0; + + private int $emptyCount = 0; + + private int|false $cursor = 0; + + public function __construct( + ?string $uploadMaxFilesize = null, + ?int $maxFileUploads = null, + ) { + $this->maxInputVars = (int) ini_get('max_input_vars'); + $this->maxInputNestingLevel = (int) ini_get('max_input_nesting_level'); + + if ($uploadMaxFilesize === null) { + $uploadMaxFilesize = (string) ini_get('upload_max_filesize'); + } + + $this->uploadMaxFilesize = self::iniSizeToBytes($uploadMaxFilesize); + $this->maxFileUploads = $maxFileUploads ?? (ini_get('file_uploads') === '' ? 0 : (int) ini_get('max_file_uploads')); + + $this->maxMultipartBodyParts = $this->maxInputVars + $this->maxFileUploads; + } + + /** + * @return array{array, array} + */ + public function parse(AmpRequest $request, string $body): array + { + $contentType = $request->getHeader('content-type') ?? ''; + if (preg_match('/boundary="?(.*?)"?$/', $contentType, $matches) === false) { + return [[], []]; + } + + $this->parseBody('--'.$matches[1], $body); + + $superglobals = $this->superglobals; + $this->superglobals = ['$_POST' => [], '$_FILES' => []]; + $this->multipartBodyPartCount = 0; + $this->cursor = 0; + $this->postCount = 0; + $this->filesCount = 0; + $this->emptyCount = 0; + $this->maxFileSize = null; + + return array_values($superglobals); + } + + /** + * @see https://github.com/reactphp/http/blob/3.x/src/Io/IniUtil.php + */ + private static function iniSizeToBytes(string $size): int|float + { + if (is_numeric($size)) { + return (int) $size; + } + + $suffix = mb_strtoupper(mb_substr($size, -1)); + $strippedSize = mb_substr($size, 0, -1); + + if (! is_numeric($strippedSize)) { + throw new InvalidArgumentException("$size is not a valid ini size"); + } + + if ($strippedSize <= 0) { + throw new InvalidArgumentException("Expect $size to be higher isn't zero or lower"); + } + + if ($suffix === 'K') { + return $strippedSize * 1024; + } + if ($suffix === 'M') { + return $strippedSize * 1024 * 1024; + } + if ($suffix === 'G') { + return $strippedSize * 1024 * 1024 * 1024; + } + if ($suffix === 'T') { + return $strippedSize * 1024 * 1024 * 1024 * 1024; + } + + return (int) $size; + } + + private function parseBody(string $boundary, string $buffer): void + { + $len = mb_strlen($boundary); + + // ignore everything before initial boundary (SHOULD be empty) + $this->cursor = mb_strpos($buffer, $boundary."\r\n"); + + while ($this->cursor !== false) { + // search following boundary (preceded by newline) + // ignore last if not followed by boundary (SHOULD end with "--") + $this->cursor += $len + 2; + $end = mb_strpos($buffer, "\r\n".$boundary, $this->cursor); + if ($end === false) { + break; + } + + // parse one part and continue searching for next + $this->parsePart(mb_substr($buffer, $this->cursor, $end - $this->cursor)); + $this->cursor = $end; + + if (++$this->multipartBodyPartCount > $this->maxMultipartBodyParts) { + break; + } + } + } + + private function parsePart(string $chunk): void + { + $pos = mb_strpos($chunk, "\r\n\r\n"); + if ($pos === false) { + return; + } + + $headers = $this->parseHeaders(mb_substr($chunk, 0, $pos)); + $body = mb_substr($chunk, $pos + 4); + + if (! isset($headers['content-disposition'])) { + return; + } + + $name = $this->getParameterFromHeader($headers['content-disposition'], 'name'); + if ($name === null) { + return; + } + + $filename = $this->getParameterFromHeader($headers['content-disposition'], 'filename'); + if ($filename !== null) { + $this->parseFile( + $name, + $filename, + $headers['content-type'][0] ?? null, + $body + ); + } else { + $this->parsePost($name, $body); + } + } + + private function parseFile(string $name, string $filename, ?string $contentType, string $contents): void + { + $file = $this->parseUploadedFile($filename, $contentType, $contents); + if ($file === null) { + return; + } + + $this->superglobals['$_FILES'] = $this->extractPost( + $this->superglobals['$_FILES'], + $name, + $file, + ); + } + + private function parseUploadedFile(string $filename, ?string $contentType, string $contents): ?UploadedFile + { + $size = mb_strlen($contents); + $tempFileName = tempnam(sys_get_temp_dir(), 'PHP_UPLOAD_FILE_'); + + // no file selected (zero size and empty filename) + if ($size === 0 && $filename === '') { + // ignore excessive number of empty file uploads + if (++$this->emptyCount + $this->filesCount > $this->maxInputVars) { + return null; + } + + return new UploadedFile( + $tempFileName, + $filename, + $contentType, + UPLOAD_ERR_NO_FILE, + true, + ); + } + + // ignore excessive number of file uploads + if (++$this->filesCount > $this->maxFileUploads) { + return null; + } + + // file exceeds "upload_max_filesize" ini setting + if ($size > $this->uploadMaxFilesize) { + return new UploadedFile( + $tempFileName, + $filename, + $contentType, + UPLOAD_ERR_INI_SIZE, + true, + ); + } + + // file exceeds MAX_FILE_SIZE value + if ($this->maxFileSize !== null && $size > $this->maxFileSize) { + return new UploadedFile( + $tempFileName, + $filename, + $contentType, + UPLOAD_ERR_FORM_SIZE, + true, + ); + } + + file_put_contents($tempFileName, $contents); + + return new UploadedFile( + $tempFileName, + $filename, + $contentType, + UPLOAD_ERR_OK, + true, + ); + } + + private function parsePost(string $name, string $value): void + { + // ignore excessive number of post fields + if (++$this->postCount > $this->maxInputVars) { + return; + } + + $this->superglobals['$_POST'] = $this->extractPost( + $this->superglobals['$_POST'], + $name, + $value + ); + + if (mb_strtoupper($name) === 'MAX_FILE_SIZE') { + $this->maxFileSize = (int) $value; + + if ($this->maxFileSize === 0) { + $this->maxFileSize = null; + } + } + } + + /** + * @return array + */ + private function parseHeaders(string $header): array + { + $headers = []; + + foreach (explode("\r\n", mb_trim($header)) as $line) { + $parts = explode(':', $line, 2); + if (! isset($parts[1])) { + continue; + } + + $key = mb_strtolower(mb_trim($parts[0])); + $values = explode(';', $parts[1]); + $values = array_map('trim', $values); + $headers[$key] = $values; + } + + return $headers; + } + + /** + * @param string[] $header + */ + private function getParameterFromHeader(array $header, string $parameter): ?string + { + foreach ($header as $part) { + if (preg_match('/'.$parameter.'="?(.*?)"?$/', $part, $matches) === 1) { + return $matches[1]; + } + } + + return null; + } + + /** + * @template TArray of array + * + * @param TArray $postFields + * @return TArray + */ + private function extractPost(array $postFields, string $key, mixed $value): array + { + $chunks = explode('[', $key); + if (count($chunks) === 1) { + $postFields[$key] = $value; + + /** @phpstan-ignore-next-line I don't know why it changes the value */ + return $postFields; + } + + // ignore this key if maximum nesting level is exceeded + if (isset($chunks[$this->maxInputNestingLevel])) { + return $postFields; + } + + $chunkKey = mb_rtrim($chunks[0], ']'); + $parent = &$postFields; + for ($i = 1; isset($chunks[$i]); $i++) { + $previousChunkKey = $chunkKey; + + if ($previousChunkKey === '') { + /** @phpstan-ignore-next-line Array manipulation over mixed */ + $parent[] = []; + /** @phpstan-ignore-next-line Array manipulation over mixed */ + end($parent); + /** @phpstan-ignore-next-line Array manipulation over mixed */ + $parent = &$parent[key($parent)]; + } else { + /** @phpstan-ignore-next-line Array manipulation over mixed */ + if (! isset($parent[$previousChunkKey]) || ! is_array($parent[$previousChunkKey])) { + /** @phpstan-ignore-next-line Array manipulation over mixed */ + $parent[$previousChunkKey] = []; + } + $parent = &$parent[$previousChunkKey]; + } + + $chunkKey = mb_rtrim($chunks[$i], ']'); + } + + if ($chunkKey === '') { + /** @phpstan-ignore-next-line More array manipulation over mixed */ + $parent[] = $value; + } else { + /** @phpstan-ignore-next-line More array manipulation over mixed */ + $parent[$chunkKey] = $value; + } + + return $postFields; + } +} diff --git a/src/Http/RequestBodyParser.php b/src/Http/RequestBodyParser.php new file mode 100644 index 00000000..bacb5417 --- /dev/null +++ b/src/Http/RequestBodyParser.php @@ -0,0 +1,55 @@ +multipart = new MultipartParser($uploadMaxFilesize, $maxFileUploads); + } + + /** + * @return array{array, array} + */ + public function parseForm(AmpRequest $request, string $body): array + { + $type = mb_strtolower($request->getHeader('Content-Type') ?? ''); + [$type] = explode(';', $type); + + if ($type === 'application/x-www-form-urlencoded') { + return $this->parseFormUrlencoded($body); + } + + if ($type === 'multipart/form-data') { + return $this->multipart->parse($request, $body); + } + + return [[], []]; + } + + /** + * @return array{array, array} + */ + private function parseFormUrlencoded(string $body): array + { + // parse string into array structure + // ignore warnings due to excessive data structures (max_input_vars and max_input_nesting_level) + $ret = []; + @parse_str($body, $ret); + + return [$ret, []]; + } +} diff --git a/tests/Fixtures/lorem-ipsum.txt b/tests/Fixtures/lorem-ipsum.txt new file mode 100644 index 00000000..f1727858 --- /dev/null +++ b/tests/Fixtures/lorem-ipsum.txt @@ -0,0 +1,9 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sit amet bibendum dui. Phasellus viverra justo quam, eu sollicitudin felis commodo eu. Integer at elit commodo, egestas ipsum eu, vestibulum libero. Donec ut metus sed sapien lobortis ultricies sit amet ac quam. Proin vitae dui non tortor laoreet laoreet quis rutrum leo. Ut pharetra nec arcu gravida egestas. Duis eget nisl pharetra, fermentum sapien vel, tempus ligula. Sed fringilla feugiat gravida. Mauris efficitur eu metus non consectetur. Vestibulum faucibus lacus tellus, ut vulputate nisl dapibus nec. Nunc eleifend nisl urna, ut imperdiet velit tristique nec. Nunc vel lacus libero. Praesent et pretium metus. Interdum et malesuada fames ac ante ipsum primis in faucibus. + +In urna tellus, interdum in molestie nec, venenatis ut turpis. Aliquam erat volutpat. Nulla dictum turpis et tellus iaculis egestas. Proin velit neque, posuere sit amet sapien eget, dapibus pharetra velit. Morbi ultricies arcu et orci tempus accumsan. Maecenas ullamcorper, tortor nec finibus iaculis, sapien ipsum varius turpis, sit amet rutrum turpis libero cursus lorem. Nam bibendum lectus odio, ut sollicitudin justo gravida in. Integer aliquam, dolor eu tempor rutrum, enim nisl fringilla eros, a eleifend lectus est in turpis. + +Nulla interdum mi erat, cursus ultricies nisi vulputate non. Cras at neque ex. Sed sodales vehicula dolor, ut dignissim eros convallis et. Vestibulum ornare sagittis eleifend. Mauris fermentum orci est, sed venenatis ex tincidunt id. Morbi ut nisi sagittis, egestas quam in, ornare tellus. Mauris molestie tincidunt lorem ac volutpat. + +Duis fermentum, tellus vitae mollis finibus, diam augue pretium sapien, sed mattis urna lorem a leo. Sed quis enim finibus, finibus dui sit amet, luctus elit. Nullam velit enim, fermentum sit amet sem vitae, elementum volutpat justo. Integer porttitor tellus non dictum vulputate. Donec vulputate eu mi sit amet maximus. Donec egestas metus sit amet ante egestas, in suscipit elit dapibus. Proin magna massa, fringilla placerat facilisis vel, eleifend quis erat. Praesent ultrices lectus ac condimentum consectetur. + +Ut vel venenatis augue, nec luctus neque. Nullam commodo urna a felis tempor posuere. Duis nec nisl eget leo efficitur feugiat. Nulla elementum massa a sapien finibus fermentum. Quisque pretium ligula in sapien auctor, in rhoncus ex eleifend. Sed id bibendum orci, sed ultrices sem. Curabitur sollicitudin quam odio, dignissim lacinia dolor ultrices vel. diff --git a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php index 1b9aa3de..76a37c65 100644 --- a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php +++ b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; + it('rewrites the URLs on JS files', function (): void { @file_put_contents( public_path('app.js'), @@ -15,3 +18,126 @@ $page->assertSee('http://127.0.0.1') ->assertDontSee('http://localhost'); }); + +it('parse a URL-encoded body', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + +
+ + + "); + Route::post('/form', static fn (Request $request): string => " + + + +

Hello {$request->post('name')}

+ + + "); + + $page = visit('/'); + $page->assertSee('Your name'); + + $page->fill('Your name', 'World'); + $page->click('Send'); + + $page->assertSee('Hello World'); +}); + +it('parse a multipart body with files', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + + + + +
+ + + "); + Route::post('/form', static fn (Request $request): string => " + + + +

Hello {$request->post('name')}

+

Uploaded file: {$request->file('file')->getClientOriginalName()}

+ + + "); + + $page = visit('/'); + $page->assertSee('Your name'); + + $page->fill('Your name', 'World'); + $page->attach('Your file', fixture('lorem-ipsum.txt')); + $page->submit(); + + $page->assertSee('Hello World'); + $page->assertSee('Uploaded file: lorem-ipsum.txt'); +}); + +it('parse a multipart body with nested fields', function (): void { + Route::get('/', static fn (): string => " + + + +
+ + + + + + + + + + + + + + + + +
+ + + "); + Route::post('/form', static function (Request $request): string { + $childNames = implode(', ', (array) $request->input('children')); + + return " + + + +

Hello {$request->input('person.first_name')} {$request->input('person.last_name')}

+

and $childNames

+ + + "; + }); + + $page = visit('/'); + + $page->fill('Your first name', 'Jane'); + $page->fill('Your last name', 'Doe'); + $page->fill('Child 2', 'Johnathan'); + $page->fill('Child 3', 'Jamie'); + $page->fill('Child 1', 'John'); + $page->submit(); + + $page->assertSee('Hello Jane Doe') + ->assertSee('and John, Johnathan, Jamie'); +}); From 17ea01fea062206f18d4094a3cd105f137a55ff0 Mon Sep 17 00:00:00 2001 From: Brandon Antonio Lorenzo Date: Tue, 7 Oct 2025 20:38:49 -0600 Subject: [PATCH 2/5] Removed references for mb_* - Removed the multibyte management for the strings, because with empty PDFs and DOCX does not find the boundary string correctly. --- src/Http/MultipartParser.php | 30 +++++++++++++++--------------- src/Http/RequestBodyParser.php | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Http/MultipartParser.php b/src/Http/MultipartParser.php index 2b9390cd..c16c8aaa 100644 --- a/src/Http/MultipartParser.php +++ b/src/Http/MultipartParser.php @@ -120,8 +120,8 @@ private static function iniSizeToBytes(string $size): int|float return (int) $size; } - $suffix = mb_strtoupper(mb_substr($size, -1)); - $strippedSize = mb_substr($size, 0, -1); + $suffix = strtoupper(substr($size, -1)); + $strippedSize = substr($size, 0, -1); if (! is_numeric($strippedSize)) { throw new InvalidArgumentException("$size is not a valid ini size"); @@ -149,22 +149,22 @@ private static function iniSizeToBytes(string $size): int|float private function parseBody(string $boundary, string $buffer): void { - $len = mb_strlen($boundary); + $len = strlen($boundary); // ignore everything before initial boundary (SHOULD be empty) - $this->cursor = mb_strpos($buffer, $boundary."\r\n"); + $this->cursor = strpos($buffer, $boundary."\r\n"); while ($this->cursor !== false) { // search following boundary (preceded by newline) // ignore last if not followed by boundary (SHOULD end with "--") $this->cursor += $len + 2; - $end = mb_strpos($buffer, "\r\n".$boundary, $this->cursor); + $end = strpos($buffer, "\r\n".$boundary, $this->cursor); if ($end === false) { break; } // parse one part and continue searching for next - $this->parsePart(mb_substr($buffer, $this->cursor, $end - $this->cursor)); + $this->parsePart(substr($buffer, $this->cursor, $end - $this->cursor)); $this->cursor = $end; if (++$this->multipartBodyPartCount > $this->maxMultipartBodyParts) { @@ -175,13 +175,13 @@ private function parseBody(string $boundary, string $buffer): void private function parsePart(string $chunk): void { - $pos = mb_strpos($chunk, "\r\n\r\n"); + $pos = strpos($chunk, "\r\n\r\n"); if ($pos === false) { return; } - $headers = $this->parseHeaders(mb_substr($chunk, 0, $pos)); - $body = mb_substr($chunk, $pos + 4); + $headers = $this->parseHeaders(substr($chunk, 0, $pos)); + $body = substr($chunk, $pos + 4); if (! isset($headers['content-disposition'])) { return; @@ -221,7 +221,7 @@ private function parseFile(string $name, string $filename, ?string $contentType, private function parseUploadedFile(string $filename, ?string $contentType, string $contents): ?UploadedFile { - $size = mb_strlen($contents); + $size = strlen($contents); $tempFileName = tempnam(sys_get_temp_dir(), 'PHP_UPLOAD_FILE_'); // no file selected (zero size and empty filename) @@ -291,7 +291,7 @@ private function parsePost(string $name, string $value): void $value ); - if (mb_strtoupper($name) === 'MAX_FILE_SIZE') { + if (strtoupper($name) === 'MAX_FILE_SIZE') { $this->maxFileSize = (int) $value; if ($this->maxFileSize === 0) { @@ -307,13 +307,13 @@ private function parseHeaders(string $header): array { $headers = []; - foreach (explode("\r\n", mb_trim($header)) as $line) { + foreach (explode("\r\n", trim($header)) as $line) { $parts = explode(':', $line, 2); if (! isset($parts[1])) { continue; } - $key = mb_strtolower(mb_trim($parts[0])); + $key = strtolower(trim($parts[0])); $values = explode(';', $parts[1]); $values = array_map('trim', $values); $headers[$key] = $values; @@ -357,7 +357,7 @@ private function extractPost(array $postFields, string $key, mixed $value): arra return $postFields; } - $chunkKey = mb_rtrim($chunks[0], ']'); + $chunkKey = rtrim($chunks[0], ']'); $parent = &$postFields; for ($i = 1; isset($chunks[$i]); $i++) { $previousChunkKey = $chunkKey; @@ -378,7 +378,7 @@ private function extractPost(array $postFields, string $key, mixed $value): arra $parent = &$parent[$previousChunkKey]; } - $chunkKey = mb_rtrim($chunks[$i], ']'); + $chunkKey = rtrim($chunks[$i], ']'); } if ($chunkKey === '') { diff --git a/src/Http/RequestBodyParser.php b/src/Http/RequestBodyParser.php index bacb5417..a5d41d00 100644 --- a/src/Http/RequestBodyParser.php +++ b/src/Http/RequestBodyParser.php @@ -26,7 +26,7 @@ public function __construct(?string $uploadMaxFilesize = null, ?int $maxFileUplo */ public function parseForm(AmpRequest $request, string $body): array { - $type = mb_strtolower($request->getHeader('Content-Type') ?? ''); + $type = strtolower($request->getHeader('Content-Type') ?? ''); [$type] = explode(';', $type); if ($type === 'application/x-www-form-urlencoded') { From f801c333cb5222435129fbe1a5ae0714b37d7a6c Mon Sep 17 00:00:00 2001 From: Brandon Antonio Lorenzo Date: Wed, 8 Oct 2025 12:47:25 -0600 Subject: [PATCH 3/5] Fix paths from file error - Remove the temp name if the file has an error. - Standardized the name with the PHP conventions. --- src/Http/MultipartParser.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Http/MultipartParser.php b/src/Http/MultipartParser.php index c16c8aaa..0684f4b7 100644 --- a/src/Http/MultipartParser.php +++ b/src/Http/MultipartParser.php @@ -222,7 +222,6 @@ private function parseFile(string $name, string $filename, ?string $contentType, private function parseUploadedFile(string $filename, ?string $contentType, string $contents): ?UploadedFile { $size = strlen($contents); - $tempFileName = tempnam(sys_get_temp_dir(), 'PHP_UPLOAD_FILE_'); // no file selected (zero size and empty filename) if ($size === 0 && $filename === '') { @@ -232,7 +231,7 @@ private function parseUploadedFile(string $filename, ?string $contentType, strin } return new UploadedFile( - $tempFileName, + '', $filename, $contentType, UPLOAD_ERR_NO_FILE, @@ -248,7 +247,7 @@ private function parseUploadedFile(string $filename, ?string $contentType, strin // file exceeds "upload_max_filesize" ini setting if ($size > $this->uploadMaxFilesize) { return new UploadedFile( - $tempFileName, + '', $filename, $contentType, UPLOAD_ERR_INI_SIZE, @@ -259,7 +258,7 @@ private function parseUploadedFile(string $filename, ?string $contentType, strin // file exceeds MAX_FILE_SIZE value if ($this->maxFileSize !== null && $size > $this->maxFileSize) { return new UploadedFile( - $tempFileName, + '', $filename, $contentType, UPLOAD_ERR_FORM_SIZE, @@ -267,6 +266,7 @@ private function parseUploadedFile(string $filename, ?string $contentType, strin ); } + $tempFileName = tempnam(sys_get_temp_dir(), 'php'); file_put_contents($tempFileName, $contents); return new UploadedFile( From 3f615c2a6701ff1ecabf547d2ac7920b1189e17d Mon Sep 17 00:00:00 2001 From: Brandon Antonio Lorenzo Date: Wed, 8 Oct 2025 13:19:52 -0600 Subject: [PATCH 4/5] Fix non-selected files - Converted the non-selected files into nulls. --- src/Drivers/LaravelHttpServer.php | 22 +++--- src/Http/MultipartParser.php | 67 +++++++++--------- tests/Fixtures/example.pdf | Bin 0 -> 46753 bytes .../Drivers/Laravel/LaravelHttpServerTest.php | 22 ++++-- 4 files changed, 63 insertions(+), 48 deletions(-) create mode 100644 tests/Fixtures/example.pdf diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 5972b170..81647792 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -15,7 +15,6 @@ use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Foundation\Testing\Concerns\WithoutExceptionHandlingHandler; -use Illuminate\Http\Concerns\InteractsWithInput; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Routing\UrlGenerator; @@ -26,7 +25,6 @@ use Pest\Browser\GlobalState; use Pest\Browser\Http\RequestBodyParser; use Psr\Log\NullLogger; -use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; use Symfony\Component\Mime\MimeTypes; use Throwable; @@ -302,20 +300,22 @@ private function handleRequest(AmpRequest $request): Response } /** - * Taken from Laravel because we can't manipulate the test flag - * - * @param array $files - * @return array - * - * @see InteractsWithInput + * Convert the array to a Laravel file, to keep the test flag. + * If the file is empty, we return the original array, so Laravel + * would convert it to a null. */ + // @phpstan-ignore-next-line private function convertUploadedFiles(array $files): array { // @phpstan-ignore-next-line - return array_map(function (array|SymfonyUploadedFile $file) { - return is_array($file) + return array_map(function (array $file) { + if (isset($file['error']) && $file['error'] === UPLOAD_ERR_NO_FILE) { + return $file; + } + + return array_is_list($file) ? $this->convertUploadedFiles($file) - : UploadedFile::createFromBase($file, true); + : new UploadedFile($file['tmp_name'], $file['name'], $file['type'], $file['error'], true); }, $files); } diff --git a/src/Http/MultipartParser.php b/src/Http/MultipartParser.php index 0684f4b7..206525fe 100644 --- a/src/Http/MultipartParser.php +++ b/src/Http/MultipartParser.php @@ -6,7 +6,6 @@ use Amp\Http\Server\Request as AmpRequest; use InvalidArgumentException; -use Symfony\Component\HttpFoundation\File\UploadedFile; /** * Derived work from the MultipartParser in ReactPHP. @@ -19,7 +18,7 @@ final class MultipartParser { /** - * @var array{"$_POST": array, "$_FILES": array} + * @var array{"$_POST": array, "$_FILES": array} */ private array $superglobals = ['$_POST' => [], '$_FILES' => []]; @@ -88,7 +87,7 @@ public function __construct( } /** - * @return array{array, array} + * @return array{array, array} */ public function parse(AmpRequest $request, string $body): array { @@ -219,8 +218,12 @@ private function parseFile(string $name, string $filename, ?string $contentType, ); } - private function parseUploadedFile(string $filename, ?string $contentType, string $contents): ?UploadedFile + /** + * @return array{tmp_file: string, error: int, name: string, type: string, size: int}|null + */ + private function parseUploadedFile(string $filename, ?string $contentType, string $contents): ?array { + $contentType ??= 'application/octet-stream'; $size = strlen($contents); // no file selected (zero size and empty filename) @@ -230,13 +233,13 @@ private function parseUploadedFile(string $filename, ?string $contentType, strin return null; } - return new UploadedFile( - '', - $filename, - $contentType, - UPLOAD_ERR_NO_FILE, - true, - ); + return [ + 'tmp_name' => '', + 'error' => UPLOAD_ERR_NO_FILE, + 'name' => $filename, + 'type' => $contentType, + 'size' => $size, + ]; } // ignore excessive number of file uploads @@ -246,36 +249,36 @@ private function parseUploadedFile(string $filename, ?string $contentType, strin // file exceeds "upload_max_filesize" ini setting if ($size > $this->uploadMaxFilesize) { - return new UploadedFile( - '', - $filename, - $contentType, - UPLOAD_ERR_INI_SIZE, - true, - ); + return [ + 'tmp_name' => '', + 'error' => UPLOAD_ERR_INI_SIZE, + 'name' => $filename, + 'type' => $contentType, + 'size' => $size, + ]; } // file exceeds MAX_FILE_SIZE value if ($this->maxFileSize !== null && $size > $this->maxFileSize) { - return new UploadedFile( - '', - $filename, - $contentType, - UPLOAD_ERR_FORM_SIZE, - true, - ); + return [ + 'tmp_name' => '', + 'error' => UPLOAD_ERR_FORM_SIZE, + 'name' => $filename, + 'type' => $contentType, + 'size' => $size, + ]; } $tempFileName = tempnam(sys_get_temp_dir(), 'php'); file_put_contents($tempFileName, $contents); - return new UploadedFile( - $tempFileName, - $filename, - $contentType, - UPLOAD_ERR_OK, - true, - ); + return [ + 'tmp_name' => $tempFileName, + 'error' => UPLOAD_ERR_OK, + 'name' => $filename, + 'type' => $contentType, + 'size' => $size, + ]; } private function parsePost(string $name, string $value): void diff --git a/tests/Fixtures/example.pdf b/tests/Fixtures/example.pdf new file mode 100644 index 0000000000000000000000000000000000000000..afa36062bc605220a3d64e10f06abf0d801df772 GIT binary patch literal 46753 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 GhW`P&W6Jsf literal 0 HcmV?d00001 diff --git a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php index 76a37c65..336f9eff 100644 --- a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php +++ b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php @@ -52,6 +52,7 @@ }); it('parse a multipart body with files', function (): void { + Route::get('favicon.ico', static fn (): string => ''); Route::get('/', static fn (): string => " @@ -60,8 +61,14 @@ - - + + + + + + + + @@ -73,7 +80,9 @@

Hello {$request->post('name')}

-

Uploaded file: {$request->file('file')->getClientOriginalName()}

+

Text file: {$request->file('file1')?->getClientOriginalName()}

+

Binary file: {$request->file('file2')?->getClientOriginalName()}

+

Empty file: {$request->file('file3')?->getClientOriginalName()}

"); @@ -82,11 +91,14 @@ $page->assertSee('Your name'); $page->fill('Your name', 'World'); - $page->attach('Your file', fixture('lorem-ipsum.txt')); + $page->attach('Your text file', fixture('lorem-ipsum.txt')); + $page->attach('Your binary file', fixture('example.pdf')); $page->submit(); $page->assertSee('Hello World'); - $page->assertSee('Uploaded file: lorem-ipsum.txt'); + $page->assertSee('Text file: lorem-ipsum.txt'); + $page->assertSee('Binary file: example.pdf'); + $page->assertSee('Empty file: '); }); it('parse a multipart body with nested fields', function (): void { From d1918aabdefb5de2c831e463b57053ab51629409 Mon Sep 17 00:00:00 2001 From: Brandon Antonio Lorenzo Date: Wed, 8 Oct 2025 14:55:05 -0600 Subject: [PATCH 5/5] Fix nested non-sequential file arrays --- src/Drivers/LaravelHttpServer.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 81647792..d907e643 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -309,13 +309,15 @@ private function convertUploadedFiles(array $files): array { // @phpstan-ignore-next-line return array_map(function (array $file) { - if (isset($file['error']) && $file['error'] === UPLOAD_ERR_NO_FILE) { - return $file; + if (isset($file['error'])) { + if ($file['error'] === UPLOAD_ERR_NO_FILE) { + return $file; + } + + return new UploadedFile($file['tmp_name'], $file['name'], $file['type'], $file['error'], true); } - return array_is_list($file) - ? $this->convertUploadedFiles($file) - : new UploadedFile($file['tmp_name'], $file['name'], $file['type'], $file['error'], true); + return $this->convertUploadedFiles($file); }, $files); }