mirror of
https://github.com/codeigniter4/CodeIgniter4.git
synced 2025-02-20 11:44:28 +08:00
* Apply to parameters * Apply to array destructuring * Apply to match * Apply for arguments
593 lines
18 KiB
PHP
593 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* This file is part of CodeIgniter 4 framework.
|
|
*
|
|
* (c) CodeIgniter Foundation <admin@codeigniter.com>
|
|
*
|
|
* 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 CodeIgniter\Router\Exceptions\MethodNotFoundException;
|
|
use Config\Routing;
|
|
use ReflectionClass;
|
|
use ReflectionException;
|
|
|
|
/**
|
|
* New Secure Router for Auto-Routing
|
|
*
|
|
* @see \CodeIgniter\Router\AutoRouterImprovedTest
|
|
*/
|
|
final class AutoRouterImproved implements AutoRouterInterface
|
|
{
|
|
/**
|
|
* Sub-directory that contains the requested controller class.
|
|
*/
|
|
private ?string $directory = 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.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
private array $params = [];
|
|
|
|
/**
|
|
* Whether to translate dashes in URIs for controller/method to CamelCase.
|
|
* E.g., blog-controller -> BlogController
|
|
*/
|
|
private readonly bool $translateUriToCamelCase;
|
|
|
|
/**
|
|
* The namespace for controllers.
|
|
*/
|
|
private string $namespace;
|
|
|
|
/**
|
|
* Map of URI segments and namespaces.
|
|
*
|
|
* The key is the first URI segment. The value is the controller namespace.
|
|
* E.g.,
|
|
* [
|
|
* 'blog' => 'Acme\Blog\Controllers',
|
|
* ]
|
|
*
|
|
* @var array [ uri_segment => namespace ]
|
|
*/
|
|
private array $moduleRoutes;
|
|
|
|
/**
|
|
* The URI segments.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
private array $segments = [];
|
|
|
|
/**
|
|
* The position of the Controller in the URI segments.
|
|
* Null for the default controller.
|
|
*/
|
|
private ?int $controllerPos = null;
|
|
|
|
/**
|
|
* The position of the Method in the URI segments.
|
|
* Null for the default method.
|
|
*/
|
|
private ?int $methodPos = null;
|
|
|
|
/**
|
|
* The position of the first Parameter in the URI segments.
|
|
* Null for the no parameters.
|
|
*/
|
|
private ?int $paramPos = null;
|
|
|
|
/**
|
|
* The current URI
|
|
*/
|
|
private ?string $uri = null;
|
|
|
|
/**
|
|
* @param list<class-string> $protectedControllers
|
|
* @param string $defaultController Short classname
|
|
*/
|
|
public function __construct(
|
|
/**
|
|
* List of controllers in Defined Routes that should not be accessed via this Auto-Routing.
|
|
*/
|
|
private readonly array $protectedControllers,
|
|
string $namespace,
|
|
private readonly string $defaultController,
|
|
/**
|
|
* The name of the default method without HTTP verb prefix.
|
|
*/
|
|
private readonly string $defaultMethod,
|
|
/**
|
|
* Whether dashes in URI's should be converted
|
|
* to underscores when determining method names.
|
|
*/
|
|
private readonly bool $translateURIDashes,
|
|
) {
|
|
$this->namespace = rtrim($namespace, '\\');
|
|
|
|
$routingConfig = config(Routing::class);
|
|
$this->moduleRoutes = $routingConfig->moduleRoutes;
|
|
$this->translateUriToCamelCase = $routingConfig->translateUriToCamelCase;
|
|
|
|
// Set the default values
|
|
$this->controller = $this->defaultController;
|
|
}
|
|
|
|
private function createSegments(string $uri): array
|
|
{
|
|
$segments = explode('/', $uri);
|
|
$segments = array_filter($segments, static fn ($segment): bool => $segment !== '');
|
|
|
|
// numerically reindex the array, removing gaps
|
|
return array_values($segments);
|
|
}
|
|
|
|
/**
|
|
* Search for the first controller corresponding to the URI segment.
|
|
*
|
|
* If there is a controller corresponding to the first segment, the search
|
|
* ends there. The remaining segments are parameters to the controller.
|
|
*
|
|
* @return bool true if a controller class is found.
|
|
*/
|
|
private function searchFirstController(): bool
|
|
{
|
|
$segments = $this->segments;
|
|
|
|
$controller = '\\' . $this->namespace;
|
|
|
|
$controllerPos = -1;
|
|
|
|
while ($segments !== []) {
|
|
$segment = array_shift($segments);
|
|
$controllerPos++;
|
|
|
|
$class = $this->translateURI($segment);
|
|
|
|
// as soon as we encounter any segment that is not PSR-4 compliant, stop searching
|
|
if (! $this->isValidSegment($class)) {
|
|
return false;
|
|
}
|
|
|
|
$controller .= '\\' . $class;
|
|
|
|
if (class_exists($controller)) {
|
|
$this->controller = $controller;
|
|
$this->controllerPos = $controllerPos;
|
|
|
|
$this->checkUriForController($controller);
|
|
|
|
// The first item may be a method name.
|
|
$this->params = $segments;
|
|
if ($segments !== []) {
|
|
$this->paramPos = $this->controllerPos + 1;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Search for the last default controller corresponding to the URI segments.
|
|
*
|
|
* @return bool true if a controller class is found.
|
|
*/
|
|
private function searchLastDefaultController(): bool
|
|
{
|
|
$segments = $this->segments;
|
|
|
|
$segmentCount = count($this->segments);
|
|
$paramPos = null;
|
|
$params = [];
|
|
|
|
while ($segments !== []) {
|
|
if ($segmentCount > count($segments)) {
|
|
$paramPos = count($segments);
|
|
}
|
|
|
|
$namespaces = array_map(
|
|
fn ($segment): string => $this->translateURI($segment),
|
|
$segments,
|
|
);
|
|
|
|
$controller = '\\' . $this->namespace
|
|
. '\\' . implode('\\', $namespaces)
|
|
. '\\' . $this->defaultController;
|
|
|
|
if (class_exists($controller)) {
|
|
$this->controller = $controller;
|
|
$this->params = $params;
|
|
|
|
if ($params !== []) {
|
|
$this->paramPos = $paramPos;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Prepend the last element in $segments to the beginning of $params.
|
|
array_unshift($params, array_pop($segments));
|
|
}
|
|
|
|
// Check for the default controller in Controllers directory.
|
|
$controller = '\\' . $this->namespace
|
|
. '\\' . $this->defaultController;
|
|
|
|
if (class_exists($controller)) {
|
|
$this->controller = $controller;
|
|
$this->params = $params;
|
|
|
|
if ($params !== []) {
|
|
$this->paramPos = 0;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Finds controller, method and params from the URI.
|
|
*
|
|
* @param string $httpVerb HTTP verb like `GET`,`POST`
|
|
*
|
|
* @return array [directory_name, controller_name, controller_method, params]
|
|
*/
|
|
public function getRoute(string $uri, string $httpVerb): array
|
|
{
|
|
$this->uri = $uri;
|
|
$httpVerb = strtolower($httpVerb);
|
|
|
|
// Reset Controller method params.
|
|
$this->params = [];
|
|
|
|
$defaultMethod = $httpVerb . ucfirst($this->defaultMethod);
|
|
$this->method = $defaultMethod;
|
|
|
|
$this->segments = $this->createSegments($uri);
|
|
|
|
// Check for Module Routes.
|
|
if (
|
|
$this->segments !== []
|
|
&& array_key_exists($this->segments[0], $this->moduleRoutes)
|
|
) {
|
|
$uriSegment = array_shift($this->segments);
|
|
$this->namespace = rtrim($this->moduleRoutes[$uriSegment], '\\');
|
|
}
|
|
|
|
if ($this->searchFirstController()) {
|
|
// Controller is found.
|
|
$baseControllerName = class_basename($this->controller);
|
|
|
|
// Prevent access to default controller path
|
|
if (
|
|
strtolower($baseControllerName) === strtolower($this->defaultController)
|
|
) {
|
|
throw new PageNotFoundException(
|
|
'Cannot access the default controller "' . $this->controller . '" with the controller name URI path.',
|
|
);
|
|
}
|
|
} elseif ($this->searchLastDefaultController()) {
|
|
// The default Controller is found.
|
|
$baseControllerName = class_basename($this->controller);
|
|
} else {
|
|
// No Controller is found.
|
|
throw new PageNotFoundException('No controller is found for: ' . $uri);
|
|
}
|
|
|
|
// The first item may be a method name.
|
|
/** @var list<string> $params */
|
|
$params = $this->params;
|
|
|
|
$methodParam = array_shift($params);
|
|
|
|
$method = '';
|
|
if ($methodParam !== null) {
|
|
$method = $httpVerb . $this->translateURI($methodParam);
|
|
|
|
$this->checkUriForMethod($method);
|
|
}
|
|
|
|
if ($methodParam !== null && method_exists($this->controller, $method)) {
|
|
// Method is found.
|
|
$this->method = $method;
|
|
$this->params = $params;
|
|
|
|
// Update the positions.
|
|
$this->methodPos = $this->paramPos;
|
|
if ($params === []) {
|
|
$this->paramPos = null;
|
|
}
|
|
if ($this->paramPos !== null) {
|
|
$this->paramPos++;
|
|
}
|
|
|
|
// Prevent access to default controller's method
|
|
if (strtolower($baseControllerName) === strtolower($this->defaultController)) {
|
|
throw new PageNotFoundException(
|
|
'Cannot access the default controller "' . $this->controller . '::' . $this->method . '"',
|
|
);
|
|
}
|
|
|
|
// Prevent access to default method path
|
|
if (strtolower($this->method) === strtolower($defaultMethod)) {
|
|
throw new PageNotFoundException(
|
|
'Cannot access the default method "' . $this->method . '" with the method name URI path.',
|
|
);
|
|
}
|
|
} elseif (method_exists($this->controller, $defaultMethod)) {
|
|
// The default method is found.
|
|
$this->method = $defaultMethod;
|
|
} else {
|
|
// No method is found.
|
|
throw PageNotFoundException::forControllerNotFound($this->controller, $method);
|
|
}
|
|
|
|
// Ensure the controller is not defined in routes.
|
|
$this->protectDefinedRoutes();
|
|
|
|
// Ensure the controller does not have _remap() method.
|
|
$this->checkRemap();
|
|
|
|
// Ensure the URI segments for the controller and method do not contain
|
|
// underscores when $translateURIDashes is true.
|
|
$this->checkUnderscore();
|
|
|
|
// Check parameter count
|
|
try {
|
|
$this->checkParameters();
|
|
} catch (MethodNotFoundException) {
|
|
throw PageNotFoundException::forControllerNotFound($this->controller, $this->method);
|
|
}
|
|
|
|
$this->setDirectory();
|
|
|
|
return [$this->directory, $this->controller, $this->method, $this->params];
|
|
}
|
|
|
|
/**
|
|
* @internal For test purpose only.
|
|
*
|
|
* @return array<string, int|null>
|
|
*/
|
|
public function getPos(): array
|
|
{
|
|
return [
|
|
'controller' => $this->controllerPos,
|
|
'method' => $this->methodPos,
|
|
'params' => $this->paramPos,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get the directory path from the controller and set it to the property.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function setDirectory()
|
|
{
|
|
$segments = explode('\\', trim($this->controller, '\\'));
|
|
|
|
// Remove short classname.
|
|
array_pop($segments);
|
|
|
|
$namespaces = implode('\\', $segments);
|
|
|
|
$dir = str_replace(
|
|
'\\',
|
|
'/',
|
|
ltrim(substr($namespaces, strlen($this->namespace)), '\\'),
|
|
);
|
|
|
|
if ($dir !== '') {
|
|
$this->directory = $dir . '/';
|
|
}
|
|
}
|
|
|
|
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(): void
|
|
{
|
|
try {
|
|
$refClass = new ReflectionClass($this->controller);
|
|
} catch (ReflectionException) {
|
|
throw PageNotFoundException::forControllerNotFound($this->controller, $this->method);
|
|
}
|
|
|
|
try {
|
|
$refMethod = $refClass->getMethod($this->method);
|
|
$refParams = $refMethod->getParameters();
|
|
} catch (ReflectionException) {
|
|
throw new MethodNotFoundException();
|
|
}
|
|
|
|
if (! $refMethod->isPublic()) {
|
|
throw new MethodNotFoundException();
|
|
}
|
|
|
|
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:' . $this->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) {
|
|
// Do nothing.
|
|
}
|
|
}
|
|
|
|
private function checkUnderscore(): void
|
|
{
|
|
if ($this->translateURIDashes === false) {
|
|
return;
|
|
}
|
|
|
|
$paramPos = $this->paramPos ?? count($this->segments);
|
|
|
|
for ($i = 0; $i < $paramPos; $i++) {
|
|
if (str_contains($this->segments[$i], '_')) {
|
|
throw new PageNotFoundException(
|
|
'AutoRouterImproved prohibits access to the URI'
|
|
. ' containing underscores ("' . $this->segments[$i] . '")'
|
|
. ' when $translateURIDashes is enabled.'
|
|
. ' Please use the dash.'
|
|
. ' Handler:' . $this->controller . '::' . $this->method
|
|
. ', URI:' . $this->uri,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check URI for controller for $translateUriToCamelCase
|
|
*
|
|
* @param string $classname Controller classname that is generated from URI.
|
|
* The case may be a bit incorrect.
|
|
*/
|
|
private function checkUriForController(string $classname): void
|
|
{
|
|
if ($this->translateUriToCamelCase === false) {
|
|
return;
|
|
}
|
|
|
|
if (! in_array(ltrim($classname, '\\'), get_declared_classes(), true)) {
|
|
throw new PageNotFoundException(
|
|
'"' . $classname . '" is not found.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check URI for method for $translateUriToCamelCase
|
|
*
|
|
* @param string $method Controller method name that is generated from URI.
|
|
* The case may be a bit incorrect.
|
|
*/
|
|
private function checkUriForMethod(string $method): void
|
|
{
|
|
if ($this->translateUriToCamelCase === false) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
// For example, if `getSomeMethod()` exists in the controller, only
|
|
// the URI `controller/some-method` should be accessible. But if a
|
|
// visitor navigates to the URI `controller/somemethod`, `getSomemethod()`
|
|
// will be checked, and `method_exists()` will return true because
|
|
// method names in PHP are case-insensitive.
|
|
method_exists($this->controller, $method)
|
|
// But we do not permit `controller/somemethod`, so check the exact
|
|
// method name.
|
|
&& ! in_array($method, get_class_methods($this->controller), true)
|
|
) {
|
|
throw new PageNotFoundException(
|
|
'"' . $this->controller . '::' . $method . '()" is not found.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* Translates URI segment to CamelCase or replaces `-` with `_`.
|
|
*/
|
|
private function translateURI(string $segment): string
|
|
{
|
|
if ($this->translateUriToCamelCase) {
|
|
if (strtolower($segment) !== $segment) {
|
|
throw new PageNotFoundException(
|
|
'AutoRouterImproved prohibits access to the URI'
|
|
. ' containing uppercase letters ("' . $segment . '")'
|
|
. ' when $translateUriToCamelCase is enabled.'
|
|
. ' Please use the dash.'
|
|
. ' URI:' . $this->uri,
|
|
);
|
|
}
|
|
|
|
if (str_contains($segment, '--')) {
|
|
throw new PageNotFoundException(
|
|
'AutoRouterImproved prohibits access to the URI'
|
|
. ' containing double dash ("' . $segment . '")'
|
|
. ' when $translateUriToCamelCase is enabled.'
|
|
. ' Please use the single dash.'
|
|
. ' URI:' . $this->uri,
|
|
);
|
|
}
|
|
|
|
return str_replace(
|
|
' ',
|
|
'',
|
|
ucwords(
|
|
preg_replace('/[\-]+/', ' ', $segment),
|
|
),
|
|
);
|
|
}
|
|
|
|
$segment = ucfirst($segment);
|
|
|
|
if ($this->translateURIDashes) {
|
|
return str_replace('-', '_', $segment);
|
|
}
|
|
|
|
return $segment;
|
|
}
|
|
}
|