diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8197dc0..123de8c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,11 +13,15 @@ jobs: strategy: fail-fast: true matrix: - php: [7.2, 7.3, 7.4, 8.0] + php: [7.2, 7.3, 7.4, 8.0, 8.1] laravel: [^6.0, ^7.0, ^8.0] exclude: - php: 7.2 laravel: ^8.0 + - php: 8.1 + laravel: ^6.0 + - php: 8.1 + laravel: ^7.0 name: P${{ matrix.php }} - L${{ matrix.laravel }} @@ -46,7 +50,7 @@ jobs: run: | composer require laravel/octane --dev if: | - matrix.php == 8.0 && matrix.laravel == '^8.0' + matrix.php >= 8.0 && matrix.laravel == '^8.0' - name: Execute unit tests run: vendor/bin/phpunit diff --git a/composer.json b/composer.json index bafae9f..3378439 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,6 @@ "illuminate/support": "^6.0|^7.0|^8.0", "monolog/monolog": "^1.12|^2.0", "nyholm/psr7": "^1.0", - "riverline/multipart-parser": "^2.0", "symfony/process": "^4.3|^5.0", "symfony/psr-http-message-bridge": "^1.0|^2.0" }, diff --git a/src/Runtime/Octane/OctaneRequestContextFactory.php b/src/Runtime/Octane/OctaneRequestContextFactory.php index 8d45387..65fd331 100644 --- a/src/Runtime/Octane/OctaneRequestContextFactory.php +++ b/src/Runtime/Octane/OctaneRequestContextFactory.php @@ -8,9 +8,9 @@ use Laravel\Octane\RequestContext; use Laravel\Vapor\Arr; use Laravel\Vapor\Runtime\Request; +use Laravel\Vapor\Support\Part; use Nyholm\Psr7\ServerRequest; use Nyholm\Psr7\UploadedFile; -use Riverline\MultiPartParser\Part; class OctaneRequestContextFactory { diff --git a/src/Support/Part.php b/src/Support/Part.php new file mode 100644 index 0000000..9b28cb0 --- /dev/null +++ b/src/Support/Part.php @@ -0,0 +1,416 @@ +stream = $stream; + $this->EOLCharacterLength = $EOLCharacterLength; + + rewind($this->stream); + + $endOfHeaders = false; + $bufferSize = 8192; + $headerLines = []; + $buffer = ''; + + while (false !== ($line = fgets($this->stream, $bufferSize))) { + $buffer .= rtrim($line, "\r\n"); + + if (strlen($line) === $bufferSize - 1) { + continue; + } + + if ('' === $buffer) { + $endOfHeaders = true; + break; + } + + $trimmed = ltrim($buffer); + if (strlen($buffer) > strlen($trimmed)) { + $headerLines[count($headerLines) - 1] .= "\x20".$trimmed; + } else { + $headerLines[] = $buffer; + } + + $buffer = ''; + } + + if (false === $endOfHeaders) { + throw new InvalidArgumentException('Content is not valid'); + } + + $this->headers = []; + foreach ($headerLines as $line) { + $lineSplit = explode(':', $line, 2); + + if (2 === count($lineSplit)) { + [$key, $value] = $lineSplit; + + $value = mb_decode_mimeheader(trim($value)); + } else { + $key = $lineSplit[0]; + $value = ''; + } + + $key = strtolower($key); + if (false === key_exists($key, $this->headers)) { + $this->headers[$key] = $value; + } else { + if (false === is_array($this->headers[$key])) { + $this->headers[$key] = (array) $this->headers[$key]; + } + $this->headers[$key][] = $value; + } + } + + $this->bodyOffset = ftell($stream); + + if ($this->isMultiPart()) { + $boundary = self::getHeaderOption($this->getHeader('Content-Type'), 'boundary'); + + if (null === $boundary) { + throw new InvalidArgumentException("Can't find boundary in content type"); + } + + $separator = '--'.$boundary; + + $partOffset = 0; + $endOfBody = false; + while ($line = fgets($this->stream, $bufferSize)) { + $trimmed = rtrim($line, "\r\n"); + + if ($trimmed === $separator || $trimmed === $separator.'--') { + if ($partOffset > 0) { + $currentOffset = ftell($this->stream); + $eofLength = strlen($line) - strlen($trimmed); + $partLength = $currentOffset - $partOffset - strlen($trimmed) - (2 * $eofLength); + + if ($eofLength === 0 && feof($this->stream)) { + $partLength = $currentOffset - $partOffset - strlen($line) - $this->EOLCharacterLength; + } + + $partStream = fopen('php://temp', 'rw'); + stream_copy_to_stream($this->stream, $partStream, $partLength, $partOffset); + $this->parts[] = new static($partStream, $this->EOLCharacterLength); + fseek($this->stream, $currentOffset); + } + + if ($trimmed === $separator.'--') { + $endOfBody = true; + break; + } + + $partOffset = ftell($this->stream); + } + } + + if (0 === count($this->parts) || false === $endOfBody + ) { + throw new LogicException("Can't find multi-part content"); + } + } + } + + /** + * @return bool + */ + public function isMultiPart() + { + return 'multipart' === mb_strstr( + self::getHeaderValue($this->getHeader('Content-Type')), + '/', + true + ); + } + + /** + * @return string + * + * @throws \LogicException if is multipart + */ + public function getBody() + { + if ($this->isMultiPart()) { + throw new LogicException("MultiPart content, there aren't body"); + } + + $body = stream_get_contents($this->stream, -1, $this->bodyOffset); + + $encoding = strtolower((string) $this->getHeader('Content-Transfer-Encoding')); + switch ($encoding) { + case 'base64': + $body = base64_decode($body); + break; + case 'quoted-printable': + $body = quoted_printable_decode($body); + break; + } + + if (false === in_array($encoding, ['binary', '7bit'])) { + $contentType = $this->getHeader('Content-Type'); + $charset = self::getHeaderOption($contentType, 'charset'); + if (null === $charset) { + $charset = mb_detect_encoding($body) ?: 'utf-8'; + } + + if ('utf-8' !== strtolower($charset)) { + $body = mb_convert_encoding($body, 'utf-8', $charset); + } + } + + return $body; + } + + /** + * @return array + */ + public function getHeaders() + { + return $this->headers; + } + + /** + * @param string $key + * @param mixed $default + * @return mixed + */ + public function getHeader($key, $default = null) + { + $key = strtolower($key); + + if (false === isset($this->headers[$key])) { + return $default; + } + + return $this->headers[$key]; + } + + /** + * @param string $header + * @return string + */ + public static function getHeaderValue($header) + { + [$value] = self::parseHeaderContent($header); + + return $value; + } + + /** + * @param string $header + * @return array + */ + public static function getHeaderOptions($header) + { + [, $options] = self::parseHeaderContent($header); + + return $options; + } + + /** + * @param string $header + * @param string $key + * @param mixed $default + * @return mixed + */ + public static function getHeaderOption($header, $key, $default = null) + { + $options = self::getHeaderOptions($header); + + if (false === isset($options[$key])) { + return $default; + } + + return $options[$key]; + } + + /** + * @return string + */ + public function getMimeType() + { + $contentType = $this->getHeader('Content-Type'); + + return self::getHeaderValue($contentType) ?: 'application/octet-stream'; + } + + /** + * @return string|null + */ + public function getName() + { + $contentDisposition = $this->getHeader('Content-Disposition'); + + return self::getHeaderOption($contentDisposition, 'name'); + } + + /** + * @return string|null + */ + public function getFileName() + { + // Find Content-Disposition + $contentDisposition = $this->getHeader('Content-Disposition'); + + return self::getHeaderOption($contentDisposition, 'filename'); + } + + /** + * Checks if the part is a file. + * + * @return bool + */ + public function isFile() + { + return false === is_null($this->getFileName()); + } + + /** + * @return array + * + * @throws \LogicException if is not multipart + */ + public function getParts() + { + if (false === $this->isMultiPart()) { + throw new LogicException("Not MultiPart content, there aren't any parts"); + } + + return $this->parts; + } + + /** + * @param string $name + * @return array + * + * @throws \LogicException if is not multipart + */ + public function getPartsByName($name) + { + $parts = []; + + foreach ($this->getParts() as $part) { + if ($part->getName() === $name) { + $parts[] = $part; + } + } + + return $parts; + } + + /** + * @param string $content + * @return array + */ + protected static function parseHeaderContent($content) + { + $parts = explode(';', (string) $content); + $headerValue = array_shift($parts); + $options = []; + + foreach ($parts as $part) { + if (false === empty($part)) { + $partSplit = explode('=', $part, 2); + if (2 === count($partSplit)) { + [$key, $value] = $partSplit; + if ('*' === substr($key, -1)) { + $key = substr($key, 0, -1); + if (preg_match( + "/(?P[\w!#$%&+^_`{}~-]+)'(?P[\w-]*)'(?P.*)$/", + $value, + $matches + )) { + $value = mb_convert_encoding( + rawurldecode($matches['value']), + 'utf-8', + $matches['charset'] + ); + } + } + $options[trim($key)] = trim($value, ' "'); + } else { + $options[$partSplit[0]] = ''; + } + } + } + + return [$headerValue, $options]; + } +} diff --git a/tests/Feature/LoadBalancedOctaneHandlerTest.php b/tests/Feature/LoadBalancedOctaneHandlerTest.php index 4746d83..b50deff 100644 --- a/tests/Feature/LoadBalancedOctaneHandlerTest.php +++ b/tests/Feature/LoadBalancedOctaneHandlerTest.php @@ -2,7 +2,7 @@ namespace Laravel\Vapor\Tests\Feature; -if (\PHP_VERSION_ID < 80000) { +if (! interface_exists(\Laravel\Octane\Contracts\Client::class)) { return; } diff --git a/tests/Feature/OctaneHandlerTest.php b/tests/Feature/OctaneHandlerTest.php index 757d184..78c51e7 100644 --- a/tests/Feature/OctaneHandlerTest.php +++ b/tests/Feature/OctaneHandlerTest.php @@ -2,7 +2,7 @@ namespace Laravel\Vapor\Tests\Feature; -if (\PHP_VERSION_ID < 80000) { +if (! interface_exists(\Laravel\Octane\Contracts\Client::class)) { return; } diff --git a/tests/Feature/OctaneTest.php b/tests/Feature/OctaneTest.php index 9892910..afdf023 100644 --- a/tests/Feature/OctaneTest.php +++ b/tests/Feature/OctaneTest.php @@ -2,7 +2,7 @@ namespace Laravel\Vapor\Tests\Feature; -if (\PHP_VERSION_ID < 80000) { +if (! interface_exists(\Laravel\Octane\Contracts\Client::class)) { return; }