diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ff0f95a486..67765e8f0e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -189,3 +189,70 @@ jobs: with: project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} coverage-reports: build/logs/clover.xml + + test-php-psr7-implementations: + name: Test on PHP (+ psr7-${{ matrix.psr-7-library }}) ${{ matrix.php-version }} and ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + php-version: ['7.3'] + os: [ubuntu-latest] + php-extensions: ['mbstring, iconv, mysqli, zip, bz2'] + psr-7-library: ['guzzlehttp/psr7', 'nyholm/psr7', 'laminas/laminas-diactoros'] + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + # Fetch some commits for Scrutinizer coverage upload + fetch-depth: 15 + + - name: Install gettext + run: sudo apt-get install -y gettext + + - name: Generate mo files + run: ./scripts/generate-mo --quiet + + - name: Set up PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: ${{ matrix.php-extensions }} + coverage: pcov + + - name: Install Composer dependencies + uses: ramsey/composer-install@v2 + with: + dependency-versions: highest + + - name: Remove default PSR-7 implementation + run: composer remove slim/psr7 + + - name: Add PSR-7 implementation + run: composer require ${{ matrix.psr-7-library }} + + - name: Run PHP tests + run: composer run phpunit -- --testsuite unit + + - name: Send coverage + uses: codecov/codecov-action@v3 + with: + flags: ${{ matrix.psr-7-library }}-psr-7 + name: php-7.2-psr-7 + + - name: Send coverage to Scrutinizer + uses: sudo-bot/action-scrutinizer@latest + # Do not run this step on forked versions of the main repository (example: contributor forks) + if: github.repository == 'phpmyadmin/phpmyadmin' + with: + cli-args: "--format=php-clover build/logs/clover.xml --revision=${{ github.event.pull_request.head.sha || github.sha }}" + + - name: Send coverage to Codacy + uses: codacy/codacy-coverage-reporter-action@v1 + # Do not run this step on forked versions of the main repository (example: contributor forks) + if: github.repository == 'phpmyadmin/phpmyadmin' + # Upload can fail on forks or if the secret is missing + continue-on-error: true + with: + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + coverage-reports: build/logs/clover.xml diff --git a/libraries/classes/Http/Factory/ServerRequestFactory.php b/libraries/classes/Http/Factory/ServerRequestFactory.php index c074edc220..360fb56db2 100644 --- a/libraries/classes/Http/Factory/ServerRequestFactory.php +++ b/libraries/classes/Http/Factory/ServerRequestFactory.php @@ -4,42 +4,194 @@ declare(strict_types=1); namespace PhpMyAdmin\Http\Factory; +use GuzzleHttp\Psr7\HttpFactory; +use Laminas\Diactoros\ServerRequestFactory as LaminasServerRequestFactory; +use Laminas\Diactoros\UriFactory as LaminasUriFactory; +use Nyholm\Psr7\Factory\Psr17Factory; use PhpMyAdmin\Http\ServerRequest; use Psr\Http\Message\ServerRequestFactoryInterface; use Psr\Http\Message\ServerRequestInterface; -use Slim\Psr7\Factory\ServerRequestFactory as RequestFactory; +use Psr\Http\Message\UriFactoryInterface; +use Psr\Http\Message\UriInterface; +use Slim\Psr7\Factory\ServerRequestFactory as SlimServerRequestFactory; +use Slim\Psr7\Factory\UriFactory as SlimUriFactory; -class ServerRequestFactory implements ServerRequestFactoryInterface +use function class_exists; +use function count; +use function current; +use function explode; +use function function_exists; +use function getallheaders; +use function in_array; +use function is_numeric; +use function is_string; +use function parse_url; +use function preg_match; +use function strpos; +use function strstr; +use function substr; + +use const PHP_URL_QUERY; + +class ServerRequestFactory { /** @var ServerRequestFactoryInterface */ - private $factory; + private $serverRequestFactory; - public function __construct(?ServerRequestFactoryInterface $factory = null) - { - if ($factory === null) { - $this->factory = new RequestFactory(); + /** @var UriFactoryInterface */ + private $uriFactory; - return; - } - - $this->factory = $factory; + public function __construct( + ?ServerRequestFactoryInterface $serverRequestFactory = null, + ?UriFactoryInterface $uriFactory = null + ) { + $this->serverRequestFactory = $serverRequestFactory ?? $this->createServerRequestFactory(); + $this->uriFactory = $uriFactory ?? $this->createUriFactory(); } - /** - * @inheritDoc - */ - public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface + private function createServerRequestFactory(): ServerRequestFactoryInterface { - $serverRequest = $this->factory->createServerRequest($method, $uri, $serverParams); + if (class_exists(Psr17Factory::class)) { + /** @var ServerRequestFactoryInterface $factory */ + $factory = new Psr17Factory(); + } elseif (class_exists(HttpFactory::class)) { + /** @var ServerRequestFactoryInterface $factory */ + $factory = new HttpFactory(); + } elseif (class_exists(LaminasServerRequestFactory::class)) { + /** @var ServerRequestFactoryInterface $factory */ + $factory = new LaminasServerRequestFactory(); + } else { + $factory = new SlimServerRequestFactory(); + } - return new ServerRequest($serverRequest); + return $factory; + } + + private function createUriFactory(): UriFactoryInterface + { + if (class_exists(Psr17Factory::class)) { + /** @var UriFactoryInterface $factory */ + $factory = new Psr17Factory(); + } elseif (class_exists(HttpFactory::class)) { + /** @var UriFactoryInterface $factory */ + $factory = new HttpFactory(); + } elseif (class_exists(LaminasUriFactory::class)) { + /** @var UriFactoryInterface $factory */ + $factory = new LaminasUriFactory(); + } else { + $factory = new SlimUriFactory(); + } + + return $factory; } public static function createFromGlobals(): ServerRequest { - /** @psalm-suppress InternalMethod */ - $serverRequest = RequestFactory::createFromGlobals(); + if (class_exists(SlimServerRequestFactory::class)) { + /** @psalm-suppress InternalMethod */ + $serverRequest = SlimServerRequestFactory::createFromGlobals(); + } elseif (class_exists(LaminasServerRequestFactory::class)) { + /** @var ServerRequestInterface $serverRequest */ + $serverRequest = LaminasServerRequestFactory::fromGlobals(); + } else { + $serverRequest = self::createServerRequestFromGlobals(); + } return new ServerRequest($serverRequest); } + + private static function createServerRequestFromGlobals(): ServerRequestInterface + { + $creator = new self(); + $serverRequest = $creator->serverRequestFactory->createServerRequest( + $_SERVER['REQUEST_METHOD'] ?? 'GET', + $creator->createUriFromGlobals($_SERVER), + $_SERVER + ); + + /** @var array $headers */ + $headers = function_exists('getallheaders') ? getallheaders() : []; + foreach ($headers as $name => $value) { + $serverRequest = $serverRequest->withAddedHeader($name, $value); + } + + if ($serverRequest->getMethod() !== 'POST') { + return $serverRequest; + } + + $contentType = ''; + foreach ($serverRequest->getHeader('Content-Type') as $headerValue) { + $contentType = current(explode(';', $headerValue)); + } + + if (in_array($contentType, ['application/x-www-form-urlencoded', 'multipart/form-data'], true)) { + return $serverRequest->withParsedBody($_POST); + } + + return $serverRequest; + } + + /** + * Create new Uri from environment. + * + * Initially based on the \Slim\Psr7\Factory\UriFactory::createFromGlobals() implementation. + * + * @param mixed[] $server + */ + private function createUriFromGlobals(array $server): UriInterface + { + $uri = $this->uriFactory->createUri(''); + + $uri = $uri->withScheme(! isset($server['HTTPS']) || $server['HTTPS'] === 'off' ? 'http' : 'https'); + + if (isset($server['PHP_AUTH_USER']) && is_string($server['PHP_AUTH_USER']) && $server['PHP_AUTH_USER'] !== '') { + $uri = $uri->withUserInfo( + $server['PHP_AUTH_USER'], + isset($server['PHP_AUTH_PW']) && is_string($server['PHP_AUTH_PW']) ? $server['PHP_AUTH_PW'] : null + ); + } + + if (isset($server['HTTP_HOST']) && is_string($server['HTTP_HOST'])) { + $uri = $uri->withHost($server['HTTP_HOST']); + } elseif (isset($server['SERVER_NAME']) && is_string($server['SERVER_NAME'])) { + $uri = $uri->withHost($server['SERVER_NAME']); + } + + if (isset($server['SERVER_PORT']) && is_numeric($server['SERVER_PORT']) && $server['SERVER_PORT'] >= 1) { + $uri = $uri->withPort((int) $server['SERVER_PORT']); + } else { + $uri = $uri->withPort($uri->getScheme() === 'https' ? 443 : 80); + } + + if (preg_match('/^(\[[a-fA-F0-9:.]+])(:\d+)?\z/', $uri->getHost(), $matches)) { + $uri = $uri->withHost($matches[1]); + if (isset($matches[2])) { + $uri = $uri->withPort((int) substr($matches[2], 1)); + } + } else { + $pos = strpos($uri->getHost(), ':'); + if ($pos !== false) { + $port = (int) substr($uri->getHost(), $pos + 1); + $host = (string) strstr($uri->getHost(), ':', true); + $uri = $uri->withHost($host)->withPort($port); + } + } + + if (isset($server['QUERY_STRING']) && is_string($server['QUERY_STRING'])) { + $uri = $uri->withQuery($server['QUERY_STRING']); + } + + if (isset($server['REQUEST_URI']) && is_string($server['REQUEST_URI'])) { + $uriFragments = explode('?', $server['REQUEST_URI']); + $uri = $uri->withPath($uriFragments[0]); + if ($uri->getQuery() === '' && count($uriFragments) > 1) { + $query = parse_url('https://www.example.com' . $server['REQUEST_URI'], PHP_URL_QUERY); + if (is_string($query) && $query !== '') { + $uri = $uri->withQuery($query); + } + } + } + + return $uri; + } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8a63de9321..1d5913bbdc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1026,7 +1026,7 @@ parameters: path: libraries/classes/Controllers/Database/CentralColumnsController.php - - message: "#^Parameter \\#2 \\$field_select of method PhpMyAdmin\\\\Database\\\\CentralColumns\\:\\:deleteColumnsFromList\\(\\) expects array, array\\\\|string given\\.$#" + message: "#^Parameter \\#2 \\$field_select of method PhpMyAdmin\\\\Database\\\\CentralColumns\\:\\:deleteColumnsFromList\\(\\) expects array, array\\|string given\\.$#" count: 1 path: libraries/classes/Controllers/Database/CentralColumnsController.php @@ -1846,7 +1846,7 @@ parameters: path: libraries/classes/Controllers/Table/RelationController.php - - message: "#^Argument of an invalid type array\\\\|string supplied for foreach, only iterables are supported\\.$#" + message: "#^Argument of an invalid type array\\|string supplied for foreach, only iterables are supported\\.$#" count: 1 path: libraries/classes/Controllers/Table/ReplaceController.php @@ -4450,11 +4450,6 @@ parameters: count: 2 path: libraries/classes/Html/Generator.php - - - message: "#^Method PhpMyAdmin\\\\Http\\\\Factory\\\\ServerRequestFactory\\:\\:createServerRequest\\(\\) has parameter \\$serverParams with no value type specified in iterable type array\\.$#" - count: 1 - path: libraries/classes/Http/Factory/ServerRequestFactory.php - - message: "#^Method PhpMyAdmin\\\\Http\\\\ServerRequest\\:\\:getAttributes\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 395e0ece06..cea98696dc 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -15060,6 +15060,19 @@ array + + + array + + + new $className() + + + GuzzleHttpFactory + LaminasServerRequestFactory + NyholmPsr17Factory + + assertSame diff --git a/test/classes/Http/Factory/ServerRequestFactoryTest.php b/test/classes/Http/Factory/ServerRequestFactoryTest.php new file mode 100644 index 0000000000..6f7227bbf7 --- /dev/null +++ b/test/classes/Http/Factory/ServerRequestFactoryTest.php @@ -0,0 +1,132 @@ + [ + SlimServerRequestFactory::class, + 'Slim PSR-7', + ], + 'guzzlehttp/psr7' => [ + GuzzleHttpFactory::class, + 'Guzzle PSR-7', + ], + 'nyholm/psr7' => [ + NyholmPsr17Factory::class, + 'Nyholm PSR-7', + ], + 'laminas/laminas-diactoros' => [ + LaminasServerRequestFactory::class, + 'Laminas diactoros PSR-7', + ], + ]; + + public function dataProviderPsr7Implementations(): array + { + return self::IMPLEMENTATION_CLASSES; + } + + /** + * @phpstan-param class-string $className + */ + private function testOrSkip(string $className, string $humanName): void + { + if (! class_exists($className)) { + $this->markTestSkipped($humanName . ' is missing'); + } + + foreach (self::IMPLEMENTATION_CLASSES as $libName => $details) { + /** @phpstan-var class-string */ + $classImpl = $details[0]; + if ($classImpl === $className) { + continue; + } + + if (! class_exists($classImpl)) { + continue; + } + + $this->markTestSkipped($libName . ' exists and will conflict with the test results'); + } + } + + /** + * @phpstan-param class-string $className + * + * @dataProvider dataProviderPsr7Implementations + */ + public function testPsr7Implementation(string $className, string $humanName): void + { + $this->testOrSkip($className, $humanName); + + $_GET['foo'] = 'bar'; + $_SERVER['QUERY_STRING'] = 'foo=bar&blob=baz'; + $_SERVER['REQUEST_URI'] = '/test-page.php'; + $_SERVER['REQUEST_METHOD'] = 'PATCH'; + $_SERVER['HTTP_HOST'] = 'phpmyadmin.local'; + + $request = ServerRequestFactory::createFromGlobals(); + $this->assertSame( + 'PATCH', + $request->getMethod() + ); + $this->assertSame( + 'http://phpmyadmin.local/test-page.php?foo=bar&blob=baz', + $request->getUri()->__toString() + ); + $this->assertFalse( + $request->isPost() + ); + $this->assertSame( + 'default', + $request->getParam('not-exists', 'default') + ); + $this->assertSame( + 'bar', + $request->getParam('foo') + ); + $this->assertSame( + 'baz', + $request->getParam('blob') + ); + $this->assertSame([ + 'foo' => 'bar', + 'blob' => 'baz', + ], $request->getQueryParams()); + } + + /** + * @phpstan-param class-string $className + * + * @dataProvider dataProviderPsr7Implementations + */ + public function testPsr7ImplementationCreateServerRequestFactory(string $className, string $humanName): void + { + $this->testOrSkip($className, $humanName); + + $serverRequestFactory = new $className(); + $this->assertInstanceOf(ServerRequestFactoryInterface::class, $serverRequestFactory); + + $factory = new ServerRequestFactory( + $serverRequestFactory + ); + $this->assertInstanceOf(ServerRequestFactory::class, $factory); + } +}