From 157d9cb3306a1797235934fcfcf411758962ea17 Mon Sep 17 00:00:00 2001 From: kenjis Date: Wed, 15 Feb 2023 13:45:16 +0900 Subject: [PATCH] feat: add SiteURIFactory --- system/HTTP/IncomingRequest.php | 9 + system/HTTP/SiteURIFactory.php | 251 ++++++++++++++++++ .../SiteURIFactoryDetectRoutePathTest.php | 227 ++++++++++++++++ tests/system/HTTP/SiteURIFactoryTest.php | 91 +++++++ 4 files changed, 578 insertions(+) create mode 100644 system/HTTP/SiteURIFactory.php create mode 100644 tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php create mode 100644 tests/system/HTTP/SiteURIFactoryTest.php diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index 008a865bcdcd..caef09335495 100755 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -235,6 +235,8 @@ protected function detectURI(string $protocol, string $baseURL) /** * Detects the relative path based on * the URIProtocol Config setting. + * + * @deprecated Moved to SiteURIFactory. */ public function detectPath(string $protocol = ''): string { @@ -265,6 +267,8 @@ public function detectPath(string $protocol = ''): string * fixing the query string if necessary. * * @return string The URI it found. + * + * @deprecated Moved to SiteURIFactory. */ protected function parseRequestURI(): string { @@ -323,6 +327,8 @@ protected function parseRequestURI(): string * Parse QUERY_STRING * * Will parse QUERY_STRING and automatically detect the URI from it. + * + * @deprecated Moved to SiteURIFactory. */ protected function parseQueryString(): string { @@ -495,6 +501,9 @@ public function setPath(string $path, ?App $config = null) return $this; } + /** + * @deprecated Moved to SiteURIFactory. + */ private function determineHost(App $config, string $baseURL): string { $host = parse_url($baseURL, PHP_URL_HOST); diff --git a/system/HTTP/SiteURIFactory.php b/system/HTTP/SiteURIFactory.php new file mode 100644 index 000000000000..e96cc2781f85 --- /dev/null +++ b/system/HTTP/SiteURIFactory.php @@ -0,0 +1,251 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\HTTP\Exceptions\HTTPException; +use Config\App; + +class SiteURIFactory +{ + /** + * @var array Superglobal SERVER array + */ + private array $server; + + private App $appConfig; + + /** + * @param array $server Superglobal $_SERVER array + */ + public function __construct(array $server, App $appConfig) + { + $this->server = $server; + $this->appConfig = $appConfig; + } + + /** + * Create the current URI object from superglobals. + * + * This method updates superglobal $_SERVER and $_GET. + */ + public function createFromGlobals(): SiteURI + { + $routePath = $this->detectRoutePath(); + + return $this->createURIFromRoutePath($routePath); + } + + /** + * Create the SiteURI object from URI string. + * + * @internal Used for testing purposes only. + */ + public function createFromString(string $uri): SiteURI + { + // Validate URI + if (filter_var($uri, FILTER_VALIDATE_URL) === false) { + throw HTTPException::forUnableToParseURI($uri); + } + + $parts = parse_url($uri); + + if ($parts === false) { + throw HTTPException::forUnableToParseURI($uri); + } + + $query = $fragment = ''; + if (isset($parts['query'])) { + $query = '?' . $parts['query']; + } + if (isset($parts['fragment'])) { + $fragment = '#' . $parts['fragment']; + } + + $relativePath = $parts['path'] . $query . $fragment; + + return new SiteURI($this->appConfig, $relativePath, $parts['host'], $parts['scheme']); + } + + /** + * Detects the current URI path relative to baseURL based on the URIProtocol + * Config setting. + * + * @param string $protocol URIProtocol + * + * @return string The route path + * + * @internal Used for testing purposes only. + */ + public function detectRoutePath(string $protocol = ''): string + { + if ($protocol === '') { + $protocol = $this->appConfig->uriProtocol; + } + + switch ($protocol) { + case 'REQUEST_URI': + $routePath = $this->parseRequestURI(); + break; + + case 'QUERY_STRING': + $routePath = $this->parseQueryString(); + break; + + case 'PATH_INFO': + default: + $routePath = $this->server[$protocol] ?? $this->parseRequestURI(); + break; + } + + return ($routePath === '/' || $routePath === '') ? '/' : ltrim($routePath, '/'); + } + + /** + * Will parse the REQUEST_URI and automatically detect the URI from it, + * fixing the query string if necessary. + * + * This method updates superglobal $_SERVER and $_GET. + * + * @return string The route path (before normalization). + */ + private function parseRequestURI(): string + { + if (! isset($this->server['REQUEST_URI'], $this->server['SCRIPT_NAME'])) { + return ''; + } + + // parse_url() returns false if no host is present, but the path or query + // string contains a colon followed by a number. So we attach a dummy + // host since REQUEST_URI does not include the host. This allows us to + // parse out the query string and path. + $parts = parse_url('http://dummy' . $this->server['REQUEST_URI']); + $query = $parts['query'] ?? ''; + $path = $parts['path'] ?? ''; + + // Strip the SCRIPT_NAME path from the URI + if ( + $path !== '' && isset($this->server['SCRIPT_NAME'][0]) + && pathinfo($this->server['SCRIPT_NAME'], PATHINFO_EXTENSION) === 'php' + ) { + // Compare each segment, dropping them until there is no match + $segments = $keep = explode('/', $path); + + foreach (explode('/', $this->server['SCRIPT_NAME']) as $i => $segment) { + // If these segments are not the same then we're done + if (! isset($segments[$i]) || $segment !== $segments[$i]) { + break; + } + + array_shift($keep); + } + + $path = implode('/', $keep); + } + + // This section ensures that even on servers that require the URI to + // contain the query string (Nginx) a correct URI is found, and also + // fixes the QUERY_STRING Server var and $_GET array. + if (trim($path, '/') === '' && strncmp($query, '/', 1) === 0) { + $parts = explode('?', $query, 2); + $path = $parts[0]; + $newQuery = $query[1] ?? ''; + + $this->server['QUERY_STRING'] = $newQuery; + $this->updateServer('QUERY_STRING', $newQuery); + } else { + $this->server['QUERY_STRING'] = $query; + $this->updateServer('QUERY_STRING', $query); + } + + // Update our global GET for values likely to have been changed + parse_str($this->server['QUERY_STRING'], $get); + $this->updateGetArray($get); + + return URI::removeDotSegments($path); + } + + private function updateServer(string $key, string $value): void + { + $_SERVER[$key] = $value; + } + + private function updateGetArray(array $array): void + { + $_GET = $array; + } + + /** + * Will parse QUERY_STRING and automatically detect the URI from it. + * + * This method updates superglobal $_SERVER and $_GET. + * + * @return string The route path (before normalization). + */ + private function parseQueryString(): string + { + $query = $this->server['QUERY_STRING'] ?? @getenv('QUERY_STRING'); + + if (trim($query, '/') === '') { + return '/'; + } + + if (strncmp($query, '/', 1) === 0) { + $parts = explode('?', $query, 2); + $path = $parts[0]; + $newQuery = $parts[1] ?? ''; + + $this->server['QUERY_STRING'] = $newQuery; + $this->updateServer('QUERY_STRING', $newQuery); + } else { + $path = $query; + } + + // Update our global GET for values likely to have been changed + parse_str($this->server['QUERY_STRING'], $get); + $this->updateGetArray($get); + + return URI::removeDotSegments($path); + } + + /** + * Create current URI object. + * + * @param string $routePath URI path relative to baseURL + */ + private function createURIFromRoutePath(string $routePath): SiteURI + { + $query = $this->server['QUERY_STRING'] ?? ''; + + $relativePath = $query !== '' ? $routePath . '?' . $query : $routePath; + + return new SiteURI($this->appConfig, $relativePath, $this->getHost()); + } + + /** + * @return string|null The current hostname. Returns null if no host header. + */ + private function getHost(): ?string + { + $host = null; + + $httpHostPort = $this->server['HTTP_HOST'] ?? null; + if ($httpHostPort !== null) { + [$httpHost] = explode(':', $httpHostPort, 2); + + if (in_array($httpHost, $this->appConfig->allowedHostnames, true)) { + $host = $httpHost; + } + } + + return $host; + } +} diff --git a/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php new file mode 100644 index 000000000000..3b7715049a5c --- /dev/null +++ b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php @@ -0,0 +1,227 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Test\CIUnitTestCase; +use Config\App; + +/** + * @backupGlobals enabled + * + * @internal + * + * @group Others + */ +final class SiteURIFactoryDetectRoutePathTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $_GET = $_SERVER = []; + } + + private function createSiteURIFactory(array $server, ?App $appConfig = null): SiteURIFactory + { + $appConfig ??= new App(); + + return new SiteURIFactory($server, $appConfig); + } + + public function testDefault() + { + // /index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath()); + } + + public function testDefaultEmpty() + { + // / + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = '/'; + $this->assertSame($expected, $factory->detectRoutePath()); + } + + public function testRequestURI() + { + // /index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURINested() + { + // I'm not sure but this is a case of Apache config making such SERVER + // values? + // The current implementation doesn't use the value of the URI object. + // So I removed the code to set URI. Therefore, it's exactly the same as + // the method above as a test. + // But it may be changed in the future to use the value of the URI object. + // So I don't remove this test case. + + // /ci/index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURISubfolder() + { + // /ci/index.php/popcorn/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/ci/index.php/popcorn/woot'; + $_SERVER['SCRIPT_NAME'] = '/ci/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'popcorn/woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURINoIndex() + { + // /sub/example + $_SERVER['REQUEST_URI'] = '/sub/example'; + $_SERVER['SCRIPT_NAME'] = '/sub/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'example'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURINginx() + { + // /ci/index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot?code=good'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURINginxRedirecting() + { + // /?/ci/index.php/woot + $_SERVER['REQUEST_URI'] = '/?/ci/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'ci/woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURISuppressed() + { + // /woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/woot'; + $_SERVER['SCRIPT_NAME'] = '/'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testQueryString() + { + // /index.php?/ci/woot + $_SERVER['REQUEST_URI'] = '/index.php?/ci/woot'; + $_SERVER['QUERY_STRING'] = '/ci/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $_GET['/ci/woot'] = ''; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'ci/woot'; + $this->assertSame($expected, $factory->detectRoutePath('QUERY_STRING')); + } + + public function testQueryStringWithQueryString() + { + // /index.php?/ci/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php?/ci/woot?code=good'; + $_SERVER['QUERY_STRING'] = '/ci/woot?code=good'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $_GET['/ci/woot?code'] = 'good'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'ci/woot'; + $this->assertSame($expected, $factory->detectRoutePath('QUERY_STRING')); + $this->assertSame('code=good', $_SERVER['QUERY_STRING']); + $this->assertSame(['code' => 'good'], $_GET); + } + + public function testQueryStringEmpty() + { + // /index.php? + $_SERVER['REQUEST_URI'] = '/index.php?'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = '/'; + $this->assertSame($expected, $factory->detectRoutePath('QUERY_STRING')); + } + + public function testPathInfoUnset() + { + // /index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('PATH_INFO')); + } + + public function testPathInfoSubfolder() + { + $appConfig = new App(); + $appConfig->baseURL = 'http://localhost:8888/ci431/public/'; + + // http://localhost:8888/ci431/public/index.php/woot?code=good#pos + $_SERVER['PATH_INFO'] = '/woot'; + $_SERVER['REQUEST_URI'] = '/ci431/public/index.php/woot?code=good'; + $_SERVER['SCRIPT_NAME'] = '/ci431/public/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER, $appConfig); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('PATH_INFO')); + } +} diff --git a/tests/system/HTTP/SiteURIFactoryTest.php b/tests/system/HTTP/SiteURIFactoryTest.php new file mode 100644 index 000000000000..294ae9e9844b --- /dev/null +++ b/tests/system/HTTP/SiteURIFactoryTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Test\CIUnitTestCase; +use Config\App; + +/** + * @backupGlobals enabled + * + * @internal + * + * @group Others + */ +final class SiteURIFactoryTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $_GET = $_SERVER = []; + } + + public function testCreateFromGlobals() + { + // http://localhost:8080/index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot?code=good'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['QUERY_STRING'] = 'code=good'; + $_SERVER['HTTP_HOST'] = 'localhost:8080'; + $_SERVER['PATH_INFO'] = '/woot'; + + $_GET['code'] = 'good'; + + $factory = new SiteURIFactory($_SERVER, new App()); + + $uri = $factory->createFromGlobals(); + + $this->assertInstanceOf(SiteURI::class, $uri); + $this->assertSame('http://localhost:8080/index.php/woot?code=good', (string) $uri); + $this->assertSame('/index.php/woot', $uri->getPath()); + $this->assertSame('woot', $uri->getRoutePath()); + } + + public function testCreateFromGlobalsAllowedHost() + { + // http://users.example.jp/index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot?code=good'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['QUERY_STRING'] = 'code=good'; + $_SERVER['HTTP_HOST'] = 'users.example.jp'; + $_SERVER['PATH_INFO'] = '/woot'; + + $_GET['code'] = 'good'; + + $config = new App(); + $config->baseURL = 'http://example.jp/'; + $config->allowedHostnames = ['users.example.jp']; + + $factory = new SiteURIFactory($_SERVER, $config); + + $uri = $factory->createFromGlobals(); + + $this->assertInstanceOf(SiteURI::class, $uri); + $this->assertSame('http://users.example.jp/index.php/woot?code=good', (string) $uri); + $this->assertSame('/index.php/woot', $uri->getPath()); + $this->assertSame('woot', $uri->getRoutePath()); + } + + public function testCreateFromString() + { + $factory = new SiteURIFactory($_SERVER, new App()); + + $uriString = 'http://invalid.example.jp/foo/bar?page=3'; + $uri = $factory->createFromString($uriString); + + $this->assertInstanceOf(SiteURI::class, $uri); + $this->assertSame('http://localhost:8080/index.php/foo/bar?page=3', (string) $uri); + $this->assertSame('/index.php/foo/bar', $uri->getPath()); + $this->assertSame('foo/bar', $uri->getRoutePath()); + } +}