diff --git a/app/Config/Feature.php b/app/Config/Feature.php index af42534ac423..4c5ec90cd3ff 100644 --- a/app/Config/Feature.php +++ b/app/Config/Feature.php @@ -10,7 +10,7 @@ class Feature extends BaseConfig { /** - * Enable multiple filters for a route or not + * Enable multiple filters for a route or not. * * If you enable this: * - CodeIgniter\CodeIgniter::handleRequest() uses: @@ -24,4 +24,9 @@ class Feature extends BaseConfig * @var bool */ public $multipleFilters = false; + + /** + * Use improved new auto routing instead of the default legacy version. + */ + public bool $autoRoutesImproved = false; } diff --git a/phpstan-baseline.neon.dist b/phpstan-baseline.neon.dist index 709daad82cdd..4b3938eccbae 100644 --- a/phpstan-baseline.neon.dist +++ b/phpstan-baseline.neon.dist @@ -677,7 +677,7 @@ parameters: - message: "#^Call to an undefined method CodeIgniter\\\\Router\\\\RouteCollectionInterface\\:\\:getDefaultNamespace\\(\\)\\.$#" - count: 2 + count: 3 path: system/Router/Router.php - @@ -706,8 +706,8 @@ parameters: path: system/Router/Router.php - - message: "#^Call to an undefined method CodeIgniter\\\\Router\\\\RouteCollectionInterface\\:\\:getRegisteredControllers\\(\\)\\.$#" - count: 1 + message: "#^Call to an undefined method CodeIgniter\\\\Router\\\\RouteCollectionInterface\\:\\:getRegisteredControllers\\(.*\\)\\.$#" + count: 2 path: system/Router/Router.php - diff --git a/system/Router/AutoRouter.php b/system/Router/AutoRouter.php index bd55d7fae39c..3c29ff8290d4 100644 --- a/system/Router/AutoRouter.php +++ b/system/Router/AutoRouter.php @@ -16,44 +16,46 @@ /** * Router for Auto-Routing */ -class AutoRouter +final class AutoRouter implements AutoRouterInterface { /** * List of controllers registered for the CLI verb that should not be accessed in the web. + * + * @var class-string[] */ - protected array $protectedControllers; + private array $protectedControllers; /** * Sub-directory that contains the requested controller class. * Primarily used by 'autoRoute'. */ - protected ?string $directory = null; + private ?string $directory = null; /** * The name of the controller class. */ - protected string $controller; + private string $controller; /** * The name of the method to use. */ - protected string $method; + private string $method; /** * Whether dashes in URI's should be converted * to underscores when determining method names. */ - protected bool $translateURIDashes; + private bool $translateURIDashes; /** * HTTP verb for the request. */ - protected string $httpVerb; + private string $httpVerb; /** * Default namespace for controllers. */ - protected string $defaultNamespace; + private string $defaultNamespace; public function __construct( array $protectedControllers, @@ -164,6 +166,8 @@ public function getRoute(string $uri): array /** * Tells the system whether we should translate URI dashes or not * in the URI from a dash to an underscore. + * + * @deprecated This method should be removed. */ public function setTranslateURIDashes(bool $val = false): self { @@ -179,7 +183,7 @@ public function setTranslateURIDashes(bool $val = false): self * * @return array returns an array of remaining uri segments that don't map onto a directory */ - protected function scanControllers(array $segments): array + private function scanControllers(array $segments): array { $segments = array_filter($segments, static fn ($segment) => $segment !== ''); // numerically reindex the array, removing gaps @@ -234,6 +238,8 @@ private function isValidSegment(string $segment): bool * Sets the sub-directory that the controller is in. * * @param bool $validate if true, checks to make sure $dir consists of only PSR4 compliant segments + * + * @deprecated This method should be removed. */ public function setDirectory(?string $dir = null, bool $append = false, bool $validate = true) { @@ -263,6 +269,8 @@ public function setDirectory(?string $dir = null, bool $append = false, bool $va /** * Returns the name of the sub-directory the controller is in, * if any. Relative to APPPATH.'Controllers'. + * + * @deprecated This method should be removed. */ public function directory(): string { diff --git a/system/Router/AutoRouterImproved.php b/system/Router/AutoRouterImproved.php new file mode 100644 index 000000000000..7b03e705a1df --- /dev/null +++ b/system/Router/AutoRouterImproved.php @@ -0,0 +1,324 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router; + +use CodeIgniter\Exceptions\PageNotFoundException; +use ReflectionClass; +use ReflectionException; + +/** + * New Secure Router for Auto-Routing + */ +final class AutoRouterImproved implements AutoRouterInterface +{ + /** + * List of controllers in Defined Routes that should not be accessed via this Auto-Routing. + * + * @var class-string[] + */ + private array $protectedControllers; + + /** + * Sub-directory that contains the requested controller class. + */ + private ?string $directory = null; + + /** + * Sub-namespace that contains the requested controller class. + */ + private ?string $subNamespace = null; + + /** + * The name of the controller class. + */ + private string $controller; + + /** + * The name of the method to use. + */ + private string $method; + + /** + * An array of params to the controller method. + */ + private array $params = []; + + /** + * Whether dashes in URI's should be converted + * to underscores when determining method names. + */ + private bool $translateURIDashes; + + /** + * HTTP verb for the request. + */ + private string $httpVerb; + + /** + * The namespace for controllers. + */ + private string $namespace; + + /** + * The name of the default controller class. + */ + private string $defaultController; + + /** + * The name of the default method + */ + private string $defaultMethod; + + /** + * @param class-string[] $protectedControllers + * @param string $defaultController Short classname + */ + public function __construct( + array $protectedControllers, + string $namespace, + string $defaultController, + string $defaultMethod, + bool $translateURIDashes, + string $httpVerb + ) { + $this->protectedControllers = $protectedControllers; + $this->namespace = rtrim($namespace, '\\') . '\\'; + $this->translateURIDashes = $translateURIDashes; + $this->httpVerb = $httpVerb; + $this->defaultController = $defaultController; + $this->defaultMethod = $httpVerb . ucfirst($defaultMethod); + + // Set the default values + $this->controller = $this->defaultController; + $this->method = $this->defaultMethod; + } + + /** + * Finds controller, method and params from the URI. + * + * @return array [directory_name, controller_name, controller_method, params] + */ + public function getRoute(string $uri): array + { + $segments = explode('/', $uri); + + // WARNING: Directories get shifted out of the segments array. + $nonDirSegments = $this->scanControllers($segments); + + $controllerSegment = ''; + $baseControllerName = $this->defaultController; + + // If we don't have any segments left - use the default controller; + // If not empty, then the first segment should be the controller + if (! empty($nonDirSegments)) { + $controllerSegment = array_shift($nonDirSegments); + + $baseControllerName = $this->translateURIDashes(ucfirst($controllerSegment)); + } + + if (! $this->isValidSegment($baseControllerName)) { + throw new PageNotFoundException($baseControllerName . ' is not a valid controller name'); + } + + // Prevent access to default controller path + if ( + strtolower($baseControllerName) === strtolower($this->defaultController) + && strtolower($controllerSegment) === strtolower($this->defaultController) + ) { + throw new PageNotFoundException( + 'Cannot access the default controller "' . $baseControllerName . '" with the controller name URI path.' + ); + } + + // Use the method name if it exists. + if (! empty($nonDirSegments)) { + $methodSegment = $this->translateURIDashes(array_shift($nonDirSegments)); + + // Prefix HTTP verb + $this->method = $this->httpVerb . ucfirst($methodSegment); + + // Prevent access to default method path + if (strtolower($this->method) === strtolower($this->defaultMethod)) { + throw new PageNotFoundException( + 'Cannot access the default method "' . $this->method . '" with the method name URI path.' + ); + } + } + + if (! empty($nonDirSegments)) { + $this->params = $nonDirSegments; + } + + // Ensure the controller stores the fully-qualified class name + $this->controller = '\\' . ltrim( + str_replace( + '/', + '\\', + $this->namespace . $this->subNamespace . $baseControllerName + ), + '\\' + ); + + // Ensure routes registered via $routes->cli() are not accessible via web. + $this->protectDefinedRoutes(); + + // Check _remap() + $this->checkRemap(); + + // Check parameters + try { + $this->checkParameters($uri); + } catch (ReflectionException $e) { + throw PageNotFoundException::forControllerNotFound($this->controller, $this->method); + } + + return [$this->directory, $this->controller, $this->method, $this->params]; + } + + private function protectDefinedRoutes(): void + { + $controller = strtolower($this->controller); + + foreach ($this->protectedControllers as $controllerInRoutes) { + $routeLowerCase = strtolower($controllerInRoutes); + + if ($routeLowerCase === $controller) { + throw new PageNotFoundException( + 'Cannot access the controller in Defined Routes. Controller: ' . $controllerInRoutes + ); + } + } + } + + private function checkParameters(string $uri): void + { + $refClass = new ReflectionClass($this->controller); + $refMethod = $refClass->getMethod($this->method); + $refParams = $refMethod->getParameters(); + + if (! $refMethod->isPublic()) { + throw PageNotFoundException::forMethodNotFound($this->method); + } + + if (count($refParams) < count($this->params)) { + throw new PageNotFoundException( + 'The param count in the URI are greater than the controller method params.' + . ' Handler:' . $this->controller . '::' . $this->method + . ', URI:' . $uri + ); + } + } + + private function checkRemap(): void + { + try { + $refClass = new ReflectionClass($this->controller); + $refClass->getMethod('_remap'); + + throw new PageNotFoundException( + 'AutoRouterImproved does not support `_remap()` method.' + . ' Controller:' . $this->controller + ); + } catch (ReflectionException $e) { + // Do nothing. + } + } + + /** + * Scans the controller directory, attempting to locate a controller matching the supplied uri $segments + * + * @param array $segments URI segments + * + * @return array returns an array of remaining uri segments that don't map onto a directory + */ + private function scanControllers(array $segments): array + { + $segments = array_filter($segments, static fn ($segment) => $segment !== ''); + // numerically reindex the array, removing gaps + $segments = array_values($segments); + + // Loop through our segments and return as soon as a controller + // is found or when such a directory doesn't exist + $c = count($segments); + + while ($c-- > 0) { + $segmentConvert = ucfirst( + $this->translateURIDashes === true + ? str_replace('-', '_', $segments[0]) + : $segments[0] + ); + + // as soon as we encounter any segment that is not PSR-4 compliant, stop searching + if (! $this->isValidSegment($segmentConvert)) { + return $segments; + } + + $test = $this->namespace . $this->subNamespace . $segmentConvert; + + // as long as each segment is *not* a controller file, add it to $this->subNamespace + if (! class_exists($test)) { + $this->setSubNamespace($segmentConvert, true, false); + array_shift($segments); + + $this->directory .= $this->directory . $segmentConvert . '/'; + + continue; + } + + return $segments; + } + + // This means that all segments were actually directories + return $segments; + } + + /** + * Returns true if the supplied $segment string represents a valid PSR-4 compliant namespace/directory segment + * + * regex comes from https://www.php.net/manual/en/language.variables.basics.php + */ + private function isValidSegment(string $segment): bool + { + return (bool) preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $segment); + } + + /** + * Sets the sub-namespace that the controller is in. + * + * @param bool $validate if true, checks to make sure $dir consists of only PSR4 compliant segments + */ + private function setSubNamespace(?string $namespace = null, bool $append = false, bool $validate = true): void + { + if ($validate) { + $segments = explode('/', trim($namespace, '/')); + + foreach ($segments as $segment) { + if (! $this->isValidSegment($segment)) { + return; + } + } + } + + if ($append !== true || empty($this->subNamespace)) { + $this->subNamespace = trim($namespace, '/') . '\\'; + } else { + $this->subNamespace .= trim($namespace, '/') . '\\'; + } + } + + private function translateURIDashes(string $classname): string + { + return $this->translateURIDashes + ? str_replace('-', '_', $classname) + : $classname; + } +} diff --git a/system/Router/AutoRouterInterface.php b/system/Router/AutoRouterInterface.php new file mode 100644 index 000000000000..9ecdd3ec2b30 --- /dev/null +++ b/system/Router/AutoRouterInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router; + +/** + * Expected behavior of a AutoRouter. + */ +interface AutoRouterInterface +{ + /** + * Returns controller, method and params from the URI. + * + * @return array [directory_name, controller_name, controller_method, params] + */ + public function getRoute(string $uri): array; +} diff --git a/system/Router/Router.php b/system/Router/Router.php index ad431ddbfa58..ef0996938ccd 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -113,7 +113,7 @@ class Router implements RouterInterface */ protected $filtersInfo = []; - protected ?AutoRouter $autoRouter = null; + protected ?AutoRouterInterface $autoRouter = null; /** * Stores a reference to the RouteCollection object. @@ -131,14 +131,26 @@ public function __construct(RouteCollectionInterface $routes, ?Request $request $this->translateURIDashes = $this->collection->shouldTranslateURIDashes(); if ($this->collection->shouldAutoRoute()) { - $this->autoRouter = new AutoRouter( - $this->collection->getRegisteredControllers('cli'), - $this->collection->getDefaultNamespace(), - $this->collection->getDefaultController(), - $this->collection->getDefaultMethod(), - $this->translateURIDashes, - $this->collection->getHTTPVerb() - ); + $autoRoutesImproved = config('Feature')->autoRoutesImproved ?? false; + if ($autoRoutesImproved) { + $this->autoRouter = new AutoRouterImproved( + $this->collection->getRegisteredControllers('*'), + $this->collection->getDefaultNamespace(), + $this->collection->getDefaultController(), + $this->collection->getDefaultMethod(), + $this->translateURIDashes, + $this->collection->getHTTPVerb() + ); + } else { + $this->autoRouter = new AutoRouter( + $this->collection->getRegisteredControllers('cli'), + $this->collection->getDefaultNamespace(), + $this->collection->getDefaultController(), + $this->collection->getDefaultMethod(), + $this->translateURIDashes, + $this->collection->getHTTPVerb() + ); + } } } @@ -280,11 +292,11 @@ public function params(): array */ public function directory(): string { - if ($this->autoRouter === null) { - return ''; + if ($this->autoRouter instanceof AutoRouter) { + return $this->autoRouter->directory(); } - return $this->autoRouter->directory(); + return ''; } /** @@ -327,16 +339,16 @@ public function setIndexPage($page): self * Tells the system whether we should translate URI dashes or not * in the URI from a dash to an underscore. * - * @deprecated Moved to AutoRouter class. + * @deprecated This method should be removed. */ public function setTranslateURIDashes(bool $val = false): self { - if ($this->autoRouter === null) { + if ($this->autoRouter instanceof AutoRouter) { + $this->autoRouter->setTranslateURIDashes($val); + return $this; } - $this->autoRouter->setTranslateURIDashes($val); - return $this; } @@ -580,7 +592,7 @@ protected function scanControllers(array $segments): array * * @param bool $validate if true, checks to make sure $dir consists of only PSR4 compliant segments * - * @deprecated Moved to AutoRouter class. + * @deprecated This method should be removed. */ public function setDirectory(?string $dir = null, bool $append = false, bool $validate = true) { @@ -590,11 +602,9 @@ public function setDirectory(?string $dir = null, bool $append = false, bool $va return; } - if ($this->autoRouter === null) { - return; + if ($this->autoRouter instanceof AutoRouter) { + $this->autoRouter->setDirectory($dir, $append, $validate); } - - $this->autoRouter->setDirectory($dir, $append, $validate); } /** diff --git a/tests/system/Router/AutoRouterImprovedTest.php b/tests/system/Router/AutoRouterImprovedTest.php new file mode 100644 index 000000000000..4298a59c5b3b --- /dev/null +++ b/tests/system/Router/AutoRouterImprovedTest.php @@ -0,0 +1,265 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router; + +use CodeIgniter\Config\Services; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\Router\Controllers\Dash_folder\Dash_controller; +use CodeIgniter\Router\Controllers\Dash_folder\Home; +use CodeIgniter\Router\Controllers\Index; +use CodeIgniter\Router\Controllers\Mycontroller; +use CodeIgniter\Test\CIUnitTestCase; +use Config\Modules; + +/** + * @internal + */ +final class AutoRouterImprovedTest extends CIUnitTestCase +{ + private RouteCollection $collection; + + protected function setUp(): void + { + parent::setUp(); + + $moduleConfig = new Modules(); + $moduleConfig->enabled = false; + $this->collection = new RouteCollection(Services::locator(), $moduleConfig); + } + + private function createNewAutoRouter(string $httpVerb = 'get'): AutoRouterImproved + { + return new AutoRouterImproved( + [], + 'CodeIgniter\Router\Controllers', + $this->collection->getDefaultController(), + $this->collection->getDefaultMethod(), + true, + $httpVerb + ); + } + + public function testAutoRouteFindsDefaultControllerAndMethodGet() + { + $this->collection->setDefaultController('Index'); + + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('/'); + + $this->assertNull($directory); + $this->assertSame('\\' . Index::class, $controller); + $this->assertSame('getIndex', $method); + $this->assertSame([], $params); + } + + public function testAutoRouteFindsDefaultControllerAndMethodPost() + { + $this->collection->setDefaultController('Index'); + + $router = $this->createNewAutoRouter('post'); + + [$directory, $controller, $method, $params] + = $router->getRoute('/'); + + $this->assertNull($directory); + $this->assertSame('\\' . Index::class, $controller); + $this->assertSame('postIndex', $method); + $this->assertSame([], $params); + } + + public function testAutoRouteFindsControllerWithFileAndMethod() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('mycontroller/somemethod'); + + $this->assertNull($directory); + $this->assertSame('\\' . Mycontroller::class, $controller); + $this->assertSame('getSomemethod', $method); + $this->assertSame([], $params); + } + + public function testFindsControllerAndMethodAndParam() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('mycontroller/somemethod/a'); + + $this->assertNull($directory); + $this->assertSame('\\' . Mycontroller::class, $controller); + $this->assertSame('getSomemethod', $method); + $this->assertSame(['a'], $params); + } + + public function testUriParamCountIsGreaterThanMethodParams() + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage( + 'Handler:\CodeIgniter\Router\Controllers\Mycontroller::getSomemethod, URI:mycontroller/somemethod/a/b' + ); + + $router = $this->createNewAutoRouter(); + + $router->getRoute('mycontroller/somemethod/a/b'); + } + + public function testAutoRouteFindsControllerWithFile() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('mycontroller'); + + $this->assertNull($directory); + $this->assertSame('\\' . Mycontroller::class, $controller); + $this->assertSame('getIndex', $method); + $this->assertSame([], $params); + } + + public function testAutoRouteFindsControllerWithSubfolder() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('subfolder/mycontroller/somemethod'); + + $this->assertSame('Subfolder/', $directory); + $this->assertSame('\\' . \CodeIgniter\Router\Controllers\Subfolder\Mycontroller::class, $controller); + $this->assertSame('getSomemethod', $method); + $this->assertSame([], $params); + } + + public function testAutoRouteFindsDashedSubfolder() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('dash-folder/mycontroller/somemethod'); + + $this->assertSame('Dash_folder/', $directory); + $this->assertSame( + '\\' . \CodeIgniter\Router\Controllers\Dash_folder\Mycontroller::class, + $controller + ); + $this->assertSame('getSomemethod', $method); + $this->assertSame([], $params); + } + + public function testAutoRouteFindsDashedController() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('dash-folder/dash-controller/somemethod'); + + $this->assertSame('Dash_folder/', $directory); + $this->assertSame('\\' . Dash_controller::class, $controller); + $this->assertSame('getSomemethod', $method); + $this->assertSame([], $params); + } + + public function testAutoRouteFindsDashedMethod() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('dash-folder/dash-controller/dash-method'); + + $this->assertSame('Dash_folder/', $directory); + $this->assertSame('\\' . Dash_controller::class, $controller); + $this->assertSame('getDash_method', $method); + $this->assertSame([], $params); + } + + public function testAutoRouteFindsDefaultDashFolder() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('dash-folder'); + + $this->assertSame('Dash_folder/', $directory); + $this->assertSame('\\' . Home::class, $controller); + $this->assertSame('getIndex', $method); + $this->assertSame([], $params); + } + + public function testAutoRouteRejectsSingleDot() + { + $this->expectException(PageNotFoundException::class); + + $router = $this->createNewAutoRouter(); + + $router->getRoute('.'); + } + + public function testAutoRouteRejectsDoubleDot() + { + $this->expectException(PageNotFoundException::class); + + $router = $this->createNewAutoRouter(); + + $router->getRoute('..'); + } + + public function testAutoRouteRejectsMidDot() + { + $this->expectException(PageNotFoundException::class); + + $router = $this->createNewAutoRouter(); + + $router->getRoute('foo.bar'); + } + + public function testRejectsDefaultControllerPath() + { + $this->expectException(PageNotFoundException::class); + + $router = $this->createNewAutoRouter(); + + $router->getRoute('home'); + } + + public function testRejectsDefaultControllerAndDefaultMethodPath() + { + $this->expectException(PageNotFoundException::class); + + $router = $this->createNewAutoRouter(); + + $router->getRoute('home/index'); + } + + public function testRejectsDefaultMethodPath() + { + $this->expectException(PageNotFoundException::class); + + $router = $this->createNewAutoRouter(); + + $router->getRoute('mycontroller/index'); + } + + public function testRejectsControllerWithRemapMethod() + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage( + 'AutoRouterImproved does not support `_remap()` method. Controller:\CodeIgniter\Router\Controllers\Remap' + ); + + $router = $this->createNewAutoRouter(); + + $router->getRoute('remap/test'); + } +} diff --git a/tests/system/Router/Controllers/Dash_folder/Dash_controller.php b/tests/system/Router/Controllers/Dash_folder/Dash_controller.php new file mode 100644 index 000000000000..52736f751b2f --- /dev/null +++ b/tests/system/Router/Controllers/Dash_folder/Dash_controller.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Controllers\Dash_folder; + +use CodeIgniter\Controller; + +class Dash_controller extends Controller +{ + public function getSomemethod() + { + } + + public function getDash_method() + { + } +} diff --git a/tests/system/Router/Controllers/Dash_folder/Emptyfolder/.gitkeep b/tests/system/Router/Controllers/Dash_folder/Emptyfolder/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/system/Router/Controllers/Dash_folder/Home.php b/tests/system/Router/Controllers/Dash_folder/Home.php new file mode 100644 index 000000000000..60ca6ce6c853 --- /dev/null +++ b/tests/system/Router/Controllers/Dash_folder/Home.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Controllers\Dash_folder; + +use CodeIgniter\Controller; + +class Home extends Controller +{ + public function getIndex() + { + } +} diff --git a/tests/system/Router/Controllers/Dash_folder/Mycontroller.php b/tests/system/Router/Controllers/Dash_folder/Mycontroller.php new file mode 100644 index 000000000000..a5d5ba65fc6a --- /dev/null +++ b/tests/system/Router/Controllers/Dash_folder/Mycontroller.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Controllers\Dash_folder; + +use CodeIgniter\Controller; + +class Mycontroller extends Controller +{ + public function getSomemethod() + { + } +} diff --git a/tests/system/Router/Controllers/Home.php b/tests/system/Router/Controllers/Home.php new file mode 100644 index 000000000000..e4411e4ae592 --- /dev/null +++ b/tests/system/Router/Controllers/Home.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Controllers; + +use CodeIgniter\Controller; + +class Home extends Controller +{ + public function getIndex() + { + } + + public function postIndex() + { + } +} diff --git a/tests/system/Router/Controllers/Index.php b/tests/system/Router/Controllers/Index.php new file mode 100644 index 000000000000..bfc3539e0b1f --- /dev/null +++ b/tests/system/Router/Controllers/Index.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Controllers; + +use CodeIgniter\Controller; + +class Index extends Controller +{ + public function getIndex() + { + } + + public function postIndex() + { + } +} diff --git a/tests/system/Router/Controllers/Mycontroller.php b/tests/system/Router/Controllers/Mycontroller.php new file mode 100644 index 000000000000..dc7f72a21dc5 --- /dev/null +++ b/tests/system/Router/Controllers/Mycontroller.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Controllers; + +use CodeIgniter\Controller; + +class Mycontroller extends Controller +{ + public function getIndex() + { + } + + public function getSomemethod($first = '') + { + } +} diff --git a/tests/system/Router/Controllers/Remap.php b/tests/system/Router/Controllers/Remap.php new file mode 100644 index 000000000000..ca56c9d90ce0 --- /dev/null +++ b/tests/system/Router/Controllers/Remap.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Controllers; + +use CodeIgniter\Controller; +use CodeIgniter\Exceptions\PageNotFoundException; + +class Remap extends Controller +{ + public function _remap(string $method, ...$params): string + { + $method = 'process_' . $method; + + if (method_exists($this, $method)) { + return $this->{$method}(...$params); + } + + throw PageNotFoundException::forPageNotFound(); + } + + public function getTest(): string + { + return __METHOD__; + } + + protected function process_index(): string + { + return __METHOD__; + } +} diff --git a/tests/system/Router/Controllers/Subfolder/Mycontroller.php b/tests/system/Router/Controllers/Subfolder/Mycontroller.php new file mode 100644 index 000000000000..9ab632a0fd98 --- /dev/null +++ b/tests/system/Router/Controllers/Subfolder/Mycontroller.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router\Controllers\Subfolder; + +use CodeIgniter\Controller; + +class Mycontroller extends Controller +{ + public function getSomemethod() + { + } +} diff --git a/user_guide_src/source/changelogs/v4.2.0.rst b/user_guide_src/source/changelogs/v4.2.0.rst index 52cc16b86dc7..14e63e6990cb 100644 --- a/user_guide_src/source/changelogs/v4.2.0.rst +++ b/user_guide_src/source/changelogs/v4.2.0.rst @@ -19,11 +19,33 @@ BREAKING - The method signature of ``CodeIgniter\CLI\CommandRunner::_remap()`` has been changed to fix a bug. - The ``CodeIgniter\Autoloader\Autoloader::initialize()`` has changed the behavior to fix a bug. It used to use Composer classmap only when ``$modules->discoverInComposer`` is true. Now it always uses the Composer classmap if Composer is available. - The color code output by :ref:`CLI::color() ` has been changed to fix a bug. -- To prevent unexpected access from the web browser, if a controller is added to a cli route (``$routes->cli()``), all methods of that controller are no longer accessible via auto routing. +- To prevent unexpected access from the web browser, if a controller is added to a cli route (``$routes->cli()``), all methods of that controller are no longer accessible via auto-routing. Enhancements ************ +New Improved Auto Routing +========================= + +Added an optional new more secure auto router. These are the changes from the legacy auto-routing: + +- A controller method needs HTTP verb prefix like ``getIndex()``, ``postCreate()``. + - Developers always know the HTTP method, so requests by an unexpected HTTP method does not pass. +- The Default Controller (``Home`` by default) and the Default Method (``index`` by default) must be omitted in the URI. + - It restricts one-to-one correspondence between controller methods and URIs. + - E.g. by default, you can access ``/``, but ``/home`` and ``/home/index`` will be 404. +- It checks method parameter count. + - If there are more parameters in the URI than the method parameters, it results in 404. +- It does not support ``_remap()`` method. + - It restricts one-to-one correspondence between controller methods and URIs. +- Can't access controllers in Defined Routes. + - It completely separates controllers accessible via **Auto Routes** from those accessible via **Defined Routes**. + +See :ref:`auto-routing-improved` for the details. + +Others +====== + - Content Security Policy (CSP) enhancements - Added the configs ``$scriptNonceTag`` and ``$styleNonceTag`` in ``Config\ContentSecurityPolicy`` to customize the CSP placeholders (``{csp-script-nonce}`` and ``{csp-style-nonce}``) - Added the config ``$autoNonce`` in ``Config\ContentSecurityPolicy`` to disable the CSP placeholder replacement diff --git a/user_guide_src/source/incoming/controllers.rst b/user_guide_src/source/incoming/controllers.rst index 9074bf18046f..594c7ceadfd9 100644 --- a/user_guide_src/source/incoming/controllers.rst +++ b/user_guide_src/source/incoming/controllers.rst @@ -109,20 +109,187 @@ then trying to access it using the following URL will not work:: example.com/index.php/helloworld/utility/ +.. _controller-auto-routing-improved: + +Auto Routing (Improved) +************************ + +Since v4.2.0, the new more secure Auto Routing has been introduced. + +This section describes the functionality of the new auto-routing. +It automatically routes an HTTP request, and executes the corresponding controller method +without route definitions. + +Since v4.2.0, the auto-routing is disabled by default. To use it, see :ref:`enabled-auto-routing-improved`. + +Consider this URI:: + + example.com/index.php/helloworld/ + +In the above example, CodeIgniter would attempt to find a controller named ``App\Controllers\Helloworld`` and load it, when auto-routing is enabled. + +.. note:: When a controller's short name matches the first segment of a URI, it will be loaded. + +Let's try it: Hello World! +========================== + +Let's create a simple controller so you can see it in action. Using your text editor, create a file called **Helloworld.php**, +and put the following code in it. You will notice that the ``Helloworld`` Controller is extending the ``BaseController``. you can +also extend the ``CodeIgniter\Controller`` if you do not need the functionality of the BaseController. + +The BaseController provides a convenient place for loading components and performing functions that are needed by all your +controllers. You can extend this class in any new controller. + +.. literalinclude:: controllers/020.php + +Then save the file to your **app/Controllers/** directory. + +.. important:: The file must be called **Helloworld.php**, with a capital ``H``. Controller class names MUST start with an uppercase letter and ONLY the first character can be uppercase. + +.. important:: A controller method that will be executed by Auto Routing (Improved) needs HTTP verb (``get``, ``post``, ``put``, etc.) prefix like ``getIndex()``, ``postCreate()``. + +Now visit your site using a URL similar to this:: + + example.com/index.php/helloworld + +If you did it right you should see:: + + Hello World! + +This is valid: + +.. literalinclude:: controllers/009.php + +This is **not** valid: + +.. literalinclude:: controllers/010.php + +This is **not** valid: + +.. literalinclude:: controllers/011.php + +Also, always make sure your controller extends the parent controller +class so that it can inherit all its methods. + +.. note:: + The system will attempt to match the URI against Controllers by matching each segment against + folders/files in **app/Controllers/**, when a match wasn't found against defined routes. + That's why your folders/files MUST start with a capital letter and the rest MUST be lowercase. + + Here is an example based on PSR-4 Autoloader: + + .. literalinclude:: controllers/012.php + + If you want another naming convention you need to manually define it using the + :ref:`Defined Route Routing `. + +Methods +======= + +In the above example, the method name is ``getIndex()``. +The method (HTTP verb + ``Index()``) is loaded if the **second segment** of the URI is empty. + +**The second segment of the URI determines which method in the +controller gets called.** + +Let's try it. Add a new method to your controller: + +.. literalinclude:: controllers/021.php + +Now load the following URL to see the ``getComment()`` method:: + + example.com/index.php/helloworld/comment/ + +You should see your new message. + +.. warning:: For security reasons be sure to declare any new utility methods as ``protected`` or ``private``. + +Passing URI Segments to Your Methods +==================================== + +If your URI contains more than two segments they will be passed to your +method as parameters. + +For example, let's say you have a URI like this:: + + example.com/index.php/products/shoes/sandals/123 + +Your method will be passed URI segments 3 and 4 (``'sandals'`` and ``'123'``): + +.. literalinclude:: controllers/022.php + +Defining a Default Controller +============================= + +CodeIgniter can be told to load a default controller when a URI is not +present, as will be the case when only your site root URL is requested. Let's try it +with the ``Helloworld`` controller. + +To specify a default controller open your **app/Config/Routes.php** +file and set this variable: + +.. literalinclude:: controllers/015.php + +Where ``Helloworld`` is the name of the controller class you want to be used. + +A few lines further down **Routes.php** in the "Route Definitions" section, comment out the line: + +.. literalinclude:: controllers/016.php + +If you now browse to your site without specifying any URI segments you'll +see the "Hello World" message. + +.. note:: The line ``$routes->get('/', 'Home::index');`` is an optimization that you will want to use in a "real-world" app. But for demonstration purposes we don't want to use that feature. ``$routes->get()`` is explained in :doc:`URI Routing ` + +For more information, please refer to the :ref:`routes-configuration-options` section of the +:doc:`URI Routing ` documentation. + +Organizing Your Controllers into Sub-directories +================================================ + +If you are building a large application you might want to hierarchically +organize or structure your controllers into sub-directories. CodeIgniter +permits you to do this. + +Simply create sub-directories under the main **app/Controllers/**, +and place your controller classes within them. + +.. important:: Folder names MUST start with an uppercase letter and ONLY the first character can be uppercase. + +When using this feature the first segment of your URI must +specify the folder. For example, let's say you have a controller located here:: + + app/Controllers/Products/Shoes.php + +To call the above controller your URI will look something like this:: + + example.com/index.php/products/shoes/show/123 + +.. note:: You cannot have directories with the same name in **app/Controllers/** and **public/**. + This is because if there is a directory, the web server will search for it and + it will not be routed to CodeIgniter. + +Each of your sub-directories may contain a default controller which will be +called if the URL contains *only* the sub-directory. Simply put a controller +in there that matches the name of your default controller as specified in +your **app/Config/Routes.php** file. + +CodeIgniter also permits you to map your URIs using its :ref:`Defined Route Routing `.. + .. _controller-auto-routing: -Auto Routing -************ +Auto Routing (Legacy) +********************* -This section describes the functionality of the auto-routing. +This section describes the functionality of Auto Routing (Legacy) that is a routing system from CodeIgniter 3. It automatically routes an HTTP request, and executes the corresponding controller method without route definitions. The auto-routing is disabled by default. .. warning:: To prevent misconfiguration and miscoding, we recommend that you do not use - the auto-routing feature. It is easy to create vulnerable apps where controller filters + Auto Routing (Legacy). It is easy to create vulnerable apps where controller filters or CSRF protection are bypassed. -.. important:: The auto-routing routes a HTTP request with **any** HTTP method to a controller method. +.. important:: Auto Routing (Legacy) routes a HTTP request with **any** HTTP method to a controller method. Consider this URI:: @@ -130,7 +297,7 @@ Consider this URI:: In the above example, CodeIgniter would attempt to find a controller named **Helloworld.php** and load it. -**When a controller's name matches the first segment of a URI, it will be loaded.** +.. note:: When a controller's short name matches the first segment of a URI, it will be loaded. Let's try it: Hello World! ========================== @@ -148,7 +315,7 @@ For security reasons be sure to declare any new utility methods as ``protected`` Then save the file to your **app/Controllers/** directory. -.. important:: The file must be called **Helloworld.php**, with a capital ``H``. +.. important:: The file must be called **Helloworld.php**, with a capital ``H``. Controller class names MUST start with an uppercase letter and ONLY the first character can be uppercase. Now visit your site using a URL similar to this:: @@ -158,8 +325,6 @@ If you did it right you should see:: Hello World! -.. important:: Controller class names MUST start with an uppercase letter and ONLY the first character can be uppercase. - This is valid: .. literalinclude:: controllers/009.php @@ -179,13 +344,14 @@ class so that it can inherit all its methods. The system will attempt to match the URI against Controllers by matching each segment against folders/files in **app/Controllers/**, when a match wasn't found against defined routes. That's why your folders/files MUST start with a capital letter and the rest MUST be lowercase. - If you want another naming convention you need to manually define it using the - :doc:`URI Routing ` feature. Here is an example based on PSR-4 Autoloader: .. literalinclude:: controllers/012.php + If you want another naming convention you need to manually define it using the + :ref:`Defined Route Routing `. + Methods ======= @@ -208,7 +374,7 @@ Now load the following URL to see the comment method:: You should see your new message. -Passing URI Segments to your methods +Passing URI Segments to Your Methods ==================================== If your URI contains more than two segments they will be passed to your @@ -222,10 +388,6 @@ Your method will be passed URI segments 3 and 4 (``'sandals'`` and ``'123'``): .. literalinclude:: controllers/014.php -.. important:: If you are using the :doc:`URI Routing ` - feature, the segments passed to your method will be the defined - ones. - Defining a Default Controller ============================= @@ -282,7 +444,7 @@ called if the URL contains *only* the sub-directory. Simply put a controller in there that matches the name of your default controller as specified in your **app/Config/Routes.php** file. -CodeIgniter also permits you to map your URIs using its :doc:`URI Routing ` feature. +CodeIgniter also permits you to map your URIs using its :ref:`Defined Route Routing `.. Remapping Method Calls ********************** diff --git a/user_guide_src/source/incoming/controllers/014.php b/user_guide_src/source/incoming/controllers/014.php index da295eb61836..18c076f5bc25 100644 --- a/user_guide_src/source/incoming/controllers/014.php +++ b/user_guide_src/source/incoming/controllers/014.php @@ -6,7 +6,6 @@ class Products extends BaseController { public function shoes($sandals, $id) { - return $sandals - . $id; + return $sandals . $id; } } diff --git a/user_guide_src/source/incoming/controllers/020.php b/user_guide_src/source/incoming/controllers/020.php new file mode 100644 index 000000000000..ff485a0327a5 --- /dev/null +++ b/user_guide_src/source/incoming/controllers/020.php @@ -0,0 +1,11 @@ +setAutoRoute(true); +And you need to change the property ``$autoRoutesImproved`` to ``true`` in **app/Config/Feature.php**:: + + public bool $autoRoutesImproved = true; + URI Segments ============ @@ -547,15 +560,15 @@ The segments in the URL, in following with the Model-View-Controller approach, u Consider this URI:: - example.com/index.php/helloworld/index/1 + example.com/index.php/helloworld/hello/1 -In the above example, CodeIgniter would attempt to find a controller named **Helloworld.php** -and executes ``index()`` method with passing ``'1'`` as the first argument. +In the above example, when you send a HTTP request with **GET** method, +Auto Routing would attempt to find a controller named ``App\Controllers\Helloworld`` +and executes ``getHello()`` method with passing ``'1'`` as the first argument. -We call this "**Auto Routes**". CodeIgniter automatically routes an HTTP request, -and executes the corresponding controller method. The auto-routing is disabled by default. +.. note:: A controller method that will be executed by Auto Routing (Improved) needs HTTP verb (``get``, ``post``, ``put``, etc.) prefix like ``getIndex()``, ``postCreate()``. -See :ref:`Auto Routing in Controllers ` for more info. +See :ref:`Auto Routing in Controllers ` for more info. Configuration Options ===================== @@ -565,7 +578,7 @@ These options are available at the top of **app/Config/Routes.php**. Default Controller ------------------ -When a user visits the root of your site (i.e., example.com) the controller to use is determined by the value set by +When a user visits the root of your site (i.e., **example.com**) the controller to use is determined by the value set by the ``setDefaultController()`` method, unless a route exists for it explicitly. The default value for this is ``Home`` which matches the controller at **app/Controllers/Home.php**: @@ -575,7 +588,10 @@ The default controller is also used when no matching route has been found, and t in the controllers directory. For example, if the user visits **example.com/admin**, if a controller was found at **app/Controllers/Admin/Home.php**, it would be used. -See :ref:`Auto Routing in Controllers ` for more info. +.. note:: You cannot access the default controller with the URI of the controller name. + When the default controller is ``Home``, you can access **example.com/**, but if you access **example.com/home**, it will be not found. + +See :ref:`Auto Routing in Controllers ` for more info. Default Method -------------- @@ -589,6 +605,87 @@ In this example, if the user were to visit **example.com/products**, and a ``Pro .. literalinclude:: routing/048.php +.. note:: You cannot access the controller with the URI of the default method name. + In the example above, you can access **example.com/products**, but if you access **example.com/products/listall**, it will be not found. + +.. _auto-routing: + +Auto Routing (Legacy) +********************* + +Auto Routing (Legacy) is a routing system from CodeIgniter 3. +It can automatically route HTTP requests based on conventions and execute the corresponding controller methods. + +It is recommended that all routes are defined in the **app/Config/Routes.php** file, +or to use :ref:`auto-routing-improved`, + +.. warning:: To prevent misconfiguration and miscoding, we recommend that you do not use + Auto Routing (Legacy) feature. It is easy to create vulnerable apps where controller filters + or CSRF protection are bypassed. + +.. important:: Auto Routing (Legacy) routes a HTTP request with **any** HTTP method to a controller method. + +Enable Auto Routing (Legacy) +============================ + +Since v4.2.0, the auto-routing is disabled by default. + +To use it, you need to change the setting ``setAutoRoute()`` option to true in **app/Config/Routes.php**:: + + $routes->setAutoRoute(true); + +URI Segments (Legacy) +===================== + +The segments in the URL, in following with the Model-View-Controller approach, usually represent:: + + example.com/class/method/ID + +1. The first segment represents the controller **class** that should be invoked. +2. The second segment represents the class **method** that should be called. +3. The third, and any additional segments, represent the ID and any variables that will be passed to the controller. + +Consider this URI:: + + example.com/index.php/helloworld/index/1 + +In the above example, CodeIgniter would attempt to find a controller named **Helloworld.php** +and executes ``index()`` method with passing ``'1'`` as the first argument. + +See :ref:`Auto Routing (Legacy) in Controllers ` for more info. + +Configuration Options (Legacy) +============================== + +These options are available at the top of **app/Config/Routes.php**. + +Default Controller (Legacy) +--------------------------- + +When a user visits the root of your site (i.e., example.com) the controller to use is determined by the value set by +the ``setDefaultController()`` method, unless a route exists for it explicitly. The default value for this is ``Home`` +which matches the controller at **app/Controllers/Home.php**: + +.. literalinclude:: routing/047.php + +The default controller is also used when no matching route has been found, and the URI would point to a directory +in the controllers directory. For example, if the user visits **example.com/admin**, if a controller was found at +**app/Controllers/Admin/Home.php**, it would be used. + +See :ref:`Auto Routing in Controllers ` for more info. + +Default Method (Legacy) +----------------------- + +This works similar to the default controller setting, but is used to determine the default method that is used +when a controller is found that matches the URI, but no segment exists for the method. The default value is +``index``. + +In this example, if the user were to visit **example.com/products**, and a ``Products`` controller existed, the +``Products::listAll()`` method would be executed: + +.. literalinclude:: routing/048.php + Confirming Routes ***************** @@ -617,11 +714,11 @@ The output is like the following: | auto | home/index[/...] | \App\Controllers\Home::index | invalidchars | secureheaders toolbar | +--------+------------------+------------------------------------------+----------------+-----------------------+ -The *Method* column shows the HTTP method that the route is listening for. ``auto`` means that the route is discovered by auto routing, so it is not defined in **app/Config/Routes.php**. +The *Method* column shows the HTTP method that the route is listening for. ``auto`` means that the route is discovered by auto-routing, so it is not defined in **app/Config/Routes.php**. The *Route* column shows the URI path to match. The route of a defined route is expressed as a regular expression. But ``[/...]`` in the route of an auto route is indicates any number of segments. -.. note:: When auto routing is enabled, if you have the route ``home``, it can be also accessd by ``Home``, or maybe by ``hOme``, ``hoMe``, ``HOME``, etc. But the command shows only ``home``. +.. note:: When auto-routing is enabled, if you have the route ``home``, it can be also accessd by ``Home``, or maybe by ``hOme``, ``hoMe``, ``HOME``, etc. But the command shows only ``home``. .. important:: The system is not perfect. If you use Custom Placeholders, *Filters* might not be correct. But the filters defined in **app/Config/Routes.php** are always displayed correctly.