diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index eedd01151a5e..ee671214c058 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -400,7 +400,7 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache $filters->enableFilter($routeFilter, 'after'); } - $uri = $this->request instanceof CLIRequest ? $this->request->getPath() : $this->request->getUri()->getPath(); + $uri = $this->determinePath(); // Never run filters when running through Spark cli if (! defined('SPARKED')) @@ -846,8 +846,7 @@ protected function determinePath() return $this->path; } - // @phpstan-ignore-next-line - return (is_cli() && ! (ENVIRONMENT === 'testing')) ? $this->request->getPath() : $this->request->uri->getPath(); + return method_exists($this->request, 'getPath') ? $this->request->getPath() : $this->request->getUri()->getPath(); } //-------------------------------------------------------------------- diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index 1c64b7568110..560b6523e86c 100755 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -56,12 +56,28 @@ class IncomingRequest extends Request protected $enableCSRF = false; /** - * A \CodeIgniter\HTTP\URI instance. + * The URI for this request. + * + * Note: This WILL NOT match the actual URL in the browser since for + * everything this cares about (and the router, etc) is the portion + * AFTER the script name. So, if hosted in a sub-folder this will + * appear different than actual URL. If you need that use getPath(). * * @var URI */ public $uri; + /** + * The detected path (relative to SCRIPT_NAME). + * + * Note: current_url() uses this to build its URI, + * so this becomes the source for the "current URL" + * when working with the share request instance. + * + * @var string|null + */ + protected $path; + /** * File collection * @@ -142,31 +158,16 @@ public function __construct($config, URI $uri = null, $body = 'php://input', Use $body = file_get_contents('php://input'); } - $this->body = ! empty($body) ? $body : null; - $this->config = $config; - $this->userAgent = $userAgent; + $this->config = $config; + $this->uri = $uri; + $this->body = ! empty($body) ? $body : null; + $this->userAgent = $userAgent; + $this->validLocales = $config->supportedLocales; parent::__construct($config); $this->populateHeaders(); - - // Determine the current URI - // NOTE: This WILL NOT match the actual URL in the browser since for - // everything this cares about (and the router, etc) is the portion - // AFTER the script name. So, if hosted in a sub-folder this will - // appear different than actual URL. If you need that, use current_url(). - $this->uri = $uri; - $this->detectURI($config->uriProtocol, $config->baseURL); - - // Check if the baseURL scheme needs to be coerced into its secure version - if ($config->forceGlobalSecureRequests && $this->uri->getScheme() === 'http') - { - $this->uri->setScheme('https'); - } - - $this->validLocales = $config->supportedLocales; - $this->detectLocale($config); } @@ -190,53 +191,184 @@ public function detectLocale($config) $this->setLocale($this->negotiate('language', $config->supportedLocales)); } - //-------------------------------------------------------------------- + /** + * Sets up our URI object based on the information we have. This is + * either provided by the user in the baseURL Config setting, or + * determined from the environment as needed. + * + * @param string $protocol + * @param string $baseURL + */ + protected function detectURI(string $protocol, string $baseURL) + { + // Passing the config is unnecessary but left for legacy purposes + $config = clone $this->config; + $config->baseURL = $baseURL; + + $this->setPath($this->detectPath($protocol), $config); + } /** - * Returns the default locale as set in Config\App.php + * Detects the relative path based on + * the URIProtocol Config setting. + * + * @param string $protocol * * @return string */ - public function getDefaultLocale(): string + public function detectPath(string $protocol = ''): string { - return $this->defaultLocale; + if (empty($protocol)) + { + $protocol = 'REQUEST_URI'; + } + + switch ($protocol) + { + case 'REQUEST_URI': + $this->path = $this->parseRequestURI(); + break; + case 'QUERY_STRING': + $this->path = $this->parseQueryString(); + break; + case 'PATH_INFO': + default: + $this->path = $this->fetchGlobal('server', $protocol) ?? $this->parseRequestURI(); + break; + } + + return $this->path; } //-------------------------------------------------------------------- /** - * Gets the current locale, with a fallback to the default - * locale if none is set. + * Will parse the REQUEST_URI and automatically detect the URI from it, + * fixing the query string if necessary. + * + * @return string The URI it found. + */ + protected function parseRequestURI(): string + { + if (! isset($_SERVER['REQUEST_URI'], $_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' . $_SERVER['REQUEST_URI']); + $query = $parts['query'] ?? ''; + $uri = $parts['path'] ?? ''; + + // Strip the SCRIPT_NAME path from the URI + if ($uri !== '' && isset($_SERVER['SCRIPT_NAME'][0]) && pathinfo($_SERVER['SCRIPT_NAME'], PATHINFO_EXTENSION) === 'php') + { + // Compare each segment, dropping them until there is no match + $segments = $keep = explode('/', $uri); + foreach (explode('/', $_SERVER['SCRIPT_NAME']) as $i => $segment) + { + // If these segments are not the same then we're done + if ($segment !== $segments[$i]) + { + break; + } + + array_shift($keep); + } + + $uri = 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 getServer var and $_GET array. + if (trim($uri, '/') === '' && strncmp($query, '/', 1) === 0) + { + $query = explode('?', $query, 2); + $uri = $query[0]; + $_SERVER['QUERY_STRING'] = $query[1] ?? ''; + } + else + { + $_SERVER['QUERY_STRING'] = $query; + } + + // Update our globals for values likely to been have changed + parse_str($_SERVER['QUERY_STRING'], $_GET); + $this->populateGlobals('server'); + $this->populateGlobals('get'); + + $uri = URI::removeDotSegments($uri); + + return ($uri === '/' || $uri === '') ? '/' : ltrim($uri, '/'); + } + + /** + * Parse QUERY_STRING + * + * Will parse QUERY_STRING and automatically detect the URI from it. * * @return string */ - public function getLocale(): string + protected function parseQueryString(): string { - return $this->locale ?? $this->defaultLocale; + $uri = $_SERVER['QUERY_STRING'] ?? @getenv('QUERY_STRING'); + + if (trim($uri, '/') === '') + { + return ''; + } + + if (strncmp($uri, '/', 1) === 0) + { + $uri = explode('?', $uri, 2); + $_SERVER['QUERY_STRING'] = $uri[1] ?? ''; + $uri = $uri[0]; + } + + // Update our globals for values likely to been have changed + parse_str($_SERVER['QUERY_STRING'], $_GET); + $this->populateGlobals('server'); + $this->populateGlobals('get'); + + $uri = URI::removeDotSegments($uri); + + return ($uri === '/' || $uri === '') ? '/' : ltrim($uri, '/'); } //-------------------------------------------------------------------- /** - * Sets the locale string for this request. + * Provides a convenient way to work with the Negotiate class + * for content negotiation. * - * @param string $locale + * @param string $type + * @param array $supported + * @param boolean $strictMatch * - * @return IncomingRequest + * @return string */ - public function setLocale(string $locale) + public function negotiate(string $type, array $supported, bool $strictMatch = false): string { - // If it's not a valid locale, set it - // to the default locale for the site. - if (! in_array($locale, $this->validLocales, true)) + if (is_null($this->negotiator)) { - $locale = $this->defaultLocale; + $this->negotiator = Services::negotiator($this, true); } - $this->locale = $locale; - Locale::setDefault($locale); + switch (strtolower($type)) + { + case 'media': + return $this->negotiator->media($supported, $strictMatch); + case 'charset': + return $this->negotiator->charset($supported); + case 'encoding': + return $this->negotiator->encoding($supported); + case 'language': + return $this->negotiator->language($supported); + } - return $this; + throw HTTPException::forInvalidNegotiationType($type); } //-------------------------------------------------------------------- @@ -251,8 +383,6 @@ public function isCLI(): bool return is_cli(); } - //-------------------------------------------------------------------- - /** * Test to see if a request contains the HTTP_X_REQUESTED_WITH header. * @@ -263,8 +393,6 @@ public function isAJAX(): bool return $this->hasHeader('X-Requested-With') && strtolower($this->header('X-Requested-With')->getValue()) === 'xmlhttprequest'; } - //-------------------------------------------------------------------- - /** * Attempts to detect if the current connection is secure through * a few different methods. @@ -287,6 +415,118 @@ public function isSecure(): bool //-------------------------------------------------------------------- + /** + * Sets the relative path and updates the URI object. + * Note: Since current_url() accesses the shared request + * instance, this can be used to change the "current URL" + * for testing. + * + * @param string $path URI path relative to SCRIPT_NAME + * @param App $config Optional alternate config to use + * + * @return $this + */ + public function setPath(string $path, App $config = null) + { + $this->path = $path; + $this->uri->setPath($path); + + $config = $config ?? $this->config; + + // It's possible the user forgot a trailing slash on their + // baseURL, so let's help them out. + $baseURL = $config->baseURL === '' ? $config->baseURL : rtrim($config->baseURL, '/ ') . '/'; + + // Based on our baseURL provided by the developer + // set our current domain name, scheme + if ($baseURL !== '') + { + $this->uri->setScheme(parse_url($baseURL, PHP_URL_SCHEME)); + $this->uri->setHost(parse_url($baseURL, PHP_URL_HOST)); + $this->uri->setPort(parse_url($baseURL, PHP_URL_PORT)); + + // Ensure we have any query vars + $this->uri->setQuery($_SERVER['QUERY_STRING'] ?? ''); + + // Check if the baseURL scheme needs to be coerced into its secure version + if ($config->forceGlobalSecureRequests && $this->uri->getScheme() === 'http') + { + $this->uri->setScheme('https'); + } + } + // @codeCoverageIgnoreStart + elseif (! is_cli()) + { + die('You have an empty or invalid base URL. The baseURL value must be set in Config\App.php, or through the .env file.'); + } + // @codeCoverageIgnoreEnd + + return $this; + } + + /** + * Returns the path relative to SCRIPT_NAME, + * running detection as necessary. + * + * @return string + */ + public function getPath(): string + { + if (is_null($this->path)) + { + $this->detectPath($this->config->uriProtocol); + } + + return $this->path; + } + + //-------------------------------------------------------------------- + + /** + * Sets the locale string for this request. + * + * @param string $locale + * + * @return IncomingRequest + */ + public function setLocale(string $locale) + { + // If it's not a valid locale, set it + // to the default locale for the site. + if (! in_array($locale, $this->validLocales, true)) + { + $locale = $this->defaultLocale; + } + + $this->locale = $locale; + Locale::setDefault($locale); + + return $this; + } + + /** + * Gets the current locale, with a fallback to the default + * locale if none is set. + * + * @return string + */ + public function getLocale(): string + { + return $this->locale ?? $this->defaultLocale; + } + + /** + * Returns the default locale as set in Config\App.php + * + * @return string + */ + public function getDefaultLocale(): string + { + return $this->defaultLocale; + } + + //-------------------------------------------------------------------- + /** * Fetch an item from JSON input stream with fallback to $_REQUEST object. This is the simplest way * to grab data from the request object and can be used in lieu of the @@ -545,6 +785,8 @@ public function getOldInput(string $key) return null; } + //-------------------------------------------------------------------- + /** * Returns an array of all files that have been uploaded with this * request. Each file is represented by an UploadedFile instance. @@ -561,8 +803,6 @@ public function getFiles(): array return $this->files->all(); // return all files } - //-------------------------------------------------------------------- - /** * Verify if a file exist, by the name of the input field used to upload it, in the collection * of uploaded files and if is have been uploaded with multiple option. @@ -581,8 +821,6 @@ public function getFileMultiple(string $fileID) return $this->files->getFileMultiple($fileID); } - //-------------------------------------------------------------------- - /** * Retrieves a single file by the name of the input field used * to upload it. @@ -603,207 +841,6 @@ public function getFile(string $fileID) //-------------------------------------------------------------------- - /** - * Sets up our URI object based on the information we have. This is - * either provided by the user in the baseURL Config setting, or - * determined from the environment as needed. - * - * @param string $protocol - * @param string $baseURL - */ - protected function detectURI(string $protocol, string $baseURL) - { - $this->uri->setPath($this->detectPath($protocol)); - - // It's possible the user forgot a trailing slash on their - // baseURL, so let's help them out. - $baseURL = $baseURL === '' ? $baseURL : rtrim($baseURL, '/ ') . '/'; - - // Based on our baseURL provided by the developer - // set our current domain name, scheme - if ($baseURL !== '') - { - $this->uri->setScheme(parse_url($baseURL, PHP_URL_SCHEME)); - $this->uri->setHost(parse_url($baseURL, PHP_URL_HOST)); - $this->uri->setPort(parse_url($baseURL, PHP_URL_PORT)); - - // Ensure we have any query vars - $this->uri->setQuery($_SERVER['QUERY_STRING'] ?? ''); - } - else - { - // @codeCoverageIgnoreStart - if (! is_cli()) - { - die('You have an empty or invalid base URL. The baseURL value must be set in Config\App.php, or through the .env file.'); - } - // @codeCoverageIgnoreEnd - } - } - - //-------------------------------------------------------------------- - - /** - * Based on the URIProtocol Config setting, will attempt to - * detect the path portion of the current URI. - * - * @param string $protocol - * - * @return string - */ - public function detectPath(string $protocol = ''): string - { - if (empty($protocol)) - { - $protocol = 'REQUEST_URI'; - } - - switch ($protocol) - { - case 'REQUEST_URI': - $path = $this->parseRequestURI(); - break; - case 'QUERY_STRING': - $path = $this->parseQueryString(); - break; - case 'PATH_INFO': - default: - $path = $this->fetchGlobal('server', $protocol) ?? $this->parseRequestURI(); - break; - } - - return $path; - } - - //-------------------------------------------------------------------- - - /** - * Provides a convenient way to work with the Negotiate class - * for content negotiation. - * - * @param string $type - * @param array $supported - * @param boolean $strictMatch - * - * @return string - */ - public function negotiate(string $type, array $supported, bool $strictMatch = false): string - { - if (is_null($this->negotiator)) - { - $this->negotiator = Services::negotiator($this, true); - } - - switch (strtolower($type)) - { - case 'media': - return $this->negotiator->media($supported, $strictMatch); - case 'charset': - return $this->negotiator->charset($supported); - case 'encoding': - return $this->negotiator->encoding($supported); - case 'language': - return $this->negotiator->language($supported); - } - - throw HTTPException::forInvalidNegotiationType($type); - } - - //-------------------------------------------------------------------- - - /** - * Will parse the REQUEST_URI and automatically detect the URI from it, - * fixing the query string if necessary. - * - * @return string The URI it found. - */ - protected function parseRequestURI(): string - { - if (! isset($_SERVER['REQUEST_URI'], $_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' . $_SERVER['REQUEST_URI']); - $query = $parts['query'] ?? ''; - $uri = $parts['path'] ?? ''; - - if (isset($_SERVER['SCRIPT_NAME'][0]) && pathinfo($_SERVER['SCRIPT_NAME'], PATHINFO_EXTENSION) === 'php') - { - // strip the script name from the beginning of the URI - if (strpos($uri, $_SERVER['SCRIPT_NAME']) === 0 && strpos($uri, '/index.php') === 0) - { - $uri = (string) substr($uri, strlen($_SERVER['SCRIPT_NAME'])); - } - // if the script is nested, strip the parent folder & script from the URI - elseif (strpos($uri, $_SERVER['SCRIPT_NAME']) > 0) - { - $uri = (string) substr($uri, strpos($uri, $_SERVER['SCRIPT_NAME']) + strlen($_SERVER['SCRIPT_NAME'])); - } - // or if index.php is implied - elseif (strpos($uri, dirname($_SERVER['SCRIPT_NAME'])) === 0) - { - $uri = (string) substr($uri, strlen(dirname($_SERVER['SCRIPT_NAME']))); - } - } - - // 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 getServer var and $_GET array. - if (trim($uri, '/') === '' && strncmp($query, '/', 1) === 0) - { - $query = explode('?', $query, 2); - $uri = $query[0]; - $_SERVER['QUERY_STRING'] = $query[1] ?? ''; - } - else - { - $_SERVER['QUERY_STRING'] = $query; - } - - parse_str($_SERVER['QUERY_STRING'], $_GET); - - $uri = URI::removeDotSegments($uri); - - return ($uri === '/' || $uri === '') ? '/' : ltrim($uri, '/'); - } - - //-------------------------------------------------------------------- - - /** - * Parse QUERY_STRING - * - * Will parse QUERY_STRING and automatically detect the URI from it. - * - * @return string - */ - protected function parseQueryString(): string - { - $uri = $_SERVER['QUERY_STRING'] ?? @getenv('QUERY_STRING'); - - if (trim($uri, '/') === '') - { - return ''; - } - - if (strncmp($uri, '/', 1) === 0) - { - $uri = explode('?', $uri, 2); - $_SERVER['QUERY_STRING'] = $uri[1] ?? ''; - $uri = $uri[0]; - } - - parse_str($_SERVER['QUERY_STRING'], $_GET); - - $uri = URI::removeDotSegments($uri); - - return ($uri === '/' || $uri === '') ? '/' : ltrim($uri, '/'); - } - - //-------------------------------------------------------------------- - /** * Remove relative directory (../) and multi slashes (///) * diff --git a/system/Test/FeatureTestTrait.php b/system/Test/FeatureTestTrait.php index 5043781a0950..51f5b41f132d 100644 --- a/system/Test/FeatureTestTrait.php +++ b/system/Test/FeatureTestTrait.php @@ -316,12 +316,15 @@ public function options(string $path, array $params = null) */ protected function setupRequest(string $method, string $path = null): IncomingRequest { - $config = config(App::class); - $uri = new URI(rtrim($config->baseURL, '/') . '/' . trim($path, '/ ')); + $path = URI::removeDotSegments($path); + $config = config(App::class); + $request = new IncomingRequest($config, new URI(), null, new UserAgent()); - $request = new IncomingRequest($config, clone($uri), null, new UserAgent()); - $request->uri = $uri; + // $path may have a query in it + $parts = explode('?', $path); + $_SERVER['QUERY_STRING'] = $parts[1] ?? ''; + $request->setPath($parts[0]); $request->setMethod($method); $request->setProtocolVersion('1.1'); diff --git a/tests/system/HTTP/IncomingRequestDetectingTest.php b/tests/system/HTTP/IncomingRequestDetectingTest.php index f9e486f225bd..5e084e69e9a9 100644 --- a/tests/system/HTTP/IncomingRequestDetectingTest.php +++ b/tests/system/HTTP/IncomingRequestDetectingTest.php @@ -67,7 +67,7 @@ public function testPathRequestURISubfolder() { $this->request->uri = '/ci/index.php/popcorn/woot?code=good#pos'; $_SERVER['REQUEST_URI'] = '/ci/index.php/popcorn/woot'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['SCRIPT_NAME'] = '/ci/index.php'; $expected = 'popcorn/woot'; $this->assertEquals($expected, $this->request->detectPath('REQUEST_URI')); } diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index 9798c5e181a9..98f74b44ba27 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -594,7 +594,7 @@ public function providePathChecks() return [ 'not /index.php' => [ '/test.php', - 'test.php', + '/', ], '/index.php' => [ '/index.php', @@ -616,4 +616,71 @@ public function testExtensionPHP($path, $detectPath) $request = new IncomingRequest($config, new URI($path), null, new UserAgent()); $this->assertEquals($detectPath, $request->detectPath()); } + + //-------------------------------------------------------------------- + + public function testGetPath() + { + $_SERVER['REQUEST_URI'] = '/index.php/fruits/banana'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $request = new IncomingRequest(new App(), new URI(), null, new UserAgent()); + + $this->assertEquals('fruits/banana', $request->getPath()); + } + + public function testGetPathIsRelative() + { + $_SERVER['REQUEST_URI'] = '/sub/folder/index.php/fruits/banana'; + $_SERVER['SCRIPT_NAME'] = '/sub/folder/index.php'; + + $request = new IncomingRequest(new App(), new URI(), null, new UserAgent()); + + $this->assertEquals('fruits/banana', $request->getPath()); + } + + public function testGetPathStoresDetectedValue() + { + $_SERVER['REQUEST_URI'] = '/fruits/banana'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $request = new IncomingRequest(new App(), new URI(), null, new UserAgent()); + + $_SERVER['REQUEST_URI'] = '/candy/snickers'; + + $this->assertEquals('fruits/banana', $request->getPath()); + } + + public function testGetPathIsRediscovered() + { + $_SERVER['REQUEST_URI'] = '/fruits/banana'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $request = new IncomingRequest(new App(), new URI(), null, new UserAgent()); + + $_SERVER['REQUEST_URI'] = '/candy/snickers'; + $request->detectPath(); + + $this->assertEquals('candy/snickers', $request->getPath()); + } + + //-------------------------------------------------------------------- + + public function testSetPath() + { + $request = new IncomingRequest(new App(), new URI(), null, new UserAgent()); + $this->assertEquals('', $request->getPath()); + + $request->setPath('foobar'); + $this->assertEquals('foobar', $request->getPath()); + } + + public function testSetPathUpdatesURI() + { + $request = new IncomingRequest(new App(), new URI(), null, new UserAgent()); + + $request->setPath('apples'); + + $this->assertEquals('apples', $request->uri->getPath()); + } } diff --git a/user_guide_src/source/incoming/incomingrequest.rst b/user_guide_src/source/incoming/incomingrequest.rst index 931b04607460..968872c9f218 100644 --- a/user_guide_src/source/incoming/incomingrequest.rst +++ b/user_guide_src/source/incoming/incomingrequest.rst @@ -273,6 +273,20 @@ The object gives you full abilities to grab any part of the request on it's own: echo $uri->getSegment(1); // 'path' echo $uri->getTotalSegments(); // 3 +You can work with the current URI string (the path relative to your baseURL) using the ``getPath()`` and ``setPath()`` methods. +Note that this relative path on the shared instance of ``IncomingRequest`` is what the :doc:`URL Helper ` +functions use, so this is a helpful way to "spoof" an incoming request for testing:: + + class MyMenuTest extends CIUnitTestCase + { + public function testActiveLinkUsesCurrentUrl() + { + service('request')->setPath('users/list'); + $menu = new MyMenu(); + $this->assertTrue('users/list', $menu->getActiveLink()); + } + } + Uploaded Files -------------- @@ -521,3 +535,21 @@ The methods provided by the parent classes that are available are: This method returns the User Agent string from the SERVER data:: $request->getUserAgent(); + + .. php:method:: getPath() + + :returns: The current URI path relative to ``$_SERVER['SCRIPT_NAME']`` + :rtype: string + + This is the safest method to determine the "current URI", since ``IncomingRequest::$uri`` + may not be aware of the complete App configuration for base URLs. + + .. php:method:: setPath($path) + + :param string $path: The relative path to use as the current URI + :returns: This Incoming Request + :rtype: IncomingRequest + + Used mostly just for testing purposes, this allows you to set the relative path + value for the current request instead of relying on URI detection. This will also + update the underlying ``URI`` instance with the new path.