diff --git a/.github/workflows/test-phpcpd.yml b/.github/workflows/test-phpcpd.yml index fe75c0929193..f08945514386 100644 --- a/.github/workflows/test-phpcpd.yml +++ b/.github/workflows/test-phpcpd.yml @@ -54,4 +54,6 @@ jobs: --exclude system/Database/MySQLi/Builder.php --exclude system/Database/OCI8/Builder.php --exclude system/Database/Postgre/Builder.php + --exclude system/Debug/Exceptions.php + --exclude system/HTTP/SiteURI.php -- app/ public/ system/ diff --git a/admin/css/debug-toolbar/toolbar.scss b/admin/css/debug-toolbar/toolbar.scss index 23e0807fabfc..01dfd2207b15 100644 --- a/admin/css/debug-toolbar/toolbar.scss +++ b/admin/css/debug-toolbar/toolbar.scss @@ -198,7 +198,7 @@ overflow: hidden; overflow-y: auto; padding: 0 12px 0 12px; - + // Give room for OS X scrollbar white-space: nowrap; z-index: 10000; @@ -501,3 +501,13 @@ .debug-bar-noverflow { overflow: hidden; } + +/* ENDLESS ROTATE */ +.rotate { + animation: rotate 9s linear infinite; +} +@keyframes rotate { + to { + transform: rotate(360deg); + } +} diff --git a/app/Config/App.php b/app/Config/App.php index f04703ba228c..186bfa86bb02 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -3,7 +3,6 @@ namespace Config; use CodeIgniter\Config\BaseConfig; -use CodeIgniter\Session\Handlers\FileHandler; class App extends BaseConfig { @@ -136,191 +135,6 @@ class App extends BaseConfig */ public bool $forceGlobalSecureRequests = false; - /** - * -------------------------------------------------------------------------- - * Session Driver - * -------------------------------------------------------------------------- - * - * The session storage driver to use: - * - `CodeIgniter\Session\Handlers\FileHandler` - * - `CodeIgniter\Session\Handlers\DatabaseHandler` - * - `CodeIgniter\Session\Handlers\MemcachedHandler` - * - `CodeIgniter\Session\Handlers\RedisHandler` - * - * @deprecated use Config\Session::$driver instead. - */ - public string $sessionDriver = FileHandler::class; - - /** - * -------------------------------------------------------------------------- - * Session Cookie Name - * -------------------------------------------------------------------------- - * - * The session cookie name, must contain only [0-9a-z_-] characters - * - * @deprecated use Config\Session::$cookieName instead. - */ - public string $sessionCookieName = 'ci_session'; - - /** - * -------------------------------------------------------------------------- - * Session Expiration - * -------------------------------------------------------------------------- - * - * The number of SECONDS you want the session to last. - * Setting to 0 (zero) means expire when the browser is closed. - * - * @deprecated use Config\Session::$expiration instead. - */ - public int $sessionExpiration = 7200; - - /** - * -------------------------------------------------------------------------- - * Session Save Path - * -------------------------------------------------------------------------- - * - * The location to save sessions to and is driver dependent. - * - * For the 'files' driver, it's a path to a writable directory. - * WARNING: Only absolute paths are supported! - * - * For the 'database' driver, it's a table name. - * Please read up the manual for the format with other session drivers. - * - * IMPORTANT: You are REQUIRED to set a valid save path! - * - * @deprecated use Config\Session::$savePath instead. - */ - public string $sessionSavePath = WRITEPATH . 'session'; - - /** - * -------------------------------------------------------------------------- - * Session Match IP - * -------------------------------------------------------------------------- - * - * Whether to match the user's IP address when reading the session data. - * - * WARNING: If you're using the database driver, don't forget to update - * your session table's PRIMARY KEY when changing this setting. - * - * @deprecated use Config\Session::$matchIP instead. - */ - public bool $sessionMatchIP = false; - - /** - * -------------------------------------------------------------------------- - * Session Time to Update - * -------------------------------------------------------------------------- - * - * How many seconds between CI regenerating the session ID. - * - * @deprecated use Config\Session::$timeToUpdate instead. - */ - public int $sessionTimeToUpdate = 300; - - /** - * -------------------------------------------------------------------------- - * Session Regenerate Destroy - * -------------------------------------------------------------------------- - * - * Whether to destroy session data associated with the old session ID - * when auto-regenerating the session ID. When set to FALSE, the data - * will be later deleted by the garbage collector. - * - * @deprecated use Config\Session::$regenerateDestroy instead. - */ - public bool $sessionRegenerateDestroy = false; - - /** - * -------------------------------------------------------------------------- - * Session Database Group - * -------------------------------------------------------------------------- - * - * DB Group for the database session. - * - * @deprecated use Config\Session::$DBGroup instead. - */ - public ?string $sessionDBGroup = null; - - /** - * -------------------------------------------------------------------------- - * Cookie Prefix - * -------------------------------------------------------------------------- - * - * Set a cookie name prefix if you need to avoid collisions. - * - * @deprecated use Config\Cookie::$prefix property instead. - */ - public string $cookiePrefix = ''; - - /** - * -------------------------------------------------------------------------- - * Cookie Domain - * -------------------------------------------------------------------------- - * - * Set to `.your-domain.com` for site-wide cookies. - * - * @deprecated use Config\Cookie::$domain property instead. - */ - public string $cookieDomain = ''; - - /** - * -------------------------------------------------------------------------- - * Cookie Path - * -------------------------------------------------------------------------- - * - * Typically will be a forward slash. - * - * @deprecated use Config\Cookie::$path property instead. - */ - public string $cookiePath = '/'; - - /** - * -------------------------------------------------------------------------- - * Cookie Secure - * -------------------------------------------------------------------------- - * - * Cookie will only be set if a secure HTTPS connection exists. - * - * @deprecated use Config\Cookie::$secure property instead. - */ - public bool $cookieSecure = false; - - /** - * -------------------------------------------------------------------------- - * Cookie HttpOnly - * -------------------------------------------------------------------------- - * - * Cookie will only be accessible via HTTP(S) (no JavaScript). - * - * @deprecated use Config\Cookie::$httponly property instead. - */ - public bool $cookieHTTPOnly = true; - - /** - * -------------------------------------------------------------------------- - * Cookie SameSite - * -------------------------------------------------------------------------- - * - * Configure cookie SameSite setting. Allowed values are: - * - None - * - Lax - * - Strict - * - '' - * - * Alternatively, you can use the constant names: - * - `Cookie::SAMESITE_NONE` - * - `Cookie::SAMESITE_LAX` - * - `Cookie::SAMESITE_STRICT` - * - * Defaults to `Lax` for compatibility with modern browsers. Setting `''` - * (empty string) means default SameSite attribute set by browsers (`Lax`) - * will be set on cookies. If set to `None`, `$cookieSecure` must also be set. - * - * @deprecated use Config\Cookie::$samesite property instead. - */ - public ?string $cookieSameSite = 'Lax'; - /** * -------------------------------------------------------------------------- * Reverse Proxy IPs @@ -344,91 +158,6 @@ class App extends BaseConfig */ public array $proxyIPs = []; - /** - * -------------------------------------------------------------------------- - * CSRF Token Name - * -------------------------------------------------------------------------- - * - * The token name. - * - * @deprecated Use `Config\Security` $tokenName property instead of using this property. - */ - public string $CSRFTokenName = 'csrf_test_name'; - - /** - * -------------------------------------------------------------------------- - * CSRF Header Name - * -------------------------------------------------------------------------- - * - * The header name. - * - * @deprecated Use `Config\Security` $headerName property instead of using this property. - */ - public string $CSRFHeaderName = 'X-CSRF-TOKEN'; - - /** - * -------------------------------------------------------------------------- - * CSRF Cookie Name - * -------------------------------------------------------------------------- - * - * The cookie name. - * - * @deprecated Use `Config\Security` $cookieName property instead of using this property. - */ - public string $CSRFCookieName = 'csrf_cookie_name'; - - /** - * -------------------------------------------------------------------------- - * CSRF Expire - * -------------------------------------------------------------------------- - * - * The number in seconds the token should expire. - * - * @deprecated Use `Config\Security` $expire property instead of using this property. - */ - public int $CSRFExpire = 7200; - - /** - * -------------------------------------------------------------------------- - * CSRF Regenerate - * -------------------------------------------------------------------------- - * - * Regenerate token on every submission? - * - * @deprecated Use `Config\Security` $regenerate property instead of using this property. - */ - public bool $CSRFRegenerate = true; - - /** - * -------------------------------------------------------------------------- - * CSRF Redirect - * -------------------------------------------------------------------------- - * - * Redirect to previous page with error on failure? - * - * @deprecated Use `Config\Security` $redirect property instead of using this property. - */ - public bool $CSRFRedirect = false; - - /** - * -------------------------------------------------------------------------- - * CSRF SameSite - * -------------------------------------------------------------------------- - * - * Setting for CSRF SameSite cookie token. Allowed values are: - * - None - * - Lax - * - Strict - * - '' - * - * Defaults to `Lax` as recommended in this link: - * - * @see https://portswigger.net/web-security/csrf/samesite-cookies - * - * @deprecated `Config\Cookie` $samesite property is used. - */ - public string $CSRFSameSite = 'Lax'; - /** * -------------------------------------------------------------------------- * Content Security Policy diff --git a/app/Config/CURLRequest.php b/app/Config/CURLRequest.php index 6c3ed74aa987..5a3d4e9311b2 100644 --- a/app/Config/CURLRequest.php +++ b/app/Config/CURLRequest.php @@ -16,5 +16,5 @@ class CURLRequest extends BaseConfig * If true, all the options won't be reset between requests. * It may cause an error request with unnecessary headers. */ - public bool $shareOptions = true; + public bool $shareOptions = false; } diff --git a/app/Config/Cookie.php b/app/Config/Cookie.php index 440af5ee8070..84ccc0e99d80 100644 --- a/app/Config/Cookie.php +++ b/app/Config/Cookie.php @@ -84,6 +84,8 @@ class Cookie extends BaseConfig * Defaults to `Lax` for compatibility with modern browsers. Setting `''` * (empty string) means default SameSite attribute set by browsers (`Lax`) * will be set on cookies. If set to `None`, `$secure` must also be set. + * + * @phpstan-var 'None'|'Lax'|'Strict'|'' */ public string $samesite = 'Lax'; diff --git a/app/Config/Database.php b/app/Config/Database.php index 2c092124550f..e2450ec16cf1 100644 --- a/app/Config/Database.php +++ b/app/Config/Database.php @@ -25,23 +25,24 @@ class Database extends Config * The default database connection. */ public array $default = [ - 'DSN' => '', - 'hostname' => 'localhost', - 'username' => '', - 'password' => '', - 'database' => '', - 'DBDriver' => 'MySQLi', - 'DBPrefix' => '', - 'pConnect' => false, - 'DBDebug' => true, - 'charset' => 'utf8', - 'DBCollat' => 'utf8_general_ci', - 'swapPre' => '', - 'encrypt' => false, - 'compress' => false, - 'strictOn' => false, - 'failover' => [], - 'port' => 3306, + 'DSN' => '', + 'hostname' => 'localhost', + 'username' => '', + 'password' => '', + 'database' => '', + 'DBDriver' => 'MySQLi', + 'DBPrefix' => '', + 'pConnect' => false, + 'DBDebug' => true, + 'charset' => 'utf8', + 'DBCollat' => 'utf8_general_ci', + 'swapPre' => '', + 'encrypt' => false, + 'compress' => false, + 'strictOn' => false, + 'failover' => [], + 'port' => 3306, + 'numberNative' => false, ]; /** diff --git a/app/Config/Events.php b/app/Config/Events.php index 5219f4ac3f68..993abd24ebc7 100644 --- a/app/Config/Events.php +++ b/app/Config/Events.php @@ -4,6 +4,7 @@ use CodeIgniter\Events\Events; use CodeIgniter\Exceptions\FrameworkException; +use CodeIgniter\HotReloader\HotReloader; /* * -------------------------------------------------------------------- @@ -44,5 +45,11 @@ if (CI_DEBUG && ! is_cli()) { Events::on('DBQuery', 'CodeIgniter\Debug\Toolbar\Collectors\Database::collect'); Services::toolbar()->respond(); + // Hot Reload route - for framework use on the hot reloader. + if (ENVIRONMENT === 'development') { + Services::routes()->get('__hot-reload', static function () { + (new HotReloader())->run(); + }); + } } }); diff --git a/app/Config/Exceptions.php b/app/Config/Exceptions.php index bf3a1b964aeb..4173dcdd1c70 100644 --- a/app/Config/Exceptions.php +++ b/app/Config/Exceptions.php @@ -3,7 +3,10 @@ namespace Config; use CodeIgniter\Config\BaseConfig; +use CodeIgniter\Debug\ExceptionHandler; +use CodeIgniter\Debug\ExceptionHandlerInterface; use Psr\Log\LogLevel; +use Throwable; /** * Setup how the exception handler works. @@ -74,4 +77,28 @@ class Exceptions extends BaseConfig * to capture logging the deprecations. */ public string $deprecationLogLevel = LogLevel::WARNING; + + /* + * DEFINE THE HANDLERS USED + * -------------------------------------------------------------------------- + * Given the HTTP status code, returns exception handler that + * should be used to deal with this error. By default, it will run CodeIgniter's + * default handler and display the error information in the expected format + * for CLI, HTTP, or AJAX requests, as determined by is_cli() and the expected + * response format. + * + * Custom handlers can be returned if you want to handle one or more specific + * error codes yourself like: + * + * if (in_array($statusCode, [400, 404, 500])) { + * return new \App\Libraries\MyExceptionHandler(); + * } + * if ($exception instanceOf PageNotFoundException) { + * return new \App\Libraries\MyExceptionHandler(); + * } + */ + public function handler(int $statusCode, Throwable $exception): ExceptionHandlerInterface + { + return new ExceptionHandler($this); + } } diff --git a/app/Config/Filters.php b/app/Config/Filters.php index f751b8c2b581..8c02a4acd331 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -14,6 +14,9 @@ class Filters extends BaseConfig /** * Configures aliases for Filter classes to * make reading things nicer and simpler. + * + * @var array + * @phpstan-var array */ public array $aliases = [ 'csrf' => CSRF::class, @@ -26,6 +29,9 @@ class Filters extends BaseConfig /** * List of filter aliases that are always * applied before and after every request. + * + * @var array>>|array> + * @phpstan-var array>|array>> */ public array $globals = [ 'before' => [ diff --git a/app/Config/Routes.php b/app/Config/Routes.php index c251ec22c4b4..fc4914a6923b 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1,49 +1,8 @@ setDefaultNamespace('App\Controllers'); -$routes->setDefaultController('Home'); -$routes->setDefaultMethod('index'); -$routes->setTranslateURIDashes(false); -$routes->set404Override(); -// The Auto Routing (Legacy) is very dangerous. It is easy to create vulnerable apps -// where controller filters or CSRF protection are bypassed. -// If you don't want to define all routes, please use the Auto Routing (Improved). -// Set `$autoRoutesImproved` to true in `app/Config/Feature.php` and set the following to true. -// $routes->setAutoRoute(false); - -/* - * -------------------------------------------------------------------- - * Route Definitions - * -------------------------------------------------------------------- +/** + * @var RouteCollection $routes */ - -// We get a performance increase by specifying the default -// route since we don't have to scan directories. $routes->get('/', 'Home::index'); - -/* - * -------------------------------------------------------------------- - * Additional Routing - * -------------------------------------------------------------------- - * - * There will often be times that you need additional routing and you - * need it to be able to override any defaults in this file. Environment - * based routes is one such time. require() additional route files here - * to make that happen. - * - * You will have access to the $routes object within that file without - * needing to reload it. - */ -if (is_file(APPPATH . 'Config/' . ENVIRONMENT . '/Routes.php')) { - require APPPATH . 'Config/' . ENVIRONMENT . '/Routes.php'; -} diff --git a/app/Config/Routing.php b/app/Config/Routing.php new file mode 100644 index 000000000000..8d3c773157cf --- /dev/null +++ b/app/Config/Routing.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Config; + +use CodeIgniter\Config\Routing as BaseRouting; + +/** + * Routing configuration + */ +class Routing extends BaseRouting +{ + /** + * An array of files that contain route definitions. + * Route files are read in order, with the first match + * found taking precedence. + * + * Default: APPPATH . 'Config/Routes.php' + */ + public array $routeFiles = [ + APPPATH . 'Config/Routes.php', + ]; + + /** + * The default namespace to use for Controllers when no other + * namespace has been specified. + * + * Default: 'App\Controllers' + */ + public string $defaultNamespace = 'App\Controllers'; + + /** + * The default controller to use when no other controller has been + * specified. + * + * Default: 'Home' + */ + public string $defaultController = 'Home'; + + /** + * The default method to call on the controller when no other + * method has been set in the route. + * + * Default: 'index' + */ + public string $defaultMethod = 'index'; + + /** + * Whether to translate dashes in URIs to underscores. + * Primarily useful when using the auto-routing. + * + * Default: false + */ + public bool $translateURIDashes = false; + + /** + * Sets the class/method that should be called if routing doesn't + * find a match. It can be either a closure or the controller/method + * name exactly like a route is defined: Users::index + * + * This setting is passed to the Router class and handled there. + * + * If you want to use a closure, you will have to set it in the + * class constructor or the routes file by calling: + * + * $routes->set404Override(function() { + * // Do something here + * }); + * + * Example: + * public $override404 = 'App\Errors::show404'; + */ + public ?string $override404 = null; + + /** + * If TRUE, the system will attempt to match the URI against + * Controllers by matching each segment against folders/files + * in APPPATH/Controllers, when a match wasn't found against + * defined routes. + * + * If FALSE, will stop searching and do NO automatic routing. + */ + public bool $autoRoute = false; + + /** + * If TRUE, will enable the use of the 'prioritize' option + * when defining routes. + * + * Default: false + */ + public bool $prioritize = false; + + /** + * Map of URI segments and namespaces. For Auto Routing (Improved). + * + * The key is the first URI segment. The value is the controller namespace. + * E.g., + * [ + * 'blog' => 'Acme\Blog\Controllers', + * ] + * + * @var array [ uri_segment => namespace ] + */ + public array $moduleRoutes = []; +} diff --git a/app/Config/Toolbar.php b/app/Config/Toolbar.php index ecab7a2cc1d3..97fbda281287 100644 --- a/app/Config/Toolbar.php +++ b/app/Config/Toolbar.php @@ -88,4 +88,31 @@ class Toolbar extends BaseConfig * `$maxQueries` defines the maximum amount of queries that will be stored. */ public int $maxQueries = 100; + + /** + * -------------------------------------------------------------------------- + * Watched Directories + * -------------------------------------------------------------------------- + * + * Contains an array of directories that will be watched for changes and + * used to determine if the hot-reload feature should reload the page or not. + * We restrict the values to keep performance as high as possible. + * + * NOTE: The ROOTPATH will be prepended to all values. + */ + public array $watchedDirectories = [ + 'app', + ]; + + /** + * -------------------------------------------------------------------------- + * Watched File Extensions + * -------------------------------------------------------------------------- + * + * Contains an array of file extensions that will be watched for changes and + * used to determine if the hot-reload feature should reload the page or not. + */ + public array $watchedExtensions = [ + 'php', 'css', 'js', 'html', 'svg', 'json', 'env', + ]; } diff --git a/deptrac.yaml b/deptrac.yaml index 6b1d0b9818e4..271378bfde55 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -223,6 +223,10 @@ parameters: - Cache skip_violations: # Individual class exemptions + CodeIgniter\Cache\ResponseCache: + - CodeIgniter\HTTP\CLIRequest + - CodeIgniter\HTTP\IncomingRequest + - CodeIgniter\HTTP\ResponseInterface CodeIgniter\Entity\Cast\URICast: - CodeIgniter\HTTP\URI CodeIgniter\Log\Handlers\ChromeLoggerHandler: diff --git a/phpstan-baseline.php b/phpstan-baseline.php index f09ce499faca..9dea6edd8884 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -11,16 +11,6 @@ 'count' => 1, 'path' => __DIR__ . '/app/Config/View.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$callback of function spl_autoload_register expects \\(callable\\(string\\)\\: void\\)\\|null, array\\{\\$this\\(CodeIgniter\\\\Autoloader\\\\Autoloader\\), \'loadClass\'\\} given\\.$#', - 'count' => 1, - 'path' => __DIR__ . '/system/Autoloader/Autoloader.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Parameter \\#1 \\$callback of function spl_autoload_register expects \\(callable\\(string\\)\\: void\\)\\|null, array\\{\\$this\\(CodeIgniter\\\\Autoloader\\\\Autoloader\\), \'loadClassmap\'\\} given\\.$#', - 'count' => 1, - 'path' => __DIR__ . '/system/Autoloader/Autoloader.php', -]; $ignoreErrors[] = [ 'message' => '#^Method CodeIgniter\\\\BaseModel\\:\\:chunk\\(\\) has parameter \\$userFunc with no signature specified for Closure\\.$#', 'count' => 1, @@ -71,16 +61,6 @@ 'count' => 1, 'path' => __DIR__ . '/system/Cache/Handlers/WincacheHandler.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Call to an undefined method CodeIgniter\\\\HTTP\\\\Request\\:\\:getPost\\(\\)\\.$#', - 'count' => 1, - 'path' => __DIR__ . '/system/CodeIgniter.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Call to an undefined method CodeIgniter\\\\HTTP\\\\Request\\:\\:setLocale\\(\\)\\.$#', - 'count' => 1, - 'path' => __DIR__ . '/system/CodeIgniter.php', -]; $ignoreErrors[] = [ 'message' => '#^Method CodeIgniter\\\\CodeIgniter\\:\\:bootstrapEnvironment\\(\\) has no return type specified\\.$#', 'count' => 1, @@ -1141,11 +1121,6 @@ 'count' => 1, 'path' => __DIR__ . '/system/Session/Session.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Property CodeIgniter\\\\Session\\\\Session\\:\\:\\$sessionExpiration \\(int\\) in isset\\(\\) is not nullable\\.$#', - 'count' => 1, - 'path' => __DIR__ . '/system/Session/Session.php', -]; $ignoreErrors[] = [ 'message' => '#^Method CodeIgniter\\\\Session\\\\SessionInterface\\:\\:destroy\\(\\) has no return type specified\\.$#', 'count' => 1, diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 0c8230dcebdd..3ede420d002c 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,10 @@ - + + + + $val + + Memcache @@ -82,6 +87,9 @@ $routes $routes + $routes + $routes + $routes @@ -101,7 +109,9 @@ - + + + diff --git a/public/index.php b/public/index.php index d49672c6d1e7..1cc4710549d5 100644 --- a/public/index.php +++ b/public/index.php @@ -16,7 +16,9 @@ define('FCPATH', __DIR__ . DIRECTORY_SEPARATOR); // Ensure the current directory is pointing to the front controller's directory -chdir(FCPATH); +if (getcwd() . DIRECTORY_SEPARATOR !== FCPATH) { + chdir(FCPATH); +} /* *--------------------------------------------------------------- @@ -41,6 +43,16 @@ require_once SYSTEMPATH . 'Config/DotEnv.php'; (new CodeIgniter\Config\DotEnv(ROOTPATH))->load(); +// Define ENVIRONMENT +if (! defined('ENVIRONMENT')) { + define('ENVIRONMENT', env('CI_ENVIRONMENT', 'production')); +} + +// Load Config Cache +// $factoriesCache = new \CodeIgniter\Cache\FactoriesCache(); +// $factoriesCache->load('config'); +// ^^^ Uncomment these lines if you want to use Config Caching. + /* * --------------------------------------------------------------- * GRAB OUR CODEIGNITER INSTANCE @@ -65,3 +77,11 @@ */ $app->run(); + +// Save Config Cache +// $factoriesCache->save('config'); +// ^^^ Uncomment this line if you want to use Config Caching. + +// Exits the application, setting the exit code for CLI-based applications +// that might be watching. +exit(EXIT_SUCCESS); diff --git a/rector.php b/rector.php index d127556c568c..fc00f27e0c4e 100644 --- a/rector.php +++ b/rector.php @@ -84,8 +84,12 @@ ], RemoveUnusedConstructorParamRector::class => [ + // there are deprecated parameters + __DIR__ . '/system/Debug/Exceptions.php', // @TODO remove if deprecated $httpVerb is removed __DIR__ . '/system/Router/AutoRouterImproved.php', + // @TODO remove if deprecated $config is removed + __DIR__ . '/system/HTTP/Request.php', ], // check on constant compare diff --git a/spark b/spark index f2ba3f305ceb..2ea79d5ccdaf 100755 --- a/spark +++ b/spark @@ -78,6 +78,11 @@ require rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'bootstra require_once SYSTEMPATH . 'Config/DotEnv.php'; (new CodeIgniter\Config\DotEnv(ROOTPATH))->load(); +// Define ENVIRONMENT +if (! defined('ENVIRONMENT')) { + define('ENVIRONMENT', env('CI_ENVIRONMENT', 'production')); +} + // Grab our CodeIgniter $app = Config\Services::codeigniter(); $app->initialize(); diff --git a/system/Autoloader/Autoloader.php b/system/Autoloader/Autoloader.php index 65be3d7d3cee..d5c155685e12 100644 --- a/system/Autoloader/Autoloader.php +++ b/system/Autoloader/Autoloader.php @@ -240,33 +240,27 @@ public function removeNamespace(string $namespace) /** * Load a class using available class mapping. * - * @return false|string + * @internal For `spl_autoload_register` use. */ - public function loadClassmap(string $class) + public function loadClassmap(string $class): void { $file = $this->classmap[$class] ?? ''; if (is_string($file) && $file !== '') { - return $this->includeFile($file); + $this->includeFile($file); } - - return false; } /** * Loads the class file for a given class name. * - * @param string $class The fully qualified class name. + * @internal For `spl_autoload_register` use. * - * @return false|string The mapped file on success, or boolean false - * on failure. + * @param string $class The fully qualified class name. */ - public function loadClass(string $class) + public function loadClass(string $class): void { - $class = trim($class, '\\'); - $class = str_ireplace('.php', '', $class); - - return $this->loadInNamespace($class); + $this->loadInNamespace($class); } /** @@ -308,8 +302,6 @@ protected function loadInNamespace(string $class) */ protected function includeFile(string $file) { - $file = $this->sanitizeFilename($file); - if (is_file($file)) { include_once $file; @@ -329,6 +321,8 @@ protected function includeFile(string $file) * and end of filename. * * @return string The sanitized filename + * + * @deprecated No longer used. See https://github.com/codeigniter4/CodeIgniter4/issues/7055 */ public function sanitizeFilename(string $filename): string { diff --git a/system/Cache/FactoriesCache.php b/system/Cache/FactoriesCache.php new file mode 100644 index 000000000000..d78d0b1b02a4 --- /dev/null +++ b/system/Cache/FactoriesCache.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +use CodeIgniter\Cache\FactoriesCache\FileVarExportHandler; +use CodeIgniter\Config\Factories; + +final class FactoriesCache +{ + /** + * @var CacheInterface|FileVarExportHandler + */ + private $cache; + + /** + * @param CacheInterface|FileVarExportHandler|null $cache + */ + public function __construct($cache = null) + { + $this->cache = $cache ?? new FileVarExportHandler(); + } + + public function save(string $component): void + { + if (! Factories::isUpdated($component)) { + return; + } + + $data = Factories::getComponentInstances($component); + + $this->cache->save($this->getCacheKey($component), $data, 3600 * 24); + } + + private function getCacheKey(string $component): string + { + return 'FactoriesCache_' . $component; + } + + public function load(string $component): bool + { + $key = $this->getCacheKey($component); + + if (! $data = $this->cache->get($key)) { + return false; + } + + Factories::setComponentInstances($component, $data); + + return true; + } + + public function delete(string $component): void + { + $this->cache->delete($this->getCacheKey($component)); + } +} diff --git a/system/Cache/FactoriesCache/FileVarExportHandler.php b/system/Cache/FactoriesCache/FileVarExportHandler.php new file mode 100644 index 000000000000..f7cee5ef6248 --- /dev/null +++ b/system/Cache/FactoriesCache/FileVarExportHandler.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache\FactoriesCache; + +final class FileVarExportHandler +{ + private string $path = WRITEPATH . 'cache'; + + /** + * @param array|bool|float|int|object|string|null $val + */ + public function save(string $key, $val): void + { + $val = var_export($val, true); + + // Write to temp file first to ensure atomicity + $tmp = $this->path . "/{$key}." . uniqid('', true) . '.tmp'; + file_put_contents($tmp, 'path . "/{$key}"); + } + + public function delete(string $key): void + { + @unlink($this->path . "/{$key}"); + } + + /** + * @return array|bool|float|int|object|string|null + */ + public function get(string $key) + { + return @include $this->path . "/{$key}"; + } +} diff --git a/system/Cache/ResponseCache.php b/system/Cache/ResponseCache.php new file mode 100644 index 000000000000..44da42947633 --- /dev/null +++ b/system/Cache/ResponseCache.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Cache as CacheConfig; +use Exception; + +/** + * Web Page Caching + */ +final class ResponseCache +{ + /** + * Whether to take the URL query string into consideration when generating + * output cache files. Valid options are: + * + * false = Disabled + * true = Enabled, take all query parameters into account. + * Please be aware that this may result in numerous cache + * files generated for the same page over and over again. + * array('q') = Enabled, but only take into account the specified list + * of query parameters. + * + * @var bool|string[] + */ + private $cacheQueryString = false; + + /** + * Cache time to live. + * + * @var int seconds + */ + private int $ttl = 0; + + private CacheInterface $cache; + + public function __construct(CacheConfig $config, CacheInterface $cache) + { + $this->cacheQueryString = $config->cacheQueryString; + $this->cache = $cache; + } + + /** + * @return $this + */ + public function setTtl(int $ttl) + { + $this->ttl = $ttl; + + return $this; + } + + /** + * Generates the cache key to use from the current request. + * + * @param CLIRequest|IncomingRequest $request + * + * @internal for testing purposes only + */ + public function generateCacheKey($request): string + { + if ($request instanceof CLIRequest) { + return md5($request->getPath()); + } + + $uri = clone $request->getUri(); + + $query = $this->cacheQueryString + ? $uri->getQuery(is_array($this->cacheQueryString) ? ['only' => $this->cacheQueryString] : []) + : ''; + + return md5($uri->setFragment('')->setQuery($query)); + } + + /** + * Caches the response. + * + * @param CLIRequest|IncomingRequest $request + */ + public function make($request, ResponseInterface $response): bool + { + if ($this->ttl === 0) { + return true; + } + + $headers = []; + + foreach ($response->headers() as $header) { + $headers[$header->getName()] = $header->getValueLine(); + } + + return $this->cache->save( + $this->generateCacheKey($request), + serialize(['headers' => $headers, 'output' => $response->getBody()]), + $this->ttl + ); + } + + /** + * Gets the cached response for the request. + * + * @param CLIRequest|IncomingRequest $request + */ + public function get($request, ResponseInterface $response): ?ResponseInterface + { + if ($cachedResponse = $this->cache->get($this->generateCacheKey($request))) { + $cachedResponse = unserialize($cachedResponse); + + if ( + ! is_array($cachedResponse) + || ! isset($cachedResponse['output']) + || ! isset($cachedResponse['headers']) + ) { + throw new Exception('Error unserializing page cache'); + } + + $headers = $cachedResponse['headers']; + $output = $cachedResponse['output']; + + // Clear all default headers + foreach (array_keys($response->headers()) as $key) { + $response->removeHeader($key); + } + + // Set cached headers + foreach ($headers as $name => $value) { + $response->setHeader($name, $value); + } + + $response->setBody($output); + + return $response; + } + + return null; + } +} diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index 198602320146..4a249ca7dbb1 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -12,18 +12,21 @@ namespace CodeIgniter; use Closure; +use CodeIgniter\Cache\ResponseCache; use CodeIgniter\Debug\Timer; use CodeIgniter\Events\Events; use CodeIgniter\Exceptions\FrameworkException; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\DownloadResponse; +use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\Request; +use CodeIgniter\HTTP\ResponsableInterface; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\URI; -use CodeIgniter\Router\Exceptions\RedirectException; +use CodeIgniter\Router\Exceptions\RedirectException as DeprecatedRedirectException; use CodeIgniter\Router\RouteCollectionInterface; use CodeIgniter\Router\Router; use Config\App; @@ -37,6 +40,7 @@ use Kint\Renderer\RichRenderer; use Locale; use LogicException; +use Throwable; /** * This class is the core of the framework, and will analyse the @@ -81,7 +85,7 @@ class CodeIgniter /** * Current request. * - * @var CLIRequest|IncomingRequest|Request|null + * @var CLIRequest|IncomingRequest|null */ protected $request; @@ -123,7 +127,9 @@ class CodeIgniter /** * Cache expiration time * - * @var int + * @var int seconds + * + * @deprecated 4.4.0 Moved to ResponseCache::$ttl. No longer used. */ protected static $cacheTTL = 0; @@ -162,9 +168,21 @@ class CodeIgniter /** * Whether to return Response object or send response. + * + * @deprecated No longer used. */ protected bool $returnResponse = false; + /** + * Application output buffering level + */ + protected int $bufferLevel; + + /** + * Web Page Caching + */ + protected ResponseCache $pageCache; + /** * Constructor. */ @@ -172,6 +190,8 @@ public function __construct(App $config) { $this->startTime = microtime(true); $this->config = $config; + + $this->pageCache = Services::responsecache(); } /** @@ -180,7 +200,6 @@ public function __construct(App $config) public function initialize() { // Define environment variables - $this->detectEnvironment(); $this->bootstrapEnvironment(); // Setup Exception Handling @@ -313,8 +332,6 @@ private function configureKint(): void */ public function run(?RouteCollectionInterface $routes = null, bool $returnResponse = false) { - $this->returnResponse = $returnResponse; - if ($this->context === null) { throw new LogicException( 'Context must be set before run() is called. If you are upgrading from 4.1.x, ' @@ -322,72 +339,38 @@ public function run(?RouteCollectionInterface $routes = null, bool $returnRespon ); } - static::$cacheTTL = 0; + $this->pageCache->setTtl(0); + $this->bufferLevel = ob_get_level(); $this->startBenchmark(); $this->getRequestObject(); $this->getResponseObject(); - $this->forceSecureAccess(); - $this->spoofRequestMethod(); - if ($this->request instanceof IncomingRequest && strtolower($this->request->getMethod()) === 'cli') { - $this->response->setStatusCode(405)->setBody('Method Not Allowed'); - - if ($this->returnResponse) { - return $this->response; + try { + $this->response = $this->handleRequest($routes, config(Cache::class), $returnResponse); + } catch (ResponsableInterface|DeprecatedRedirectException $e) { + $this->outputBufferingEnd(); + if ($e instanceof DeprecatedRedirectException) { + $e = new RedirectException($e->getMessage(), $e->getCode(), $e); } - $this->sendResponse(); + $this->response = $e->getResponse(); + } catch (PageNotFoundException $e) { + $this->response = $this->display404errors($e); + } catch (Throwable $e) { + $this->outputBufferingEnd(); - return; + throw $e; } - Events::trigger('pre_system'); - - // Check for a cached page. Execution will stop - // if the page has been cached. - $cacheConfig = new Cache(); - $response = $this->displayCache($cacheConfig); - if ($response instanceof ResponseInterface) { - if ($returnResponse) { - return $response; - } - - $this->response->send(); - $this->callExit(EXIT_SUCCESS); - - return; + if ($returnResponse) { + return $this->response; } - try { - return $this->handleRequest($routes, $cacheConfig, $returnResponse); - } catch (RedirectException $e) { - $logger = Services::logger(); - $logger->info('REDIRECTED ROUTE at ' . $e->getMessage()); - - // If the route is a 'redirect' route, it throws - // the exception with the $to as the message - $this->response->redirect(base_url($e->getMessage()), 'auto', $e->getCode()); - - if ($this->returnResponse) { - return $this->response; - } - - $this->sendResponse(); - - $this->callExit(EXIT_SUCCESS); - - return; - } catch (PageNotFoundException $e) { - $return = $this->display404errors($e); - - if ($return instanceof ResponseInterface) { - return $return; - } - } + $this->sendResponse(); } /** @@ -442,7 +425,19 @@ public function disableFilters(): void */ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cacheConfig, bool $returnResponse = false) { - $this->returnResponse = $returnResponse; + $this->forceSecureAccess(); + + if ($this->request instanceof IncomingRequest && strtolower($this->request->getMethod()) === 'cli') { + return $this->response->setStatusCode(405)->setBody('Method Not Allowed'); + } + + Events::trigger('pre_system'); + + // Check for a cached page. Execution will stop + // if the page has been cached. + if (($response = $this->displayCache($cacheConfig)) instanceof ResponseInterface) { + return $response; + } $routeFilter = $this->tryToRouteIt($routes); @@ -473,10 +468,10 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache // If a ResponseInterface instance is returned then send it back to the client and stop if ($possibleResponse instanceof ResponseInterface) { - return $this->returnResponse ? $possibleResponse : $possibleResponse->send(); + return $possibleResponse; } - if ($possibleResponse instanceof Request) { + if ($possibleResponse instanceof IncomingRequest || $possibleResponse instanceof CLIRequest) { $this->request = $possibleResponse; } } @@ -530,9 +525,7 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache // Cache it without the performance metrics replaced // so that we can have live speed updates along the way. // Must be run after filters to preserve the Response headers. - if (static::$cacheTTL > 0) { - $this->cachePage($cacheConfig); - } + $this->pageCache->make($this->request, $this->response); // Update the performance metrics $body = $this->response->getBody(); @@ -548,10 +541,6 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache unset($uri); - if (! $this->returnResponse) { - $this->sendResponse(); - } - // Is there a post-system event? Events::trigger('post_system'); @@ -570,6 +559,8 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache * production * * @codeCoverageIgnore + * + * @deprecated 4.4.0 No longer used. Moved to index.php and spark. */ protected function detectEnvironment() { @@ -620,9 +611,11 @@ protected function startBenchmark() * Sets a Request object to be used for this request. * Used when running certain tests. * + * @param CLIRequest|IncomingRequest $request + * * @return $this */ - public function setRequest(Request $request) + public function setRequest($request) { $this->request = $request; @@ -691,27 +684,11 @@ protected function forceSecureAccess($duration = 31_536_000) */ public function displayCache(Cache $config) { - if ($cachedResponse = cache()->get($this->generateCacheName($config))) { - $cachedResponse = unserialize($cachedResponse); - if (! is_array($cachedResponse) || ! isset($cachedResponse['output']) || ! isset($cachedResponse['headers'])) { - throw new Exception('Error unserializing page cache'); - } - - $headers = $cachedResponse['headers']; - $output = $cachedResponse['output']; - - // Clear all default headers - foreach (array_keys($this->response->headers()) as $key) { - $this->response->removeHeader($key); - } - - // Set cached headers - foreach ($headers as $name => $value) { - $this->response->setHeader($name, $value); - } + if ($cachedResponse = $this->pageCache->get($this->request, $this->response)) { + $this->response = $cachedResponse; $this->totalTime = $this->benchmark->getElapsedTime('total_execution'); - $output = $this->displayPerformanceMetrics($output); + $output = $this->displayPerformanceMetrics($cachedResponse->getBody()); $this->response->setBody($output); return $this->response; @@ -722,6 +699,8 @@ public function displayCache(Cache $config) /** * Tells the app that the final output should be cached. + * + * @deprecated 4.4.0 Moved to ResponseCache::setTtl(). to No longer used. */ public static function cache(int $time) { @@ -733,6 +712,8 @@ public static function cache(int $time) * full-page caching for very high performance. * * @return bool + * + * @deprecated 4.4.0 No longer used. */ public function cachePage(Cache $config) { @@ -758,6 +739,8 @@ public function getPerformanceStats(): array /** * Generates the cache name to use for our full-page caching. + * + * @deprecated 4.4.0 No longer used. */ protected function generateCacheName(Cache $config): string { @@ -808,7 +791,7 @@ protected function tryToRouteIt(?RouteCollectionInterface $routes = null) $this->benchmark->stop('bootstrap'); $this->benchmark->start('routing'); - ob_start(); + $this->outputBufferingStart(); $this->controller = $this->router->handle($path); $this->method = $this->router->methodName(); @@ -963,29 +946,17 @@ protected function display404errors(PageNotFoundException $e) unset($override); - $cacheConfig = new Cache(); + $cacheConfig = config(Cache::class); $this->gatherOutput($cacheConfig, $returned); - if ($this->returnResponse) { - return $this->response; - } - - $this->sendResponse(); - return; + return $this->response; } // Display 404 Errors $this->response->setStatusCode($e->getCode()); - if (ENVIRONMENT !== 'testing') { - if (ob_get_level() > 0) { - ob_end_flush(); // @codeCoverageIgnore - } - } - // When testing, one is for phpunit, another is for test case. - elseif (ob_get_level() > 2) { - ob_end_flush(); // @codeCoverageIgnore - } + echo $this->outputBufferingEnd(); + flush(); // Throws new PageNotFoundException and remove exception message on production. throw PageNotFoundException::forPageNotFound( @@ -1004,21 +975,9 @@ protected function display404errors(PageNotFoundException $e) */ protected function gatherOutput(?Cache $cacheConfig = null, $returned = null) { - $this->output = ob_get_contents(); - // If buffering is not null. - // Clean (erase) the output buffer and turn off output buffering - if (ob_get_length()) { - ob_end_clean(); - } + $this->output = $this->outputBufferingEnd(); if ($returned instanceof DownloadResponse) { - // Turn off output buffering completely, even if php.ini output_buffering is not off - if (ENVIRONMENT !== 'testing') { - while (ob_get_level() > 0) { - ob_end_clean(); - } - } - $this->response = $returned; return; @@ -1129,6 +1088,8 @@ protected function sendResponse() * without actually stopping script execution. * * @param int $code + * + * @deprecated 4.4.0 No longer Used. Moved to index.php. */ protected function callExit($code) { @@ -1148,4 +1109,22 @@ public function setContext(string $context) return $this; } + + protected function outputBufferingStart(): void + { + $this->bufferLevel = ob_get_level(); + ob_start(); + } + + protected function outputBufferingEnd(): string + { + $buffer = ''; + + while (ob_get_level() > $this->bufferLevel) { + $buffer .= ob_get_contents(); + ob_end_clean(); + } + + return $buffer; + } } diff --git a/system/Commands/Generators/MigrationGenerator.php b/system/Commands/Generators/MigrationGenerator.php index 5ffd259ec479..82428a6948b3 100644 --- a/system/Commands/Generators/MigrationGenerator.php +++ b/system/Commands/Generators/MigrationGenerator.php @@ -14,7 +14,6 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; use CodeIgniter\CLI\GeneratorTrait; -use Config\App as AppConfig; use Config\Database; use Config\Migrations; use Config\Session as SessionConfig; @@ -111,12 +110,10 @@ protected function prepare(string $class): string $data['DBGroup'] = is_string($DBGroup) ? $DBGroup : 'default'; $data['DBDriver'] = config(Database::class)->{$data['DBGroup']}['DBDriver']; - $config = config(AppConfig::class); /** @var SessionConfig|null $session */ $session = config(SessionConfig::class); - $data['matchIP'] = ($session instanceof SessionConfig) - ? $session->matchIP : $config->sessionMatchIP; + $data['matchIP'] = $session->matchIP; } return $this->parseTemplate($class, [], [], $data); diff --git a/system/Commands/Utilities/Routes.php b/system/Commands/Utilities/Routes.php index 4ee18331f781..b3693ec6860e 100644 --- a/system/Commands/Utilities/Routes.php +++ b/system/Commands/Utilities/Routes.php @@ -11,14 +11,15 @@ namespace CodeIgniter\Commands\Utilities; -use Closure; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; use CodeIgniter\Commands\Utilities\Routes\AutoRouteCollector; use CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved\AutoRouteCollector as AutoRouteCollectorImproved; use CodeIgniter\Commands\Utilities\Routes\FilterCollector; use CodeIgniter\Commands\Utilities\Routes\SampleURIGenerator; +use CodeIgniter\Router\DefinedRouteCollector; use Config\Feature; +use Config\Routing; use Config\Services; /** @@ -70,7 +71,8 @@ class Routes extends BaseCommand * @var array */ protected $options = [ - '-h' => 'Sort by Handler.', + '-h' => 'Sort by Handler.', + '--host' => 'Specify hostname in request URI.', ]; /** @@ -79,9 +81,24 @@ class Routes extends BaseCommand public function run(array $params) { $sortByHandler = array_key_exists('h', $params); + $host = $params['host'] ?? null; + + // Set HTTP_HOST + if ($host) { + $request = Services::request(); + $_SERVER = $request->getServer(); + $_SERVER['HTTP_HOST'] = $host; + $request->setGlobal('server', $_SERVER); + } $collection = Services::routes()->loadRoutes(); - $methods = [ + + // Reset HTTP_HOST + if ($host) { + unset($_SERVER['HTTP_HOST']); + } + + $methods = [ 'get', 'head', 'post', @@ -98,30 +115,22 @@ public function run(array $params) $uriGenerator = new SampleURIGenerator(); $filterCollector = new FilterCollector(); - foreach ($methods as $method) { - $routes = $collection->getRoutes($method); + $definedRouteCollector = new DefinedRouteCollector($collection); - foreach ($routes as $route => $handler) { - if (is_string($handler) || $handler instanceof Closure) { - $sampleUri = $uriGenerator->get($route); - $filters = $filterCollector->get($method, $sampleUri); + foreach ($definedRouteCollector->collect() as $route) { + $sampleUri = $uriGenerator->get($route['route']); + $filters = $filterCollector->get($route['method'], $sampleUri); - if ($handler instanceof Closure) { - $handler = '(Closure)'; - } - - $routeName = $collection->getRoutesOptions($route)['as'] ?? '»'; + $routeName = ($route['route'] === $route['name']) ? '»' : $route['name']; - $tbody[] = [ - strtoupper($method), - $route, - $routeName, - $handler, - implode(' ', array_map('class_basename', $filters['before'])), - implode(' ', array_map('class_basename', $filters['after'])), - ]; - } - } + $tbody[] = [ + strtoupper($route['method']), + $route['route'], + $routeName, + $route['handler'], + implode(' ', array_map('class_basename', $filters['before'])), + implode(' ', array_map('class_basename', $filters['after'])), + ]; } if ($collection->shouldAutoRoute()) { @@ -137,6 +146,22 @@ public function run(array $params) ); $autoRoutes = $autoRouteCollector->get(); + + // Check for Module Routes. + if ($routingConfig = config(Routing::class)) { + foreach ($routingConfig->moduleRoutes as $uri => $namespace) { + $autoRouteCollector = new AutoRouteCollectorImproved( + $namespace, + $collection->getDefaultController(), + $collection->getDefaultMethod(), + $methods, + $collection->getRegisteredControllers('*'), + $uri + ); + + $autoRoutes = [...$autoRoutes, ...$autoRouteCollector->get()]; + } + } } else { $autoRouteCollector = new AutoRouteCollector( $collection->getDefaultNamespace(), @@ -172,6 +197,10 @@ public function run(array $params) usort($tbody, static fn ($handler1, $handler2) => strcmp($handler1[3], $handler2[3])); } + if ($host) { + CLI::write('Host: ' . $host); + } + CLI::table($tbody, $thead); } } diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php index f2de8e4b4a6d..2b6096016f7e 100644 --- a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php +++ b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php @@ -35,6 +35,11 @@ final class AutoRouteCollector */ private array $protectedControllers; + /** + * @var string URI prefix for Module Routing + */ + private string $prefix; + /** * @param string $namespace namespace to search */ @@ -43,13 +48,15 @@ public function __construct( string $defaultController, string $defaultMethod, array $httpMethods, - array $protectedControllers + array $protectedControllers, + string $prefix = '' ) { $this->namespace = $namespace; $this->defaultController = $defaultController; $this->defaultMethod = $defaultMethod; $this->httpMethods = $httpMethods; $this->protectedControllers = $protectedControllers; + $this->prefix = $prefix; } /** @@ -82,9 +89,18 @@ public function get(): array $routes = $this->addFilters($routes); foreach ($routes as $item) { + $route = $item['route'] . $item['route_params']; + + // For module routing + if ($this->prefix !== '' && $route === '/') { + $route = $this->prefix; + } elseif ($this->prefix !== '') { + $route = $this->prefix . '/' . $route; + } + $tbody[] = [ strtoupper($item['method']) . '(auto)', - $item['route'] . $item['route_params'], + $route, '', $item['handler'], $item['before'], @@ -101,13 +117,22 @@ private function addFilters($routes) $filterCollector = new FilterCollector(true); foreach ($routes as &$route) { + $routePath = $route['route']; + + // For module routing + if ($this->prefix !== '' && $route === '/') { + $routePath = $this->prefix; + } elseif ($this->prefix !== '') { + $routePath = $this->prefix . '/' . $routePath; + } + // Search filters for the URI with all params $sampleUri = $this->generateSampleUri($route); - $filtersLongest = $filterCollector->get($route['method'], $route['route'] . $sampleUri); + $filtersLongest = $filterCollector->get($route['method'], $routePath . $sampleUri); // Search filters for the URI without optional params $sampleUri = $this->generateSampleUri($route, false); - $filtersShortest = $filterCollector->get($route['method'], $route['route'] . $sampleUri); + $filtersShortest = $filterCollector->get($route['method'], $routePath . $sampleUri); // Get common array elements $filters['before'] = array_intersect($filtersLongest['before'], $filtersShortest['before']); diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php index 9504347b8fa1..c91219c13a8e 100644 --- a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php +++ b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php @@ -73,7 +73,8 @@ public function read(string $class, string $defaultController = 'Home', string $ $classInUri, $classname, $methodName, - $httpVerb + $httpVerb, + $method ); if ($routeForDefaultController !== []) { @@ -85,13 +86,15 @@ public function read(string $class, string $defaultController = 'Home', string $ continue; } + [$params, $routeParams] = $this->getParameters($method); + // Route for the default method. $output[] = [ 'method' => $httpVerb, 'route' => $classInUri, - 'route_params' => '', + 'route_params' => $routeParams, 'handler' => '\\' . $classname . '::' . $methodName, - 'params' => [], + 'params' => $params, ]; continue; @@ -99,23 +102,7 @@ public function read(string $class, string $defaultController = 'Home', string $ $route = $classInUri . '/' . $methodInUri; - $params = []; - $routeParams = ''; - $refParams = $method->getParameters(); - - foreach ($refParams as $param) { - $required = true; - if ($param->isOptional()) { - $required = false; - - $routeParams .= '[/..]'; - } else { - $routeParams .= '/..'; - } - - // [variable_name => required?] - $params[$param->getName()] = $required; - } + [$params, $routeParams] = $this->getParameters($method); // If it is the default controller, the method will not be // routed. @@ -137,6 +124,29 @@ public function read(string $class, string $defaultController = 'Home', string $ return $output; } + private function getParameters(ReflectionMethod $method): array + { + $params = []; + $routeParams = ''; + $refParams = $method->getParameters(); + + foreach ($refParams as $param) { + $required = true; + if ($param->isOptional()) { + $required = false; + + $routeParams .= '[/..]'; + } else { + $routeParams .= '/..'; + } + + // [variable_name => required?] + $params[$param->getName()] = $required; + } + + return [$params, $routeParams]; + } + /** * @phpstan-param class-string $classname * @@ -171,7 +181,8 @@ private function getRouteForDefaultController( string $uriByClass, string $classname, string $methodName, - string $httpVerb + string $httpVerb, + ReflectionMethod $method ): array { $output = []; @@ -180,12 +191,18 @@ private function getRouteForDefaultController( $routeWithoutController = rtrim(preg_replace($pattern, '', $uriByClass), '/'); $routeWithoutController = $routeWithoutController ?: '/'; + [$params, $routeParams] = $this->getParameters($method); + + if ($routeWithoutController === '/' && $routeParams !== '') { + $routeWithoutController = ''; + } + $output[] = [ 'method' => $httpVerb, 'route' => $routeWithoutController, - 'route_params' => '', + 'route_params' => $routeParams, 'handler' => '\\' . $classname . '::' . $methodName, - 'params' => [], + 'params' => $params, ]; } diff --git a/system/Commands/Utilities/Routes/FilterFinder.php b/system/Commands/Utilities/Routes/FilterFinder.php index 042b3a269838..0cdea8c42744 100644 --- a/system/Commands/Utilities/Routes/FilterFinder.php +++ b/system/Commands/Utilities/Routes/FilterFinder.php @@ -13,7 +13,7 @@ use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Filters\Filters; -use CodeIgniter\Router\Exceptions\RedirectException; +use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\Router\Router; use Config\Feature; use Config\Services; diff --git a/system/Common.php b/system/Common.php index af2e558cfa9f..ff9ca929c9f9 100644 --- a/system/Common.php +++ b/system/Common.php @@ -21,6 +21,7 @@ use CodeIgniter\Files\Exceptions\FileNotFoundException; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\RequestInterface; @@ -474,22 +475,24 @@ function esc($data, string $context = 'html', ?string $encoding = null) * Defaults to 1 year. * * @throws HTTPException + * @throws RedirectException */ - function force_https(int $duration = 31_536_000, ?RequestInterface $request = null, ?ResponseInterface $response = null) - { - if ($request === null) { - $request = Services::request(null, true); - } + function force_https( + int $duration = 31_536_000, + ?RequestInterface $request = null, + ?ResponseInterface $response = null + ) { + $request ??= Services::request(); if (! $request instanceof IncomingRequest) { return; } - if ($response === null) { - $response = Services::response(null, true); - } + $response ??= Services::response(); - if ((ENVIRONMENT !== 'testing' && (is_cli() || $request->isSecure())) || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'test')) { + if ((ENVIRONMENT !== 'testing' && (is_cli() || $request->isSecure())) + || $request->getServer('HTTPS') === 'test' + ) { return; // @codeCoverageIgnore } @@ -518,13 +521,14 @@ function force_https(int $duration = 31_536_000, ?RequestInterface $request = nu ); // Set an HSTS header - $response->setHeader('Strict-Transport-Security', 'max-age=' . $duration); - $response->redirect($uri); - $response->sendHeaders(); - - if (ENVIRONMENT !== 'testing') { - exit(); // @codeCoverageIgnore - } + $response->setHeader('Strict-Transport-Security', 'max-age=' . $duration) + ->redirect($uri) + ->setStatusCode(307) + ->setBody('') + ->getCookieStore() + ->clear(); + + throw new RedirectException($response); } } diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index 0958d7ae7f75..d2c396dc36ba 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -26,6 +26,8 @@ * from the environment. * * These can be set within the .env file. + * + * @phpstan-consistent-constructor */ class BaseConfig { @@ -37,6 +39,11 @@ class BaseConfig */ public static $registrars = []; + /** + * Whether to override properties by Env vars and Registrars. + */ + public static bool $override = true; + /** * Has module discovery happened yet? * @@ -51,6 +58,21 @@ class BaseConfig */ protected static $moduleConfig; + public static function __set_state(array $array) + { + static::$override = false; + $obj = new static(); + static::$override = true; + + $properties = array_keys(get_object_vars($obj)); + + foreach ($properties as $property) { + $obj->{$property} = $array[$property]; + } + + return $obj; + } + /** * Will attempt to get environment variables with names * that match the properties of the child class. @@ -61,6 +83,10 @@ public function __construct() { static::$moduleConfig = config(Modules::class); + if (! static::$override) { + return; + } + $this->registerProperties(); $properties = array_keys(get_object_vars($this)); diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index e41c0c0ed06f..b66b8c9f535e 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -14,6 +14,7 @@ use CodeIgniter\Autoloader\Autoloader; use CodeIgniter\Autoloader\FileLocator; use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Cache\ResponseCache; use CodeIgniter\CLI\Commands; use CodeIgniter\CodeIgniter; use CodeIgniter\Database\ConnectionInterface; @@ -36,6 +37,7 @@ use CodeIgniter\HTTP\Request; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\SiteURIFactory; use CodeIgniter\HTTP\URI; use CodeIgniter\Images\Handlers\BaseHandler; use CodeIgniter\Language\Language; @@ -46,6 +48,7 @@ use CodeIgniter\Router\Router; use CodeIgniter\Security\Security; use CodeIgniter\Session\Session; +use CodeIgniter\Superglobals; use CodeIgniter\Throttle\Throttler; use CodeIgniter\Typography\Typography; use CodeIgniter\Validation\ValidationInterface; @@ -100,7 +103,7 @@ * @method static CURLRequest curlrequest($options = [], ResponseInterface $response = null, App $config = null, $getShared = true) * @method static Email email($config = null, $getShared = true) * @method static EncrypterInterface encrypter(Encryption $config = null, $getShared = false) - * @method static Exceptions exceptions(ConfigExceptions $config = null, IncomingRequest $request = null, ResponseInterface $response = null, $getShared = true) + * @method static Exceptions exceptions(ConfigExceptions $config = null, $getShared = true) * @method static Filters filters(ConfigFilters $config = null, $getShared = true) * @method static Format format(ConfigFormat $config = null, $getShared = true) * @method static Honeypot honeypot(ConfigHoneyPot $config = null, $getShared = true) @@ -117,10 +120,13 @@ * @method static View renderer($viewPath = null, ConfigView $config = null, $getShared = true) * @method static IncomingRequest|CLIRequest request(App $config = null, $getShared = true) * @method static ResponseInterface response(App $config = null, $getShared = true) + * @method static ResponseCache responsecache(?Cache $config = null, ?CacheInterface $cache = null, bool $getShared = true) * @method static Router router(RouteCollectionInterface $routes = null, Request $request = null, $getShared = true) * @method static RouteCollection routes($getShared = true) * @method static Security security(App $config = null, $getShared = true) * @method static Session session(App $config = null, $getShared = true) + * @method static SiteURIFactory siteurifactory(App $config = null, Superglobals $superglobals = null, $getShared = true) + * @method static Superglobals superglobals(array $server = null, array $get = null, bool $getShared = true) * @method static Throttler throttler($getShared = true) * @method static Timer timer($getShared = true) * @method static Toolbar toolbar(ConfigToolbar $config = null, $getShared = true) diff --git a/system/Config/Factories.php b/system/Config/Factories.php index f14471c5fda7..0eb6d2443e44 100644 --- a/system/Config/Factories.php +++ b/system/Config/Factories.php @@ -14,6 +14,7 @@ use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Model; use Config\Services; +use InvalidArgumentException; /** * Factories for creating instances. @@ -24,7 +25,7 @@ * instantiation checks. * * @method static BaseConfig|null config(...$arguments) - * @method static Model|null models(string $name, array $options = [], ?ConnectionInterface &$conn = null) + * @method static Model|null models(string $alias, array $options = [], ?ConnectionInterface &$conn = null) */ class Factories { @@ -51,25 +52,74 @@ class Factories ]; /** - * Mapping of class basenames (no namespace) to - * their instances. + * Mapping of class aliases to their true Fully Qualified Class Name (FQCN). + * + * Class aliases can be: + * - FQCN. E.g., 'App\Lib\SomeLib' + * - short classname. E.g., 'SomeLib' + * - short classname with sub-directories. E.g., 'Sub/SomeLib' + * + * [component => [alias => FQCN]] * * @var array> * @phpstan-var array> */ - protected static $basenames = []; + protected static $aliases = []; /** * Store for instances of any component that * has been requested as "shared". + * * A multi-dimensional array with components as * keys to the array of name-indexed instances. * + * [component => [FQCN => instance]] + * * @var array> * @phpstan-var array> */ protected static $instances = []; + /** + * Whether the component instances are updated? + * + * @var array [component => true] + * + * @internal For caching only + */ + protected static $updated = []; + + /** + * Define the class to load. You can *override* the concrete class. + * + * @param string $component Lowercase, plural component name + * @param string $alias Class alias. See the $aliases property. + * @param string $classname FQCN to be loaded + * @phpstan-param class-string $classname FQCN to be loaded + */ + public static function define(string $component, string $alias, string $classname): void + { + if (isset(self::$aliases[$component][$alias])) { + if (self::$aliases[$component][$alias] === $classname) { + return; + } + + throw new InvalidArgumentException( + 'Already defined in Factories: ' . $component . ' ' . $alias . ' -> ' . self::$aliases[$component][$alias] + ); + } + + if (! class_exists($classname)) { + throw new InvalidArgumentException('No such class: ' . $classname); + } + + // Force a configuration to exist for this component. + // Otherwise, getOptions() will reset the component. + self::getOptions($component); + + self::$aliases[$component][$alias] = $classname; + } + /** * Loads instances based on the method component name. Either * creates a new instance or returns an existing shared instance. @@ -78,86 +128,137 @@ class Factories */ public static function __callStatic(string $component, array $arguments) { - // First argument is the name, second is options - $name = trim(array_shift($arguments), '\\ '); + // First argument is the class alias, second is options + $alias = trim(array_shift($arguments), '\\ '); $options = array_shift($arguments) ?? []; // Determine the component-specific options $options = array_merge(self::getOptions(strtolower($component)), $options); if (! $options['getShared']) { - if ($class = self::locateClass($options, $name)) { + if (isset(self::$aliases[$component][$alias])) { + $class = self::$aliases[$component][$alias]; + + return new $class(...$arguments); + } + + if ($class = self::locateClass($options, $alias)) { return new $class(...$arguments); } return null; } - $basename = self::getBasename($name); + // Check for an existing definition + $instance = self::getDefinedInstance($options, $alias, $arguments); + if ($instance !== null) { + return $instance; + } + + // Try to locate the class + if (! $class = self::locateClass($options, $alias)) { + return null; + } - // Check for an existing instance - if (isset(self::$basenames[$options['component']][$basename])) { - $class = self::$basenames[$options['component']][$basename]; + self::$instances[$options['component']][$class] = new $class(...$arguments); + self::$aliases[$options['component']][$alias] = $class; + self::$updated[$options['component']] = true; + + // If a short classname is specified, also register FQCN to share the instance. + if (! isset(self::$aliases[$options['component']][$class])) { + self::$aliases[$options['component']][$class] = $class; + } + + return self::$instances[$options['component']][$class]; + } + + /** + * Gets the defined instance. If not exists, creates new one. + * + * @return object|null + */ + private static function getDefinedInstance(array $options, string $alias, array $arguments) + { + if (isset(self::$aliases[$options['component']][$alias])) { + $class = self::$aliases[$options['component']][$alias]; // Need to verify if the shared instance matches the request if (self::verifyInstanceOf($options, $class)) { + // Check for an existing instance + if (isset(self::$instances[$options['component']][$class])) { + return self::$instances[$options['component']][$class]; + } + + self::$instances[$options['component']][$class] = new $class(...$arguments); + return self::$instances[$options['component']][$class]; } } - // Try to locate the class - if (! $class = self::locateClass($options, $name)) { - return null; - } - - self::$instances[$options['component']][$class] = new $class(...$arguments); - self::$basenames[$options['component']][$basename] = $class; + return null; + } - return self::$instances[$options['component']][$class]; + /** + * Is the component Config? + * + * @param string $component Lowercase, plural component name + */ + private static function isConfig(string $component): bool + { + return $component === 'config'; } /** * Finds a component class * * @param array $options The array of component-specific directives - * @param string $name Class name, namespace optional + * @param string $alias Class alias. See the $aliases property. */ - protected static function locateClass(array $options, string $name): ?string + protected static function locateClass(array $options, string $alias): ?string { // Check for low-hanging fruit - if (class_exists($name, false) && self::verifyPreferApp($options, $name) && self::verifyInstanceOf($options, $name)) { - return $name; + if ( + class_exists($alias, false) + && self::verifyPreferApp($options, $alias) + && self::verifyInstanceOf($options, $alias) + ) { + return $alias; } // Determine the relative class names we need - $basename = self::getBasename($name); - $appname = $options['component'] === 'config' + $basename = self::getBasename($alias); + $appname = self::isConfig($options['component']) ? 'Config\\' . $basename : rtrim(APP_NAMESPACE, '\\') . '\\' . $options['path'] . '\\' . $basename; // If an App version was requested then see if it verifies - if ($options['preferApp'] && class_exists($appname) && self::verifyInstanceOf($options, $name)) { + if ( + // preferApp is used only for no namespaced class. + ! self::isNamespaced($alias) + && $options['preferApp'] && class_exists($appname) + && self::verifyInstanceOf($options, $alias) + ) { return $appname; } // If we have ruled out an App version and the class exists then try it - if (class_exists($name) && self::verifyInstanceOf($options, $name)) { - return $name; + if (class_exists($alias) && self::verifyInstanceOf($options, $alias)) { + return $alias; } // Have to do this the hard way... $locator = Services::locator(); - // Check if the class was namespaced - if (strpos($name, '\\') !== false) { - if (! $file = $locator->locateFile($name, $options['path'])) { + // Check if the class alias was namespaced + if (self::isNamespaced($alias)) { + if (! $file = $locator->locateFile($alias, $options['path'])) { return null; } $files = [$file]; } // No namespace? Search for it // Check all namespaces, prioritizing App and modules - elseif (! $files = $locator->search($options['path'] . DIRECTORY_SEPARATOR . $name)) { + elseif (! $files = $locator->search($options['path'] . DIRECTORY_SEPARATOR . $alias)) { return null; } @@ -173,13 +274,23 @@ protected static function locateClass(array $options, string $name): ?string return null; } + /** + * Is the class alias namespaced or not? + * + * @param string $alias Class alias. See the $aliases property. + */ + private static function isNamespaced(string $alias): bool + { + return strpos($alias, '\\') !== false; + } + /** * Verifies that a class & config satisfy the "preferApp" option * * @param array $options The array of component-specific directives - * @param string $name Class name, namespace optional + * @param string $alias Class alias. See the $aliases property. */ - protected static function verifyPreferApp(array $options, string $name): bool + protected static function verifyPreferApp(array $options, string $alias): bool { // Anything without that restriction passes if (! $options['preferApp']) { @@ -187,27 +298,27 @@ protected static function verifyPreferApp(array $options, string $name): bool } // Special case for Config since its App namespace is actually \Config - if ($options['component'] === 'config') { - return strpos($name, 'Config') === 0; + if (self::isConfig($options['component'])) { + return strpos($alias, 'Config') === 0; } - return strpos($name, APP_NAMESPACE) === 0; + return strpos($alias, APP_NAMESPACE) === 0; } /** * Verifies that a class & config satisfy the "instanceOf" option * * @param array $options The array of component-specific directives - * @param string $name Class name, namespace optional + * @param string $alias Class alias. See the $aliases property. */ - protected static function verifyInstanceOf(array $options, string $name): bool + protected static function verifyInstanceOf(array $options, string $alias): bool { // Anything without that restriction passes if (! $options['instanceOf']) { return true; } - return is_a($name, $options['instanceOf'], true); + return is_a($alias, $options['instanceOf'], true); } /** @@ -216,6 +327,9 @@ protected static function verifyInstanceOf(array $options, string $name): bool * @param string $component Lowercase, plural component name * * @return array + * + * @internal For testing only + * @testTag */ public static function getOptions(string $component): array { @@ -226,12 +340,14 @@ public static function getOptions(string $component): array return self::$options[$component]; } - $values = $component === 'config' + $values = self::isConfig($component) // Handle Config as a special case to prevent logic loops ? self::$configOptions // Load values from the best Factory configuration (will include Registrars) : config(Factory::class)->{$component} ?? []; + // The setOptions() reset the component. So getOptions() may reset + // the component. return self::setOptions($component, $values); } @@ -239,6 +355,7 @@ public static function getOptions(string $component): array * Normalizes, stores, and returns the configuration for a specific component * * @param string $component Lowercase, plural component name + * @param array $values option values * * @return array The result after applying defaults and normalization */ @@ -275,49 +392,106 @@ public static function reset(?string $component = null) if ($component) { unset( static::$options[$component], - static::$basenames[$component], - static::$instances[$component] + static::$aliases[$component], + static::$instances[$component], + static::$updated[$component] ); return; } static::$options = []; - static::$basenames = []; + static::$aliases = []; static::$instances = []; + static::$updated = []; } /** * Helper method for injecting mock instances * * @param string $component Lowercase, plural component name - * @param string $name The name of the instance + * @param string $alias Class alias. See the $aliases property. * * @return void + * + * @internal For testing only + * @testTag */ - public static function injectMock(string $component, string $name, object $instance) + public static function injectMock(string $component, string $alias, object $instance) { // Force a configuration to exist for this component $component = strtolower($component); self::getOptions($component); - $class = get_class($instance); - $basename = self::getBasename($name); + $class = get_class($instance); - self::$instances[$component][$class] = $instance; - self::$basenames[$component][$basename] = $class; + self::$instances[$component][$class] = $instance; + self::$aliases[$component][$alias] = $class; + + if (self::isConfig($component)) { + if (self::isNamespaced($alias)) { + self::$aliases[$component][self::getBasename($alias)] = $class; + } else { + self::$aliases[$component]['Config\\' . $alias] = $class; + } + } } /** - * Gets a basename from a class name, namespaced or not. + * Gets a basename from a class alias, namespaced or not. + * + * @internal For testing only + * @testTag */ - public static function getBasename(string $name): string + public static function getBasename(string $alias): string { // Determine the basename - if ($basename = strrchr($name, '\\')) { + if ($basename = strrchr($alias, '\\')) { return substr($basename, 1); } - return $name; + return $alias; + } + + /** + * Gets component data for caching. + * + * @internal For caching only + */ + public static function getComponentInstances(string $component): array + { + if (! isset(static::$aliases[$component])) { + return [ + 'aliases' => [], + 'instances' => [], + ]; + } + + return [ + 'aliases' => static::$aliases[$component], + 'instances' => self::$instances[$component], + ]; + } + + /** + * Sets component data + * + * @internal For caching only + */ + public static function setComponentInstances(string $component, array $data): void + { + static::$aliases[$component] = $data['aliases']; + self::$instances[$component] = $data['instances']; + unset(self::$updated[$component]); + } + + /** + * Whether the component instances are updated? + * + * @internal For caching only + */ + public static function isUpdated(string $component): bool + { + return isset(self::$updated[$component]); } } diff --git a/system/Config/Routing.php b/system/Config/Routing.php new file mode 100644 index 000000000000..409bcf099f65 --- /dev/null +++ b/system/Config/Routing.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Config; + +/** + * Routing configuration + */ +class Routing extends BaseConfig +{ + /** + * An array of files that contain route definitions. + * Route files are read in order, with the first match + * found taking precedence. + * + * Default: APPPATH . 'Config/Routes.php' + */ + public array $routeFiles = [ + APPPATH . 'Routes.php', + ]; + + /** + * The default namespace to use for Controllers when no other + * namespace has been specified. + * + * Default: 'App\Controllers' + */ + public string $defaultNamespace = 'App\Controllers'; + + /** + * The default controller to use when no other controller has been + * specified. + * + * Default: 'Home' + */ + public string $defaultController = 'Home'; + + /** + * The default method to call on the controller when no other + * method has been set in the route. + * + * Default: 'index' + */ + public string $defaultMethod = 'index'; + + /** + * Whether to translate dashes in URIs to underscores. + * Primarily useful when using the auto-routing. + * + * Default: false + */ + public bool $translateURIDashes = false; + + /** + * Sets the class/method that should be called if routing doesn't + * find a match. It can be either a closure or the controller/method + * name exactly like a route is defined: Users::index + * + * This setting is passed to the Router class and handled there. + * + * If you want to use a closure, you will have to set it in the + * class constructor or the routes file by calling: + * + * $routes->set404Override(function() { + * // Do something here + * }); + * + * Example: + * public $override404 = 'App\Errors::show404'; + */ + public ?string $override404 = null; + + /** + * If TRUE, the system will attempt to match the URI against + * Controllers by matching each segment against folders/files + * in APPPATH/Controllers, when a match wasn't found against + * defined routes. + * + * If FALSE, will stop searching and do NO automatic routing. + */ + public bool $autoRoute = false; + + /** + * If TRUE, will enable the use of the 'prioritize' option + * when defining routes. + * + * Default: false + */ + public bool $prioritize = false; +} diff --git a/system/Config/Services.php b/system/Config/Services.php index b18b99e91a91..2a4596bf203c 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -13,6 +13,7 @@ use CodeIgniter\Cache\CacheFactory; use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Cache\ResponseCache; use CodeIgniter\CLI\Commands; use CodeIgniter\CodeIgniter; use CodeIgniter\Database\ConnectionInterface; @@ -37,6 +38,7 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\SiteURIFactory; use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Images\Handlers\BaseHandler; @@ -51,6 +53,7 @@ use CodeIgniter\Session\Handlers\Database\PostgreHandler; use CodeIgniter\Session\Handlers\DatabaseHandler; use CodeIgniter\Session\Session; +use CodeIgniter\Superglobals; use CodeIgniter\Throttle\Throttler; use CodeIgniter\Typography\Typography; use CodeIgniter\Validation\Validation; @@ -76,6 +79,8 @@ use Config\Modules; use Config\Pager as PagerConfig; use Config\Paths; +use Config\Routing; +use Config\Security as SecurityConfig; use Config\Services as AppServices; use Config\Session as SessionConfig; use Config\Toolbar as ToolbarConfig; @@ -114,7 +119,7 @@ public static function cache(?Cache $config = null, bool $getShared = true) return static::getSharedInstance('cache', $config); } - $config ??= new Cache(); + $config ??= config(Cache::class); return CacheFactory::getHandler($config); } @@ -257,19 +262,15 @@ public static function encrypter(?EncryptionConfig $config = null, $getShared = */ public static function exceptions( ?ExceptionsConfig $config = null, - ?IncomingRequest $request = null, - ?ResponseInterface $response = null, bool $getShared = true ) { if ($getShared) { - return static::getSharedInstance('exceptions', $config, $request, $response); + return static::getSharedInstance('exceptions', $config); } $config ??= config(ExceptionsConfig::class); - $request ??= AppServices::request(); - $response ??= AppServices::response(); - return new Exceptions($config, $request, $response); + return new Exceptions($config); } /** @@ -433,6 +434,23 @@ public static function negotiator(?RequestInterface $request = null, bool $getSh return new Negotiate($request); } + /** + * Return the ResponseCache. + * + * @return ResponseCache + */ + public static function responsecache(?Cache $config = null, ?CacheInterface $cache = null, bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('responsecache', $config, $cache); + } + + $config ??= config(Cache::class); + $cache ??= AppServices::cache(); + + return new ResponseCache($config, $cache); + } + /** * Return the appropriate pagination handler. * @@ -596,7 +614,7 @@ public static function routes(bool $getShared = true) return static::getSharedInstance('routes'); } - return new RouteCollection(AppServices::locator(), config(Modules::class)); + return new RouteCollection(AppServices::locator(), config(Modules::class), config(Routing::class)); } /** @@ -623,13 +641,13 @@ public static function router(?RouteCollectionInterface $routes = null, ?Request * * @return Security */ - public static function security(?App $config = null, bool $getShared = true) + public static function security(?SecurityConfig $config = null, bool $getShared = true) { if ($getShared) { return static::getSharedInstance('security', $config); } - $config ??= config(App::class); + $config ??= config(SecurityConfig::class); return new Security($config); } @@ -639,24 +657,20 @@ public static function security(?App $config = null, bool $getShared = true) * * @return Session */ - public static function session(?App $config = null, bool $getShared = true) + public static function session(?SessionConfig $config = null, bool $getShared = true) { if ($getShared) { return static::getSharedInstance('session', $config); } - $config ??= config(App::class); - assert($config instanceof App); + $config ??= config(SessionConfig::class); $logger = AppServices::logger(); - /** @var SessionConfig|null $sessionConfig */ - $sessionConfig = config(SessionConfig::class); - - $driverName = $sessionConfig->driver ?? $config->sessionDriver; + $driverName = $config->driver; if ($driverName === DatabaseHandler::class) { - $DBGroup = $sessionConfig->DBGroup ?? $config->sessionDBGroup ?? config(Database::class)->defaultGroup; + $DBGroup = $config->DBGroup ?? config(Database::class)->defaultGroup; $db = Database::connect($DBGroup); $driver = $db->getPlatform(); @@ -681,6 +695,43 @@ public static function session(?App $config = null, bool $getShared = true) return $session; } + /** + * The Factory for SiteURI. + * + * @return SiteURIFactory + */ + public static function siteurifactory( + ?App $config = null, + ?Superglobals $superglobals = null, + bool $getShared = true + ) { + if ($getShared) { + return static::getSharedInstance('siteurifactory', $config, $superglobals); + } + + $config ??= config('App'); + $superglobals ??= AppServices::superglobals(); + + return new SiteURIFactory($config, $superglobals); + } + + /** + * Superglobals. + * + * @return Superglobals + */ + public static function superglobals( + ?array $server = null, + ?array $get = null, + bool $getShared = true + ) { + if ($getShared) { + return static::getSharedInstance('superglobals', $server, $get); + } + + return new Superglobals($server, $get); + } + /** * The Throttler class provides a simple method for implementing * rate limiting in your applications. @@ -732,7 +783,7 @@ public static function toolbar(?ToolbarConfig $config = null, bool $getShared = * * @param string $uri * - * @return URI + * @return URI The current URI if $uri is null. */ public static function uri(?string $uri = null, bool $getShared = true) { @@ -740,6 +791,13 @@ public static function uri(?string $uri = null, bool $getShared = true) return static::getSharedInstance('uri', $uri); } + if ($uri === null) { + $appConfig = config(App::class); + $factory = AppServices::siteurifactory($appConfig, AppServices::superglobals()); + + return $factory->createFromGlobals(); + } + return new URI($uri); } diff --git a/system/Controller.php b/system/Controller.php index 070cc76b88f5..038bc8ee7a78 100644 --- a/system/Controller.php +++ b/system/Controller.php @@ -108,14 +108,15 @@ protected function forceHTTPS(int $duration = 31_536_000) } /** - * Provides a simple way to tie into the main CodeIgniter class and - * tell it how long to cache the current page for. + * How long to cache the current page for. + * + * @params int $time time to live in seconds. * * @return void */ protected function cachePage(int $time) { - CodeIgniter::cache($time); + Services::responsecache()->setTtl($time); } /** diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 094fcfe7e3ec..45b306ce2a44 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -72,6 +72,13 @@ class Connection extends BaseConnection */ public $resultMode = MYSQLI_STORE_RESULT; + /** + * Use MYSQLI_OPT_INT_AND_FLOAT_NATIVE + * + * @var bool + */ + public $numberNative = false; + /** * Connect to the database. * @@ -99,6 +106,10 @@ public function connect(bool $persistent = false) $this->mysqli->options(MYSQLI_OPT_CONNECT_TIMEOUT, 10); + if ($this->numberNative === true) { + $this->mysqli->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, 1); + } + if (isset($this->strictOn)) { if ($this->strictOn) { $this->mysqli->options( diff --git a/system/Database/MySQLi/Result.php b/system/Database/MySQLi/Result.php index afe23486d7e8..578169deb757 100644 --- a/system/Database/MySQLi/Result.php +++ b/system/Database/MySQLi/Result.php @@ -148,7 +148,7 @@ protected function fetchAssoc() protected function fetchObject(string $className = 'stdClass') { if (is_subclass_of($className, Entity::class)) { - return empty($data = $this->fetchAssoc()) ? false : (new $className())->setAttributes($data); + return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); } return $this->resultID->fetch_object($className); diff --git a/system/Database/OCI8/Result.php b/system/Database/OCI8/Result.php index 63682e2a229c..01025763d423 100644 --- a/system/Database/OCI8/Result.php +++ b/system/Database/OCI8/Result.php @@ -103,7 +103,7 @@ protected function fetchObject(string $className = 'stdClass') return $row; } if (is_subclass_of($className, Entity::class)) { - return (new $className())->setAttributes((array) $row); + return (new $className())->injectRawData((array) $row); } $instance = new $className(); diff --git a/system/Database/Postgre/Result.php b/system/Database/Postgre/Result.php index 84ee1514ec42..0a828757632e 100644 --- a/system/Database/Postgre/Result.php +++ b/system/Database/Postgre/Result.php @@ -114,7 +114,7 @@ protected function fetchAssoc() protected function fetchObject(string $className = 'stdClass') { if (is_subclass_of($className, Entity::class)) { - return empty($data = $this->fetchAssoc()) ? false : (new $className())->setAttributes($data); + return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); } return pg_fetch_object($this->resultID, null, $className); diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 4e0ca987df9a..f1a9fb0b433a 100755 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -341,9 +341,11 @@ protected function _enableForeignKeyChecks() */ protected function _fieldData(string $table): array { - $sql = 'SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, COLUMN_DEFAULT - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME= ' . $this->escape(($table)); + $sql = 'SELECT + COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, + COLUMN_DEFAULT, IS_NULLABLE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME= ' . $this->escape(($table)); if (($query = $this->query($sql)) === false) { throw new DatabaseException(lang('Database.failGetFieldData')); @@ -362,6 +364,8 @@ protected function _fieldData(string $table): array $retVal[$i]->max_length = $query[$i]->CHARACTER_MAXIMUM_LENGTH > 0 ? $query[$i]->CHARACTER_MAXIMUM_LENGTH : $query[$i]->NUMERIC_PRECISION; + + $retVal[$i]->nullable = $query[$i]->IS_NULLABLE !== 'NO'; } return $retVal; diff --git a/system/Database/SQLSRV/Result.php b/system/Database/SQLSRV/Result.php index ea1117d3bc3f..f245a01669c6 100755 --- a/system/Database/SQLSRV/Result.php +++ b/system/Database/SQLSRV/Result.php @@ -154,7 +154,7 @@ protected function fetchAssoc() protected function fetchObject(string $className = 'stdClass') { if (is_subclass_of($className, Entity::class)) { - return empty($data = $this->fetchAssoc()) ? false : (new $className())->setAttributes($data); + return empty($data = $this->fetchAssoc()) ? false : (new $className())->injectRawData($data); } return sqlsrv_fetch_object($this->resultID, $className); diff --git a/system/Database/SQLite3/Result.php b/system/Database/SQLite3/Result.php index b2175d9d198b..f3887b044b49 100644 --- a/system/Database/SQLite3/Result.php +++ b/system/Database/SQLite3/Result.php @@ -142,7 +142,7 @@ protected function fetchObject(string $className = 'stdClass') $classObj = new $className(); if (is_subclass_of($className, Entity::class)) { - return $classObj->setAttributes($row); + return $classObj->injectRawData($row); } $classSet = Closure::bind(function ($key, $value) { diff --git a/system/Debug/BaseExceptionHandler.php b/system/Debug/BaseExceptionHandler.php new file mode 100644 index 000000000000..0330c53cf97c --- /dev/null +++ b/system/Debug/BaseExceptionHandler.php @@ -0,0 +1,246 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Exceptions as ExceptionsConfig; +use Throwable; + +/** + * Provides common functions for exception handlers, + * especially around displaying the output. + */ +abstract class BaseExceptionHandler +{ + /** + * Config for debug exceptions. + */ + protected ExceptionsConfig $config; + + /** + * Nesting level of the output buffering mechanism + */ + protected int $obLevel; + + /** + * The path to the directory containing the + * cli and html error view directories. + */ + protected ?string $viewPath = null; + + public function __construct(ExceptionsConfig $config) + { + $this->config = $config; + + $this->obLevel = ob_get_level(); + + if ($this->viewPath === null) { + $this->viewPath = rtrim($this->config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; + } + } + + /** + * The main entry point into the handler. + * + * @return void + */ + abstract public function handle( + Throwable $exception, + RequestInterface $request, + ResponseInterface $response, + int $statusCode, + int $exitCode + ); + + /** + * Gathers the variables that will be made available to the view. + */ + protected function collectVars(Throwable $exception, int $statusCode): array + { + $trace = $exception->getTrace(); + + if ($this->config->sensitiveDataInTrace !== []) { + $trace = $this->maskSensitiveData($trace, $this->config->sensitiveDataInTrace); + } + + return [ + 'title' => get_class($exception), + 'type' => get_class($exception), + 'code' => $statusCode, + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $trace, + ]; + } + + /** + * Mask sensitive data in the trace. + */ + protected function maskSensitiveData(array $trace, array $keysToMask, string $path = ''): array + { + foreach ($trace as $i => $line) { + $trace[$i]['args'] = $this->maskData($line['args'], $keysToMask); + } + + return $trace; + } + + /** + * @param array|object $args + * + * @return array|object + */ + private function maskData($args, array $keysToMask, string $path = '') + { + foreach ($keysToMask as $keyToMask) { + $explode = explode('/', $keyToMask); + $index = end($explode); + + if (strpos(strrev($path . '/' . $index), strrev($keyToMask)) === 0) { + if (is_array($args) && array_key_exists($index, $args)) { + $args[$index] = '******************'; + } elseif ( + is_object($args) && property_exists($args, $index) + && isset($args->{$index}) && is_scalar($args->{$index}) + ) { + $args->{$index} = '******************'; + } + } + } + + if (is_array($args)) { + foreach ($args as $pathKey => $subarray) { + $args[$pathKey] = $this->maskData($subarray, $keysToMask, $path . '/' . $pathKey); + } + } elseif (is_object($args)) { + foreach ($args as $pathKey => $subarray) { + $args->{$pathKey} = $this->maskData($subarray, $keysToMask, $path . '/' . $pathKey); + } + } + + return $args; + } + + /** + * Describes memory usage in real-world units. Intended for use + * with memory_get_usage, etc. + * + * @used-by app/Views/errors/html/error_exception.php + */ + protected static function describeMemory(int $bytes): string + { + helper('number'); + + return number_to_size($bytes, 2); + } + + /** + * Creates a syntax-highlighted version of a PHP file. + * + * @used-by app/Views/errors/html/error_exception.php + * + * @return bool|string + */ + protected static function highlightFile(string $file, int $lineNumber, int $lines = 15) + { + if (empty($file) || ! is_readable($file)) { + return false; + } + + // Set our highlight colors: + if (function_exists('ini_set')) { + ini_set('highlight.comment', '#767a7e; font-style: italic'); + ini_set('highlight.default', '#c7c7c7'); + ini_set('highlight.html', '#06B'); + ini_set('highlight.keyword', '#f1ce61;'); + ini_set('highlight.string', '#869d6a'); + } + + try { + $source = file_get_contents($file); + } catch (Throwable $e) { + return false; + } + + $source = str_replace(["\r\n", "\r"], "\n", $source); + $source = explode("\n", highlight_string($source, true)); + $source = str_replace('
', "\n", $source[1]); + $source = explode("\n", str_replace("\r\n", "\n", $source)); + + // Get just the part to show + $start = max($lineNumber - (int) round($lines / 2), 0); + + // Get just the lines we need to display, while keeping line numbers... + $source = array_splice($source, $start, $lines, true); + + // Used to format the line number in the source + $format = '% ' . strlen((string) ($start + $lines)) . 'd'; + + $out = ''; + // Because the highlighting may have an uneven number + // of open and close span tags on one line, we need + // to ensure we can close them all to get the lines + // showing correctly. + $spans = 1; + + foreach ($source as $n => $row) { + $spans += substr_count($row, ']+>#', $row, $tags); + + $out .= sprintf( + "{$format} %s\n%s", + $n + $start + 1, + strip_tags($row), + implode('', $tags[0]) + ); + } else { + $out .= sprintf('' . $format . ' %s', $n + $start + 1, $row) . "\n"; + } + } + + if ($spans > 0) { + $out .= str_repeat('', $spans); + } + + return '
' . $out . '
'; + } + + /** + * Given an exception and status code will display the error to the client. + * + * @param string|null $viewFile + */ + protected function render(Throwable $exception, int $statusCode, $viewFile = null): void + { + if (empty($viewFile) || ! is_file($viewFile)) { + echo 'The error view files were not found. Cannot render exception trace.'; + + exit(1); + } + + echo(function () use ($exception, $statusCode, $viewFile): string { + $vars = $this->collectVars($exception, $statusCode); + extract($vars, EXTR_SKIP); + + // CLI error views output to STDERR/STDOUT, so ob_start() does not work. + ob_start(); + include $viewFile; + + return ob_get_clean(); + })(); + } +} diff --git a/system/Debug/ExceptionHandler.php b/system/Debug/ExceptionHandler.php new file mode 100644 index 000000000000..1eaa05aceeaa --- /dev/null +++ b/system/Debug/ExceptionHandler.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\API\ResponseTrait; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Paths; +use Throwable; + +final class ExceptionHandler extends BaseExceptionHandler implements ExceptionHandlerInterface +{ + use ResponseTrait; + + /** + * ResponseTrait needs this. + */ + private ?RequestInterface $request = null; + + /** + * ResponseTrait needs this. + */ + private ?ResponseInterface $response = null; + + /** + * Determines the correct way to display the error. + */ + public function handle( + Throwable $exception, + RequestInterface $request, + ResponseInterface $response, + int $statusCode, + int $exitCode + ): void { + // ResponseTrait needs these properties. + $this->request = $request; + $this->response = $response; + + if ($request instanceof IncomingRequest) { + try { + $response->setStatusCode($statusCode); + } catch (HTTPException $e) { + // Workaround for invalid HTTP status code. + $statusCode = 500; + $response->setStatusCode($statusCode); + } + + if (! headers_sent()) { + header( + sprintf( + 'HTTP/%s %s %s', + $request->getProtocolVersion(), + $response->getStatusCode(), + $response->getReasonPhrase() + ), + true, + $statusCode + ); + } + + if (strpos($request->getHeaderLine('accept'), 'text/html') === false) { + $data = (ENVIRONMENT === 'development' || ENVIRONMENT === 'testing') + ? $this->collectVars($exception, $statusCode) + : ''; + + $this->respond($data, $statusCode)->send(); + + if (ENVIRONMENT !== 'testing') { + // @codeCoverageIgnoreStart + exit($exitCode); + // @codeCoverageIgnoreEnd + } + + return; + } + } + + // Determine possible directories of error views + $addPath = ($request instanceof IncomingRequest ? 'html' : 'cli') . DIRECTORY_SEPARATOR; + $path = $this->viewPath . $addPath; + $altPath = rtrim((new Paths())->viewDirectory, '\\/ ') + . DIRECTORY_SEPARATOR . 'errors' . DIRECTORY_SEPARATOR . $addPath; + + // Determine the views + $view = $this->determineView($exception, $path); + $altView = $this->determineView($exception, $altPath); + + // Check if the view exists + $viewFile = null; + if (is_file($path . $view)) { + $viewFile = $path . $view; + } elseif (is_file($altPath . $altView)) { + $viewFile = $altPath . $altView; + } + + // Displays the HTML or CLI error code. + $this->render($exception, $statusCode, $viewFile); + + if (ENVIRONMENT !== 'testing') { + // @codeCoverageIgnoreStart + exit($exitCode); + // @codeCoverageIgnoreEnd + } + } + + /** + * Determines the view to display based on the exception thrown, + * whether an HTTP or CLI request, etc. + * + * @return string The filename of the view file to use + */ + protected function determineView(Throwable $exception, string $templatePath): string + { + // Production environments should have a custom exception file. + $view = 'production.php'; + + if (str_ireplace(['off', 'none', 'no', 'false', 'null'], '', ini_get('display_errors'))) { + $view = 'error_exception.php'; + } + + // 404 Errors + if ($exception instanceof PageNotFoundException) { + return 'error_404.php'; + } + + $templatePath = rtrim($templatePath, '\\/ ') . DIRECTORY_SEPARATOR; + + // Allow for custom views based upon the status code + if (is_file($templatePath . 'error_' . $exception->getCode() . '.php')) { + return 'error_' . $exception->getCode() . '.php'; + } + + return $view; + } +} diff --git a/system/Debug/ExceptionHandlerInterface.php b/system/Debug/ExceptionHandlerInterface.php new file mode 100644 index 000000000000..bbfcb6ba70ab --- /dev/null +++ b/system/Debug/ExceptionHandlerInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Throwable; + +interface ExceptionHandlerInterface +{ + /** + * Determines the correct way to display the error. + */ + public function handle( + Throwable $exception, + RequestInterface $request, + ResponseInterface $response, + int $statusCode, + int $exitCode + ): void; +} diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 77f5736d28a3..3c2c7902c687 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -15,12 +15,12 @@ use CodeIgniter\Exceptions\HasExitCodeInterface; use CodeIgniter\Exceptions\HTTPExceptionInterface; use CodeIgniter\Exceptions\PageNotFoundException; -use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\Exceptions\HTTPException; -use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Config\Exceptions as ExceptionsConfig; use Config\Paths; +use Config\Services; use ErrorException; use Psr\Log\LogLevel; use Throwable; @@ -36,6 +36,8 @@ class Exceptions * Nesting level of the output buffering mechanism * * @var int + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. */ public $ob_level; @@ -44,6 +46,8 @@ class Exceptions * cli and html error view directories. * * @var string + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. */ protected $viewPath; @@ -57,7 +61,7 @@ class Exceptions /** * The request. * - * @var CLIRequest|IncomingRequest + * @var RequestInterface|null */ protected $request; @@ -70,16 +74,13 @@ class Exceptions private ?Throwable $exceptionCaughtByExceptionHandler = null; - /** - * @param CLIRequest|IncomingRequest $request - */ - public function __construct(ExceptionsConfig $config, $request, ResponseInterface $response) + public function __construct(ExceptionsConfig $config) { + // For backward compatibility $this->ob_level = ob_get_level(); $this->viewPath = rtrim($config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; - $this->config = $config; - $this->request = $request; - $this->response = $response; + + $this->config = $config; // workaround for upgraded users // This causes "Deprecated: Creation of dynamic property" in PHP 8.2. @@ -113,8 +114,6 @@ public function initialize() * (Yay PHP7!). Will log the error, display it if display_errors is on, * and fire an event that allows custom actions to be taken at this point. * - * @codeCoverageIgnore - * * @return void * @phpstan-return never|void */ @@ -124,11 +123,6 @@ public function exceptionHandler(Throwable $exception) [$statusCode, $exitCode] = $this->determineCodes($exception); - // Get the first exception. - while ($prevException = $exception->getPrevious()) { - $exception = $prevException; - } - if ($this->config->log === true && ! in_array($statusCode, $this->config->ignoreCodes, true)) { log_message('critical', "{message}\nin {exFile} on line {exLine}.\n{trace}", [ 'message' => $exception->getMessage(), @@ -138,6 +132,29 @@ public function exceptionHandler(Throwable $exception) ]); } + $this->request = Services::request(); + $this->response = Services::response(); + + // Get the first exception. + while ($prevException = $exception->getPrevious()) { + $exception = $prevException; + } + + if (method_exists($this->config, 'handler')) { + // Use new ExceptionHandler + $handler = $this->config->handler($statusCode, $exception); + $handler->handle( + $exception, + $this->request, + $this->response, + $statusCode, + $exitCode + ); + + return; + } + + // For backward compatibility if (! is_cli()) { try { $this->response->setStatusCode($statusCode); @@ -225,6 +242,8 @@ public function shutdownHandler() * whether an HTTP or CLI request, etc. * * @return string The path and filename of the view file to use + * + * @deprecated 4.4.0 No longer used. Moved to ExceptionHandler. */ protected function determineView(Throwable $exception, string $templatePath): string { @@ -254,6 +273,8 @@ protected function determineView(Throwable $exception, string $templatePath): st * * @return void * @phpstan-return never|void + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. */ protected function render(Throwable $exception, int $statusCode) { @@ -281,10 +302,6 @@ protected function render(Throwable $exception, int $statusCode) exit(1); } - if (ob_get_level() > $this->ob_level + 1) { - ob_end_clean(); - } - echo(function () use ($exception, $statusCode, $viewFile): string { $vars = $this->collectVars($exception, $statusCode); extract($vars, EXTR_SKIP); @@ -298,6 +315,8 @@ protected function render(Throwable $exception, int $statusCode) /** * Gathers the variables that will be made available to the view. + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. */ protected function collectVars(Throwable $exception, int $statusCode): array { @@ -321,9 +340,11 @@ protected function collectVars(Throwable $exception, int $statusCode): array /** * Mask sensitive data in the trace. * - * @param array|object $trace + * @param array $trace * - * @return array|object + * @return array + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. */ protected function maskSensitiveData($trace, array $keysToMask, string $path = '') { @@ -338,6 +359,8 @@ protected function maskSensitiveData($trace, array $keysToMask, string $path = ' * @param array|object $args * * @return array|object + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. */ private function maskData($args, array $keysToMask, string $path = '') { @@ -453,6 +476,8 @@ public static function cleanPath(string $file): string /** * Describes memory usage in real-world units. Intended for use * with memory_get_usage, etc. + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. */ public static function describeMemory(int $bytes): string { @@ -471,6 +496,8 @@ public static function describeMemory(int $bytes): string * Creates a syntax-highlighted version of a PHP file. * * @return bool|string + * + * @deprecated 4.4.0 No longer used. Moved to BaseExceptionHandler. */ public static function highlightFile(string $file, int $lineNumber, int $lines = 15) { diff --git a/system/Debug/Toolbar/Collectors/Routes.php b/system/Debug/Toolbar/Collectors/Routes.php index 5ea88c0411c8..0420c19b92dd 100644 --- a/system/Debug/Toolbar/Collectors/Routes.php +++ b/system/Debug/Toolbar/Collectors/Routes.php @@ -11,6 +11,7 @@ namespace CodeIgniter\Debug\Toolbar\Collectors; +use CodeIgniter\Router\DefinedRouteCollector; use Config\Services; use ReflectionException; use ReflectionFunction; @@ -55,9 +56,6 @@ public function display(): array $rawRoutes = Services::routes(true); $router = Services::router(null, null, true); - // Matched Route - $route = $router->getMatchedRoute(); - // Get our parameters // Closure routes if (is_callable($router->controllerName())) { @@ -100,32 +98,18 @@ public function display(): array ]; // Defined Routes - $routes = []; - $methods = [ - 'get', - 'head', - 'post', - 'patch', - 'put', - 'delete', - 'options', - 'trace', - 'connect', - 'cli', - ]; - - foreach ($methods as $method) { - $raw = $rawRoutes->getRoutes($method); - - foreach ($raw as $route => $handler) { - // filter for strings, as callbacks aren't displayable - if (is_string($handler)) { - $routes[] = [ - 'method' => strtoupper($method), - 'route' => $route, - 'handler' => $handler, - ]; - } + $routes = []; + + $definedRouteCollector = new DefinedRouteCollector($rawRoutes); + + foreach ($definedRouteCollector->collect() as $route) { + // filter for strings, as callbacks aren't displayable + if ($route['handler'] !== '(Closure)') { + $routes[] = [ + 'method' => strtoupper($route['method']), + 'route' => $route['route'], + 'handler' => $route['handler'], + ]; } } diff --git a/system/Debug/Toolbar/Views/toolbar.css b/system/Debug/Toolbar/Views/toolbar.css index 744d9392c2be..38bab087ae81 100644 --- a/system/Debug/Toolbar/Views/toolbar.css +++ b/system/Debug/Toolbar/Views/toolbar.css @@ -796,3 +796,14 @@ .debug-bar-noverflow { overflow: hidden; } + +/* ENDLESS ROTATE */ +.rotate { + animation: rotate 9s linear infinite; +} + +@keyframes rotate { + to { + transform: rotate(360deg); + } +} diff --git a/system/Debug/Toolbar/Views/toolbar.js b/system/Debug/Toolbar/Views/toolbar.js index 7805a99dda05..073a76ab4312 100644 --- a/system/Debug/Toolbar/Views/toolbar.js +++ b/system/Debug/Toolbar/Views/toolbar.js @@ -3,15 +3,14 @@ */ var ciDebugBar = { + toolbarContainer: null, + toolbar: null, + icon: null, - toolbarContainer : null, - toolbar : null, - icon : null, - - init : function () { - this.toolbarContainer = document.getElementById('toolbarContainer'); - this.toolbar = document.getElementById('debug-bar'); - this.icon = document.getElementById('debug-icon'); + init: function () { + this.toolbarContainer = document.getElementById("toolbarContainer"); + this.toolbar = document.getElementById("debug-bar"); + this.icon = document.getElementById("debug-icon"); ciDebugBar.createListeners(); ciDebugBar.setToolbarState(); @@ -19,115 +18,123 @@ var ciDebugBar = { ciDebugBar.setToolbarTheme(); ciDebugBar.toggleViewsHints(); ciDebugBar.routerLink(); + ciDebugBar.setHotReloadState(); - document.getElementById('debug-bar-link').addEventListener('click', ciDebugBar.toggleToolbar, true); - document.getElementById('debug-icon-link').addEventListener('click', ciDebugBar.toggleToolbar, true); + document + .getElementById("debug-bar-link") + .addEventListener("click", ciDebugBar.toggleToolbar, true); + document + .getElementById("debug-icon-link") + .addEventListener("click", ciDebugBar.toggleToolbar, true); // Allows to highlight the row of the current history request - var btn = this.toolbar.querySelector('button[data-time="' + localStorage.getItem('debugbar-time') + '"]'); - ciDebugBar.addClass(btn.parentNode.parentNode, 'current'); - - historyLoad = this.toolbar.getElementsByClassName('ci-history-load'); - - for (var i = 0; i < historyLoad.length; i++) - { - historyLoad[i].addEventListener('click', function () { - loadDoc(this.getAttribute('data-time')); - }, true); + var btn = this.toolbar.querySelector( + 'button[data-time="' + localStorage.getItem("debugbar-time") + '"]' + ); + ciDebugBar.addClass(btn.parentNode.parentNode, "current"); + + historyLoad = this.toolbar.getElementsByClassName("ci-history-load"); + + for (var i = 0; i < historyLoad.length; i++) { + historyLoad[i].addEventListener( + "click", + function () { + loadDoc(this.getAttribute("data-time")); + }, + true + ); } // Display the active Tab on page load - var tab = ciDebugBar.readCookie('debug-bar-tab'); - if (document.getElementById(tab)) - { - var el = document.getElementById(tab); - el.style.display = 'block'; - ciDebugBar.addClass(el, 'active'); - tab = document.querySelector('[data-tab=' + tab + ']'); - if (tab) - { - ciDebugBar.addClass(tab.parentNode, 'active'); + var tab = ciDebugBar.readCookie("debug-bar-tab"); + if (document.getElementById(tab)) { + var el = document.getElementById(tab); + el.style.display = "block"; + ciDebugBar.addClass(el, "active"); + tab = document.querySelector("[data-tab=" + tab + "]"); + if (tab) { + ciDebugBar.addClass(tab.parentNode, "active"); } } }, - createListeners : function () { - var buttons = [].slice.call(this.toolbar.querySelectorAll('.ci-label a')); + createListeners: function () { + var buttons = [].slice.call( + this.toolbar.querySelectorAll(".ci-label a") + ); - for (var i = 0; i < buttons.length; i++) - { - buttons[i].addEventListener('click', ciDebugBar.showTab, true); + for (var i = 0; i < buttons.length; i++) { + buttons[i].addEventListener("click", ciDebugBar.showTab, true); } // Hook up generic toggle via data attributes `data-toggle="foo"` - var links = this.toolbar.querySelectorAll('[data-toggle]'); - for (var i = 0; i < links.length; i++) - { - links[i].addEventListener('click', ciDebugBar.toggleRows, true); + var links = this.toolbar.querySelectorAll("[data-toggle]"); + for (var i = 0; i < links.length; i++) { + links[i].addEventListener("click", ciDebugBar.toggleRows, true); } }, showTab: function () { // Get the target tab, if any - var tab = document.getElementById(this.getAttribute('data-tab')); + var tab = document.getElementById(this.getAttribute("data-tab")); // If the label have not a tab stops here - if (! tab) - { + if (!tab) { return; } // Remove debug-bar-tab cookie - ciDebugBar.createCookie('debug-bar-tab', '', -1); + ciDebugBar.createCookie("debug-bar-tab", "", -1); // Check our current state. var state = tab.style.display; // Hide all tabs - var tabs = document.querySelectorAll('#debug-bar .tab'); + var tabs = document.querySelectorAll("#debug-bar .tab"); - for (var i = 0; i < tabs.length; i++) - { - tabs[i].style.display = 'none'; + for (var i = 0; i < tabs.length; i++) { + tabs[i].style.display = "none"; } // Mark all labels as inactive - var labels = document.querySelectorAll('#debug-bar .ci-label'); + var labels = document.querySelectorAll("#debug-bar .ci-label"); - for (var i = 0; i < labels.length; i++) - { - ciDebugBar.removeClass(labels[i], 'active'); + for (var i = 0; i < labels.length; i++) { + ciDebugBar.removeClass(labels[i], "active"); } // Show/hide the selected tab - if (state != 'block') - { - tab.style.display = 'block'; - ciDebugBar.addClass(this.parentNode, 'active'); + if (state != "block") { + tab.style.display = "block"; + ciDebugBar.addClass(this.parentNode, "active"); // Create debug-bar-tab cookie to persistent state - ciDebugBar.createCookie('debug-bar-tab', this.getAttribute('data-tab'), 365); + ciDebugBar.createCookie( + "debug-bar-tab", + this.getAttribute("data-tab"), + 365 + ); } }, - addClass : function (el, className) { - if (el.classList) - { + addClass: function (el, className) { + if (el.classList) { el.classList.add(className); - } - else - { - el.className += ' ' + className; + } else { + el.className += " " + className; } }, - removeClass : function (el, className) { - if (el.classList) - { + removeClass: function (el, className) { + if (el.classList) { el.classList.remove(className); - } - else - { - el.className = el.className.replace(new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'), ' '); + } else { + el.className = el.className.replace( + new RegExp( + "(^|\\b)" + className.split(" ").join("|") + "(\\b|$)", + "gi" + ), + " " + ); } }, @@ -137,12 +144,14 @@ var ciDebugBar = { * * @param event */ - toggleRows : function(event) { - if(event.target) - { - let row = event.target.closest('tr'); - let target = document.getElementById(row.getAttribute('data-toggle')); - target.style.display = target.style.display === 'none' ? 'table-row' : 'none'; + toggleRows: function (event) { + if (event.target) { + let row = event.target.closest("tr"); + let target = document.getElementById( + row.getAttribute("data-toggle") + ); + target.style.display = + target.style.display === "none" ? "table-row" : "none"; } }, @@ -151,15 +160,13 @@ var ciDebugBar = { * * @param obj */ - toggleDataTable : function (obj) { - if (typeof obj == 'string') - { - obj = document.getElementById(obj + '_table'); + toggleDataTable: function (obj) { + if (typeof obj == "string") { + obj = document.getElementById(obj + "_table"); } - if (obj) - { - obj.style.display = obj.style.display === 'none' ? 'block' : 'none'; + if (obj) { + obj.style.display = obj.style.display === "none" ? "block" : "none"; } }, @@ -168,35 +175,37 @@ var ciDebugBar = { * * @param obj */ - toggleChildRows : function (obj) { - if (typeof obj == 'string') - { - par = document.getElementById(obj + '_parent') - obj = document.getElementById(obj + '_children'); + toggleChildRows: function (obj) { + if (typeof obj == "string") { + par = document.getElementById(obj + "_parent"); + obj = document.getElementById(obj + "_children"); } - if (par && obj) - { - obj.style.display = obj.style.display === 'none' ? '' : 'none'; - par.classList.toggle('timeline-parent-open'); + if (par && obj) { + obj.style.display = obj.style.display === "none" ? "" : "none"; + par.classList.toggle("timeline-parent-open"); } }, - //-------------------------------------------------------------------- /** * Toggle tool bar from full to icon and icon to full */ - toggleToolbar : function () { - var open = ciDebugBar.toolbar.style.display != 'none'; + toggleToolbar: function () { + var open = ciDebugBar.toolbar.style.display != "none"; - ciDebugBar.icon.style.display = open == true ? 'inline-block' : 'none'; - ciDebugBar.toolbar.style.display = open == false ? 'inline-block' : 'none'; + ciDebugBar.icon.style.display = open == true ? "inline-block" : "none"; + ciDebugBar.toolbar.style.display = + open == false ? "inline-block" : "none"; // Remember it for other page loads on this site - ciDebugBar.createCookie('debug-bar-state', '', -1); - ciDebugBar.createCookie('debug-bar-state', open == true ? 'minimized' : 'open' , 365); + ciDebugBar.createCookie("debug-bar-state", "", -1); + ciDebugBar.createCookie( + "debug-bar-state", + open == true ? "minimized" : "open", + 365 + ); }, /** @@ -204,49 +213,58 @@ var ciDebugBar = { * the page is first loaded to allow it to remember the state between refreshes. */ setToolbarState: function () { - var open = ciDebugBar.readCookie('debug-bar-state'); + var open = ciDebugBar.readCookie("debug-bar-state"); - ciDebugBar.icon.style.display = open != 'open' ? 'inline-block' : 'none'; - ciDebugBar.toolbar.style.display = open == 'open' ? 'inline-block' : 'none'; + ciDebugBar.icon.style.display = + open != "open" ? "inline-block" : "none"; + ciDebugBar.toolbar.style.display = + open == "open" ? "inline-block" : "none"; }, toggleViewsHints: function () { // Avoid toggle hints on history requests that are not the initial - if (localStorage.getItem('debugbar-time') != localStorage.getItem('debugbar-time-new')) - { - var a = document.querySelector('a[data-tab="ci-views"]'); - a.href = '#'; + if ( + localStorage.getItem("debugbar-time") != + localStorage.getItem("debugbar-time-new") + ) { + var a = document.querySelector('a[data-tab="ci-views"]'); + a.href = "#"; return; } - var nodeList = []; // [ Element, NewElement( 1 )/OldElement( 0 ) ] + var nodeList = []; // [ Element, NewElement( 1 )/OldElement( 0 ) ] var sortedComments = []; - var comments = []; + var comments = []; var getComments = function () { - var nodes = []; - var result = []; - var xpathResults = document.evaluate( "//comment()[starts-with(., ' DEBUG-VIEW')]", document, null, XPathResult.ANY_TYPE, null); - var nextNode = xpathResults.iterateNext(); - while ( nextNode ) - { - nodes.push( nextNode ); + var nodes = []; + var result = []; + var xpathResults = document.evaluate( + "//comment()[starts-with(., ' DEBUG-VIEW')]", + document, + null, + XPathResult.ANY_TYPE, + null + ); + var nextNode = xpathResults.iterateNext(); + while (nextNode) { + nodes.push(nextNode); nextNode = xpathResults.iterateNext(); } // sort comment by opening and closing tags - for (var i = 0; i < nodes.length; ++i) - { + for (var i = 0; i < nodes.length; ++i) { // get file path + name to use as key - var path = nodes[i].nodeValue.substring( 18, nodes[i].nodeValue.length - 1 ); + var path = nodes[i].nodeValue.substring( + 18, + nodes[i].nodeValue.length - 1 + ); - if ( nodes[i].nodeValue[12] === 'S' ) // simple check for start comment - { + if (nodes[i].nodeValue[12] === "S") { + // simple check for start comment // create new entry - result[path] = [ nodes[i], null ]; - } - else if (result[path]) - { + result[path] = [nodes[i], null]; + } else if (result[path]) { // add to existing entry result[path][1] = nodes[i]; } @@ -256,73 +274,81 @@ var ciDebugBar = { }; // find node that has TargetNode as parentNode - var getParentNode = function ( node, targetNode ) { - if ( node.parentNode === null ) - { + var getParentNode = function (node, targetNode) { + if (node.parentNode === null) { return null; } - if ( node.parentNode !== targetNode ) - { - return getParentNode( node.parentNode, targetNode ); + if (node.parentNode !== targetNode) { + return getParentNode(node.parentNode, targetNode); } return node; }; // define invalid & outer ( also invalid ) elements - const INVALID_ELEMENTS = [ 'NOSCRIPT', 'SCRIPT', 'STYLE' ]; - const OUTER_ELEMENTS = [ 'HTML', 'BODY', 'HEAD' ]; + const INVALID_ELEMENTS = ["NOSCRIPT", "SCRIPT", "STYLE"]; + const OUTER_ELEMENTS = ["HTML", "BODY", "HEAD"]; - var getValidElementInner = function ( node, reverse ) { + var getValidElementInner = function (node, reverse) { // handle invalid tags - if ( OUTER_ELEMENTS.indexOf( node.nodeName ) !== -1 ) - { - for (var i = 0; i < document.body.children.length; ++i) - { - var index = reverse ? document.body.children.length - ( i + 1 ) : i; + if (OUTER_ELEMENTS.indexOf(node.nodeName) !== -1) { + for (var i = 0; i < document.body.children.length; ++i) { + var index = reverse + ? document.body.children.length - (i + 1) + : i; var element = document.body.children[index]; // skip invalid tags - if ( INVALID_ELEMENTS.indexOf( element.nodeName ) !== -1 ) - { + if (INVALID_ELEMENTS.indexOf(element.nodeName) !== -1) { continue; } - return [ element, reverse ]; + return [element, reverse]; } return null; } // get to next valid element - while ( node !== null && INVALID_ELEMENTS.indexOf( node.nodeName ) !== -1 ) - { - node = reverse ? node.previousElementSibling : node.nextElementSibling; + while ( + node !== null && + INVALID_ELEMENTS.indexOf(node.nodeName) !== -1 + ) { + node = reverse + ? node.previousElementSibling + : node.nextElementSibling; } // return non array if we couldnt find something - if ( node === null ) - { + if (node === null) { return null; } - return [ node, reverse ]; + return [node, reverse]; }; // get next valid element ( to be safe to add divs ) // @return [ element, skip element ] or null if we couldnt find a valid place - var getValidElement = function ( nodeElement ) { - if (nodeElement) - { - if ( nodeElement.nextElementSibling !== null ) - { - return getValidElementInner( nodeElement.nextElementSibling, false ) - || getValidElementInner( nodeElement.previousElementSibling, true ); + var getValidElement = function (nodeElement) { + if (nodeElement) { + if (nodeElement.nextElementSibling !== null) { + return ( + getValidElementInner( + nodeElement.nextElementSibling, + false + ) || + getValidElementInner( + nodeElement.previousElementSibling, + true + ) + ); } - if ( nodeElement.previousElementSibling !== null ) - { - return getValidElementInner( nodeElement.previousElementSibling, true ); + if (nodeElement.previousElementSibling !== null) { + return getValidElementInner( + nodeElement.previousElementSibling, + true + ); } } @@ -330,89 +356,80 @@ var ciDebugBar = { return null; }; - function showHints() - { + function showHints() { // Had AJAX? Reset view blocks sortedComments = getComments(); - for (var key in sortedComments) - { - var startElement = getValidElement( sortedComments[key][0] ); - var endElement = getValidElement( sortedComments[key][1] ); + for (var key in sortedComments) { + var startElement = getValidElement(sortedComments[key][0]); + var endElement = getValidElement(sortedComments[key][1]); // skip if we couldnt get a valid element - if ( startElement === null || endElement === null ) - { + if (startElement === null || endElement === null) { continue; } // find element which has same parent as startelement - var jointParent = getParentNode( endElement[0], startElement[0].parentNode ); - if ( jointParent === null ) - { + var jointParent = getParentNode( + endElement[0], + startElement[0].parentNode + ); + if (jointParent === null) { // find element which has same parent as endelement - jointParent = getParentNode( startElement[0], endElement[0].parentNode ); - if ( jointParent === null ) - { + jointParent = getParentNode( + startElement[0], + endElement[0].parentNode + ); + if (jointParent === null) { // both tries failed continue; - } - else - { + } else { startElement[0] = jointParent; } - } - else - { + } else { endElement[0] = jointParent; } - var debugDiv = document.createElement( 'div' ); // holder - var debugPath = document.createElement( 'div' ); // path + var debugDiv = document.createElement("div"); // holder + var debugPath = document.createElement("div"); // path var childArray = startElement[0].parentNode.childNodes; // target child array - var parent = startElement[0].parentNode; + var parent = startElement[0].parentNode; var start, end; // setup container - debugDiv.classList.add( 'debug-view' ); - debugDiv.classList.add( 'show-view' ); - debugPath.classList.add( 'debug-view-path' ); + debugDiv.classList.add("debug-view"); + debugDiv.classList.add("show-view"); + debugPath.classList.add("debug-view-path"); debugPath.innerText = key; - debugDiv.appendChild( debugPath ); + debugDiv.appendChild(debugPath); // calc distance between them // start - for (var i = 0; i < childArray.length; ++i) - { + for (var i = 0; i < childArray.length; ++i) { // check for comment ( start & end ) -> if its before valid start element - if ( childArray[i] === sortedComments[key][1] || + if ( + childArray[i] === sortedComments[key][1] || childArray[i] === sortedComments[key][0] || - childArray[i] === startElement[0] ) - { + childArray[i] === startElement[0] + ) { start = i; - if ( childArray[i] === sortedComments[key][0] ) - { + if (childArray[i] === sortedComments[key][0]) { start++; // increase to skip the start comment } break; } } // adjust if we want to skip the start element - if ( startElement[1] ) - { + if (startElement[1]) { start++; } // end - for (var i = start; i < childArray.length; ++i) - { - if ( childArray[i] === endElement[0] ) - { + for (var i = start; i < childArray.length; ++i) { + if (childArray[i] === endElement[0]) { end = i; // dont break to check for end comment after end valid element - } - else if ( childArray[i] === sortedComments[key][1] ) - { + } else if (childArray[i] === sortedComments[key][1]) { // if we found the end comment, we can break end = i; break; @@ -421,161 +438,230 @@ var ciDebugBar = { // move elements var number = end - start; - if ( endElement[1] ) - { + if (endElement[1]) { number++; } - for (var i = 0; i < number; ++i) - { - if ( INVALID_ELEMENTS.indexOf( childArray[start] ) !== -1 ) - { + for (var i = 0; i < number; ++i) { + if (INVALID_ELEMENTS.indexOf(childArray[start]) !== -1) { // skip invalid childs that can cause problems if moved start++; continue; } - debugDiv.appendChild( childArray[start] ); + debugDiv.appendChild(childArray[start]); } // add container to DOM - nodeList.push( parent.insertBefore( debugDiv, childArray[start] ) ); + nodeList.push(parent.insertBefore(debugDiv, childArray[start])); } - ciDebugBar.createCookie('debug-view', 'show', 365); - ciDebugBar.addClass(btn, 'active'); + ciDebugBar.createCookie("debug-view", "show", 365); + ciDebugBar.addClass(btn, "active"); } - function hideHints() - { - for (var i = 0; i < nodeList.length; ++i) - { + function hideHints() { + for (var i = 0; i < nodeList.length; ++i) { var index; // find index - for (var j = 0; j < nodeList[i].parentNode.childNodes.length; ++j) - { - if ( nodeList[i].parentNode.childNodes[j] === nodeList[i] ) - { + for ( + var j = 0; + j < nodeList[i].parentNode.childNodes.length; + ++j + ) { + if (nodeList[i].parentNode.childNodes[j] === nodeList[i]) { index = j; break; } } // move child back - while ( nodeList[i].childNodes.length !== 1 ) - { - nodeList[i].parentNode.insertBefore( nodeList[i].childNodes[1], nodeList[i].parentNode.childNodes[index].nextSibling ); + while (nodeList[i].childNodes.length !== 1) { + nodeList[i].parentNode.insertBefore( + nodeList[i].childNodes[1], + nodeList[i].parentNode.childNodes[index].nextSibling + ); index++; } - nodeList[i].parentNode.removeChild( nodeList[i] ); + nodeList[i].parentNode.removeChild(nodeList[i]); } nodeList.length = 0; - ciDebugBar.createCookie('debug-view', '', -1); - ciDebugBar.removeClass(btn, 'active'); + ciDebugBar.createCookie("debug-view", "", -1); + ciDebugBar.removeClass(btn, "active"); } - var btn = document.querySelector('[data-tab=ci-views]'); + var btn = document.querySelector("[data-tab=ci-views]"); // If the Views Collector is inactive stops here - if (! btn) - { + if (!btn) { return; } btn.parentNode.onclick = function () { - if (ciDebugBar.readCookie('debug-view')) - { + if (ciDebugBar.readCookie("debug-view")) { hideHints(); - } - else - { + } else { showHints(); } }; // Determine Hints state on page load - if (ciDebugBar.readCookie('debug-view')) - { + if (ciDebugBar.readCookie("debug-view")) { showHints(); } }, setToolbarPosition: function () { - var btnPosition = this.toolbar.querySelector('#toolbar-position'); + var btnPosition = this.toolbar.querySelector("#toolbar-position"); - if (ciDebugBar.readCookie('debug-bar-position') === 'top') - { - ciDebugBar.addClass(ciDebugBar.icon, 'fixed-top'); - ciDebugBar.addClass(ciDebugBar.toolbar, 'fixed-top'); + if (ciDebugBar.readCookie("debug-bar-position") === "top") { + ciDebugBar.addClass(ciDebugBar.icon, "fixed-top"); + ciDebugBar.addClass(ciDebugBar.toolbar, "fixed-top"); } - btnPosition.addEventListener('click', function () { - var position = ciDebugBar.readCookie('debug-bar-position'); - - ciDebugBar.createCookie('debug-bar-position', '', -1); - - if (!position || position === 'bottom') - { - ciDebugBar.createCookie('debug-bar-position', 'top', 365); - ciDebugBar.addClass(ciDebugBar.icon, 'fixed-top'); - ciDebugBar.addClass(ciDebugBar.toolbar, 'fixed-top'); - } - else - { - ciDebugBar.createCookie('debug-bar-position', 'bottom', 365); - ciDebugBar.removeClass(ciDebugBar.icon, 'fixed-top'); - ciDebugBar.removeClass(ciDebugBar.toolbar, 'fixed-top'); - } - }, true); + btnPosition.addEventListener( + "click", + function () { + var position = ciDebugBar.readCookie("debug-bar-position"); + + ciDebugBar.createCookie("debug-bar-position", "", -1); + + if (!position || position === "bottom") { + ciDebugBar.createCookie("debug-bar-position", "top", 365); + ciDebugBar.addClass(ciDebugBar.icon, "fixed-top"); + ciDebugBar.addClass(ciDebugBar.toolbar, "fixed-top"); + } else { + ciDebugBar.createCookie( + "debug-bar-position", + "bottom", + 365 + ); + ciDebugBar.removeClass(ciDebugBar.icon, "fixed-top"); + ciDebugBar.removeClass(ciDebugBar.toolbar, "fixed-top"); + } + }, + true + ); }, setToolbarTheme: function () { - var btnTheme = this.toolbar.querySelector('#toolbar-theme'); - var isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; - var isLightMode = window.matchMedia("(prefers-color-scheme: light)").matches; + var btnTheme = this.toolbar.querySelector("#toolbar-theme"); + var isDarkMode = window.matchMedia( + "(prefers-color-scheme: dark)" + ).matches; + var isLightMode = window.matchMedia( + "(prefers-color-scheme: light)" + ).matches; // If a cookie is set with a value, we force the color scheme - if (ciDebugBar.readCookie('debug-bar-theme') === 'dark') - { - ciDebugBar.removeClass(ciDebugBar.toolbarContainer, 'light'); - ciDebugBar.addClass(ciDebugBar.toolbarContainer, 'dark'); + if (ciDebugBar.readCookie("debug-bar-theme") === "dark") { + ciDebugBar.removeClass(ciDebugBar.toolbarContainer, "light"); + ciDebugBar.addClass(ciDebugBar.toolbarContainer, "dark"); + } else if (ciDebugBar.readCookie("debug-bar-theme") === "light") { + ciDebugBar.removeClass(ciDebugBar.toolbarContainer, "dark"); + ciDebugBar.addClass(ciDebugBar.toolbarContainer, "light"); } - else if (ciDebugBar.readCookie('debug-bar-theme') === 'light') - { - ciDebugBar.removeClass(ciDebugBar.toolbarContainer, 'dark'); - ciDebugBar.addClass(ciDebugBar.toolbarContainer, 'light'); + + btnTheme.addEventListener( + "click", + function () { + var theme = ciDebugBar.readCookie("debug-bar-theme"); + + if ( + !theme && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + // If there is no cookie, and "prefers-color-scheme" is set to "dark" + // It means that the user wants to switch to light mode + ciDebugBar.createCookie("debug-bar-theme", "light", 365); + ciDebugBar.removeClass(ciDebugBar.toolbarContainer, "dark"); + ciDebugBar.addClass(ciDebugBar.toolbarContainer, "light"); + } else { + if (theme === "dark") { + ciDebugBar.createCookie( + "debug-bar-theme", + "light", + 365 + ); + ciDebugBar.removeClass( + ciDebugBar.toolbarContainer, + "dark" + ); + ciDebugBar.addClass( + ciDebugBar.toolbarContainer, + "light" + ); + } else { + // In any other cases: if there is no cookie, or the cookie is set to + // "light", or the "prefers-color-scheme" is "light"... + ciDebugBar.createCookie("debug-bar-theme", "dark", 365); + ciDebugBar.removeClass( + ciDebugBar.toolbarContainer, + "light" + ); + ciDebugBar.addClass( + ciDebugBar.toolbarContainer, + "dark" + ); + } + } + }, + true + ); + }, + + setHotReloadState: function () { + var btn = document.getElementById("debug-hot-reload").parentNode; + var btnImg = btn.getElementsByTagName("img")[0]; + var eventSource; + + // If the Hot Reload Collector is inactive stops here + if (!btn) { + return; } - btnTheme.addEventListener('click', function () { - var theme = ciDebugBar.readCookie('debug-bar-theme'); + btn.onclick = function () { + if (ciDebugBar.readCookie("debug-hot-reload")) { + ciDebugBar.createCookie("debug-hot-reload", "", -1); + ciDebugBar.removeClass(btn, "active"); + ciDebugBar.removeClass(btnImg, "rotate"); - if (!theme && window.matchMedia("(prefers-color-scheme: dark)").matches) - { - // If there is no cookie, and "prefers-color-scheme" is set to "dark" - // It means that the user wants to switch to light mode - ciDebugBar.createCookie('debug-bar-theme', 'light', 365); - ciDebugBar.removeClass(ciDebugBar.toolbarContainer, 'dark'); - ciDebugBar.addClass(ciDebugBar.toolbarContainer, 'light'); - } - else - { - if (theme === 'dark') - { - ciDebugBar.createCookie('debug-bar-theme', 'light', 365); - ciDebugBar.removeClass(ciDebugBar.toolbarContainer, 'dark'); - ciDebugBar.addClass(ciDebugBar.toolbarContainer, 'light'); - } - else - { - // In any other cases: if there is no cookie, or the cookie is set to - // "light", or the "prefers-color-scheme" is "light"... - ciDebugBar.createCookie('debug-bar-theme', 'dark', 365); - ciDebugBar.removeClass(ciDebugBar.toolbarContainer, 'light'); - ciDebugBar.addClass(ciDebugBar.toolbarContainer, 'dark'); + // Close the EventSource connection if it exists + if (typeof eventSource !== "undefined") { + eventSource.close(); + eventSource = void 0; // Undefine the variable } + } else { + ciDebugBar.createCookie("debug-hot-reload", "show", 365); + ciDebugBar.addClass(btn, "active"); + ciDebugBar.addClass(btnImg, "rotate"); + + eventSource = ciDebugBar.hotReloadConnect(); } - }, true); + }; + + // Determine Hot Reload state on page load + if (ciDebugBar.readCookie("debug-hot-reload")) { + ciDebugBar.addClass(btn, "active"); + ciDebugBar.addClass(btnImg, "rotate"); + eventSource = ciDebugBar.hotReloadConnect(); + } + }, + + hotReloadConnect: function () { + const eventSource = new EventSource(ciSiteURL + "/__hot-reload"); + + eventSource.addEventListener("reload", function (e) { + console.log("reload", e); + window.location.reload(); + }); + + eventSource.onerror = (err) => { + console.error("EventSource failed:", err); + }; + + return eventSource; }, /** @@ -585,103 +671,112 @@ var ciDebugBar = { * @param value * @param days */ - createCookie : function (name,value,days) { - if (days) - { + createCookie: function (name, value, days) { + if (days) { var date = new Date(); - date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); + date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); var expires = "; expires=" + date.toGMTString(); - } - else - { + } else { var expires = ""; } - document.cookie = name + "=" + value + expires + "; path=/; samesite=Lax"; + document.cookie = + name + "=" + value + expires + "; path=/; samesite=Lax"; }, - readCookie : function (name) { + readCookie: function (name) { var nameEQ = name + "="; - var ca = document.cookie.split(';'); + var ca = document.cookie.split(";"); - for (var i = 0; i < ca.length; i++) - { + for (var i = 0; i < ca.length; i++) { var c = ca[i]; - while (c.charAt(0) == ' ') - { - c = c.substring(1,c.length); + while (c.charAt(0) == " ") { + c = c.substring(1, c.length); } - if (c.indexOf(nameEQ) == 0) - { - return c.substring(nameEQ.length,c.length); + if (c.indexOf(nameEQ) == 0) { + return c.substring(nameEQ.length, c.length); } } return null; }, trimSlash: function (text) { - return text.replace(/^\/|\/$/g, ''); + return text.replace(/^\/|\/$/g, ""); }, routerLink: function () { var row, _location; - var rowGet = this.toolbar.querySelectorAll('td[data-debugbar-route="GET"]'); - var patt = /\((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)/; + var rowGet = this.toolbar.querySelectorAll( + 'td[data-debugbar-route="GET"]' + ); + var patt = /\((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)/; - for (var i = 0; i < rowGet.length; i++) - { + for (var i = 0; i < rowGet.length; i++) { row = rowGet[i]; - if (!/\/\(.+?\)/.test(rowGet[i].innerText)) - { - row.style = 'cursor: pointer;'; - row.setAttribute('title', location.origin + '/' + ciDebugBar.trimSlash(row.innerText)); - row.addEventListener('click', function (ev) { - _location = location.origin + '/' + ciDebugBar.trimSlash(ev.target.innerText); - var redirectWindow = window.open(_location, '_blank'); + if (!/\/\(.+?\)/.test(rowGet[i].innerText)) { + row.style = "cursor: pointer;"; + row.setAttribute( + "title", + location.origin + "/" + ciDebugBar.trimSlash(row.innerText) + ); + row.addEventListener("click", function (ev) { + _location = + location.origin + + "/" + + ciDebugBar.trimSlash(ev.target.innerText); + var redirectWindow = window.open(_location, "_blank"); redirectWindow.location; }); - } - else - { - row.innerHTML = '
' + row.innerText + '
' - + '
' - + row.innerText.replace(patt, '') - + '' - + '
'; + } else { + row.innerHTML = + "
" + + row.innerText + + "
" + + '
' + + row.innerText.replace( + patt, + '' + ) + + '' + + "
"; } } - rowGet = this.toolbar.querySelectorAll('td[data-debugbar-route="GET"] form'); - for (var i = 0; i < rowGet.length; i++) - { + rowGet = this.toolbar.querySelectorAll( + 'td[data-debugbar-route="GET"] form' + ); + for (var i = 0; i < rowGet.length; i++) { row = rowGet[i]; - row.addEventListener('submit', function (event) { - event.preventDefault() - var inputArray = [], t = 0; - var input = event.target.querySelectorAll('input[type=text]'); - var tpl = event.target.getAttribute('data-debugbar-route-tpl'); + row.addEventListener("submit", function (event) { + event.preventDefault(); + var inputArray = [], + t = 0; + var input = event.target.querySelectorAll("input[type=text]"); + var tpl = event.target.getAttribute("data-debugbar-route-tpl"); - for (var n = 0; n < input.length; n++) - { - if (input[n].value.length > 0) - { + for (var n = 0; n < input.length; n++) { + if (input[n].value.length > 0) { inputArray.push(input[n].value); } } - if (inputArray.length > 0) - { - _location = location.origin + '/' + tpl.replace(/\?/g, function () { - return inputArray[t++] - }); + if (inputArray.length > 0) { + _location = + location.origin + + "/" + + tpl.replace(/\?/g, function () { + return inputArray[t++]; + }); - var redirectWindow = window.open(_location, '_blank'); + var redirectWindow = window.open(_location, "_blank"); redirectWindow.location; } - }) + }); } - } + }, }; diff --git a/system/Debug/Toolbar/Views/toolbar.tpl.php b/system/Debug/Toolbar/Views/toolbar.tpl.php index 7c0d5336a5d4..6e62340c6ded 100644 --- a/system/Debug/Toolbar/Views/toolbar.tpl.php +++ b/system/Debug/Toolbar/Views/toolbar.tpl.php @@ -23,6 +23,7 @@
@@ -34,6 +35,11 @@
🔅 + + + + + diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php index a979567d75e7..0481cc35823f 100644 --- a/system/Entity/Entity.php +++ b/system/Entity/Entity.php @@ -289,7 +289,7 @@ public function hasChanged(?string $key = null): bool * * @return $this */ - public function setAttributes(array $data) + public function injectRawData(array $data) { $this->attributes = $data; @@ -298,6 +298,18 @@ public function setAttributes(array $data) return $this; } + /** + * Set raw data array without any mutations + * + * @return $this + * + * @deprecated Use injectRawData() instead. + */ + public function setAttributes(array $data) + { + return $this->injectRawData($data); + } + /** * Checks the datamap to see if this property name is being mapped, * and returns the db column name, if any, or the original property name. @@ -454,12 +466,20 @@ public function __set(string $key, $value = null) $value = $this->castAs($value, $dbColumn, 'set'); - // if a set* method exists for this key, use that method to + // if a setter method exists for this key, use that method to // insert this value. should be outside $isNullable check, // so maybe wants to do sth with null value automatically $method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn))); - if (method_exists($this, $method)) { + // If a "`_set` + $key" method exists, it is a setter. + if (method_exists($this, '_' . $method)) { + $this->{'_' . $method}($value); + + return; + } + + // If a "`set` + $key" method exists, it is also a setter. + if (method_exists($this, $method) && $method !== 'setAttributes') { $this->{$method}($value); return; @@ -495,9 +515,13 @@ public function __get(string $key) // Convert to CamelCase for the method $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn))); - // if a get* method exists for this key, + // if a getter method exists for this key, // use that method to insert this value. - if (method_exists($this, $method)) { + if (method_exists($this, '_' . $method)) { + // If a "`_get` + $key" method exists, it is a getter. + $result = $this->{'_' . $method}(); + } elseif (method_exists($this, $method)) { + // If a "`get` + $key" method exists, it is also a getter. $result = $this->{$method}(); } diff --git a/system/Exceptions/FrameworkException.php b/system/Exceptions/FrameworkException.php index 7d71147c18de..4cafd71177e2 100644 --- a/system/Exceptions/FrameworkException.php +++ b/system/Exceptions/FrameworkException.php @@ -39,6 +39,14 @@ public static function forInvalidFile(string $path) return new static(lang('Core.invalidFile', [$path])); } + /** + * @return static + */ + public static function forInvalidDirectory(string $path) + { + return new static(lang('Core.invalidDirectory', [$path])); + } + /** * @return static */ diff --git a/system/Filters/Filters.php b/system/Filters/Filters.php index e4070b9eeccc..ccd8283b0121 100644 --- a/system/Filters/Filters.php +++ b/system/Filters/Filters.php @@ -11,6 +11,7 @@ namespace CodeIgniter\Filters; +use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\Filters\Exceptions\FilterException; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; @@ -63,7 +64,7 @@ class Filters * The processed filters that will * be used to check against. * - * @var array + * @var array */ protected $filters = [ 'before' => [], @@ -74,7 +75,7 @@ class Filters * The collection of filters' class names that will * be used to execute in each position. * - * @var array + * @var array */ protected $filtersClass = [ 'before' => [], @@ -84,14 +85,16 @@ class Filters /** * Any arguments to be passed to filters. * - * @var array + * @var array|null> [name => params] + * @phpstan-var array|null> */ protected $arguments = []; /** * Any arguments to be passed to filtersClass. * - * @var array + * @var array [classname => arguments] + * @phpstan-var array>|null> */ protected $argumentsClass = []; @@ -174,7 +177,10 @@ public function run(string $uri, string $position = 'before') } if ($position === 'before') { - $result = $class->before($this->request, $this->argumentsClass[$className] ?? null); + $result = $class->before( + $this->request, + $this->argumentsClass[$className] ?? null + ); if ($result instanceof RequestInterface) { $this->request = $result; @@ -197,7 +203,11 @@ public function run(string $uri, string $position = 'before') } if ($position === 'after') { - $result = $class->after($this->request, $this->response, $this->argumentsClass[$className] ?? null); + $result = $class->after( + $this->request, + $this->response, + $this->argumentsClass[$className] ?? null + ); if ($result instanceof ResponseInterface) { $this->response = $result; @@ -322,23 +332,17 @@ public function addFilter(string $class, ?string $alias = null, string $when = ' * after the filter name, followed by a comma-separated list of arguments that * are passed to the filter when executed. * - * @return Filters + * @param string $name filter_name or filter_name:arguments like 'role:admin,manager' + * + * @return $this * * @deprecated Use enableFilters(). This method will be private. */ public function enableFilter(string $name, string $when = 'before') { - // Get parameters and clean name - if (strpos($name, ':') !== false) { - [$name, $params] = explode(':', $name); - - $params = explode(',', $params); - array_walk($params, static function (&$item) { - $item = trim($item); - }); - - $this->arguments[$name] = $params; - } + // Get arguments and clean name + [$name, $arguments] = $this->getCleanName($name); + $this->arguments[$name] = ($arguments !== []) ? $arguments : null; if (class_exists($name)) { $this->config->aliases[$name] = $name; @@ -360,6 +364,30 @@ public function enableFilter(string $name, string $when = 'before') return $this; } + /** + * Get clean name and arguments + * + * @param string $name filter_name or filter_name:arguments like 'role:admin,manager' + * + * @return array [name, arguments] + * @phpstan-return array{0: string, 1: list} + */ + private function getCleanName(string $name): array + { + $arguments = []; + + if (strpos($name, ':') !== false) { + [$name, $arguments] = explode(':', $name); + + $arguments = explode(',', $arguments); + array_walk($arguments, static function (&$item) { + $item = trim($item); + }); + } + + return [$name, $arguments]; + } + /** * Ensures that specific filters are on and enabled for the current request. * @@ -367,6 +395,8 @@ public function enableFilter(string $name, string $when = 'before') * after the filter name, followed by a comma-separated list of arguments that * are passed to the filter when executed. * + * @params array $names filter_name or filter_name:arguments like 'role:admin,manager' + * * @return Filters */ public function enableFilters(array $names, string $when = 'before') @@ -475,20 +505,59 @@ protected function processFilters(?string $uri = null) // Look for inclusion rules if (isset($settings['before'])) { $path = $settings['before']; + if ($this->pathApplies($uri, $path)) { - $this->filters['before'][] = $alias; + // Get arguments and clean name + [$name, $arguments] = $this->getCleanName($alias); + + $this->filters['before'][] = $name; + + $this->registerArguments($name, $arguments); } } if (isset($settings['after'])) { $path = $settings['after']; + if ($this->pathApplies($uri, $path)) { - $this->filters['after'][] = $alias; + // Get arguments and clean name + [$name, $arguments] = $this->getCleanName($alias); + + $this->filters['after'][] = $name; + + // The arguments may have already been registered in the before filter. + // So disable check. + $this->registerArguments($name, $arguments, false); } } } } + /** + * @param string $name filter alias + * @param array $arguments filter arguments + * @param bool $check if true, check if already defined + */ + private function registerArguments(string $name, array $arguments, bool $check = true): void + { + if ($arguments !== []) { + if ($check && array_key_exists($name, $this->arguments)) { + throw new ConfigException( + '"' . $name . '" already has arguments: ' + . (($this->arguments[$name] === null) ? 'null' : implode(',', $this->arguments[$name])) + ); + } + + $this->arguments[$name] = $arguments; + } + + $classNames = (array) $this->config->aliases[$name]; + + foreach ($classNames as $className) { + $this->argumentsClass[$className] = $this->arguments[$name] ?? null; + } + } + /** * Maps filter aliases to the equivalent filter classes * @@ -514,8 +583,8 @@ protected function processAliasesToClass(string $position) } } - // when using enableFilter() we already write the class name in ->filtersClass as well as the - // alias in ->filters. This leads to duplicates when using route filters. + // when using enableFilter() we already write the class name in $filtersClass as well as the + // alias in $filters. This leads to duplicates when using route filters. // Since some filters like rate limiters rely on being executed once a request we filter em here. $this->filtersClass[$position] = array_values(array_unique($this->filtersClass[$position])); } diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index 09e65455882a..1f02cec983b0 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -560,6 +560,12 @@ protected function setCURLOptions(array $curlOptions = [], array $config = []) } } + // Proxy + if (isset($config['proxy'])) { + $curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true; + $curlOptions[CURLOPT_PROXY] = $config['proxy']; + } + // Debug if ($config['debug']) { $curlOptions[CURLOPT_VERBOSE] = 1; diff --git a/system/HTTP/DownloadResponse.php b/system/HTTP/DownloadResponse.php index 9753ef0239c0..c2fdcff0799f 100644 --- a/system/HTTP/DownloadResponse.php +++ b/system/HTTP/DownloadResponse.php @@ -257,6 +257,13 @@ public function setCache(array $options = []) */ public function send() { + // Turn off output buffering completely, even if php.ini output_buffering is not off + if (ENVIRONMENT !== 'testing') { + while (ob_get_level() > 0) { + ob_end_clean(); + } + } + $this->buildHeaders(); $this->sendHeaders(); $this->sendBody(); @@ -275,7 +282,10 @@ public function buildHeaders() $this->setContentTypeByMimeType(); } - $this->setHeader('Content-Disposition', $this->getContentDisposition()); + if (! $this->hasHeader('Content-Disposition')) { + $this->setHeader('Content-Disposition', $this->getContentDisposition()); + } + $this->setHeader('Expires-Disposition', '0'); $this->setHeader('Content-Transfer-Encoding', 'binary'); $this->setHeader('Content-Length', (string) $this->getContentLength()); @@ -331,4 +341,16 @@ private function sendBodyByBinary() return $this; } + + /** + * Sets the response header to display the file in the browser. + * + * @return DownloadResponse + */ + public function inline() + { + $this->setHeader('Content-Disposition', 'inline'); + + return $this; + } } diff --git a/system/HTTP/Exceptions/HTTPException.php b/system/HTTP/Exceptions/HTTPException.php index 966c3c7fd7e5..d22c48bcb9f3 100644 --- a/system/HTTP/Exceptions/HTTPException.php +++ b/system/HTTP/Exceptions/HTTPException.php @@ -77,9 +77,9 @@ public static function forInvalidNegotiationType(string $type) * * @return HTTPException */ - public static function forInvalidHTTPProtocol(string $protocols) + public static function forInvalidHTTPProtocol(string $invalidVersion) { - return new static(lang('HTTP.invalidHTTPProtocol', [$protocols])); + return new static(lang('HTTP.invalidHTTPProtocol', [$invalidVersion])); } /** diff --git a/system/HTTP/Exceptions/RedirectException.php b/system/HTTP/Exceptions/RedirectException.php new file mode 100644 index 000000000000..605f40eaba33 --- /dev/null +++ b/system/HTTP/Exceptions/RedirectException.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP\Exceptions; + +use CodeIgniter\Exceptions\HTTPExceptionInterface; +use CodeIgniter\HTTP\ResponsableInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Services; +use Exception; +use InvalidArgumentException; +use LogicException; +use Throwable; + +/** + * RedirectException + */ +class RedirectException extends Exception implements ResponsableInterface, HTTPExceptionInterface +{ + /** + * HTTP status code for redirects + * + * @var int + */ + protected $code = 302; + + protected ?ResponseInterface $response = null; + + /** + * @param ResponseInterface|string $message Response object or a string containing a relative URI. + * @param int $code HTTP status code to redirect if $message is a string. + */ + public function __construct($message = '', int $code = 0, ?Throwable $previous = null) + { + if (! is_string($message) && ! $message instanceof ResponseInterface) { + throw new InvalidArgumentException( + 'RedirectException::__construct() first argument must be a string or ResponseInterface', + 0, + $this + ); + } + + if ($message instanceof ResponseInterface) { + $this->response = $message; + $message = ''; + + if ($this->response->getHeaderLine('Location') === '' && $this->response->getHeaderLine('Refresh') === '') { + throw new LogicException( + 'The Response object passed to RedirectException does not contain a redirect address.' + ); + } + + if ($this->response->getStatusCode() < 301 || $this->response->getStatusCode() > 308) { + $this->response->setStatusCode($this->code); + } + } + + parent::__construct($message, $code, $previous); + } + + public function getResponse(): ResponseInterface + { + if (null === $this->response) { + $this->response = Services::response() + ->redirect(base_url($this->getMessage()), 'auto', $this->getCode()); + } + + Services::logger()->info( + 'REDIRECTED ROUTE at ' + . ($this->response->getHeaderLine('Location') ?: substr($this->response->getHeaderLine('Refresh'), 6)) + ); + + return $this->response; + } +} diff --git a/system/HTTP/Files/FileCollection.php b/system/HTTP/Files/FileCollection.php index 2284cb237aac..605d3a134aea 100644 --- a/system/HTTP/Files/FileCollection.php +++ b/system/HTTP/Files/FileCollection.php @@ -184,7 +184,8 @@ protected function createFileObject(array $array) $array['name'] ?? null, $array['type'] ?? null, $array['size'] ?? null, - $array['error'] ?? null + $array['error'] ?? null, + $array['full_path'] ?? null ); } diff --git a/system/HTTP/Files/UploadedFile.php b/system/HTTP/Files/UploadedFile.php index a9e81cd97ee1..70ece748d834 100644 --- a/system/HTTP/Files/UploadedFile.php +++ b/system/HTTP/Files/UploadedFile.php @@ -34,6 +34,13 @@ class UploadedFile extends File implements UploadedFileInterface */ protected $path; + /** + * The webkit relative path of the file. + * + * @var string + */ + protected $clientPath; + /** * The original filename as provided by the client. * @@ -78,8 +85,9 @@ class UploadedFile extends File implements UploadedFileInterface * @param string $mimeType The type of file as provided by PHP * @param int $size The size of the file, in bytes * @param int $error The error constant of the upload (one of PHP's UPLOADERRXXX constants) + * @param string $clientPath The webkit relative path of the uploaded file. */ - public function __construct(string $path, string $originalName, ?string $mimeType = null, ?int $size = null, ?int $error = null) + public function __construct(string $path, string $originalName, ?string $mimeType = null, ?int $size = null, ?int $error = null, ?string $clientPath = null) { $this->path = $path; $this->name = $originalName; @@ -87,6 +95,7 @@ public function __construct(string $path, string $originalName, ?string $mimeTyp $this->originalMimeType = $mimeType; $this->size = $size; $this->error = $error; + $this->clientPath = $clientPath; parent::__construct($path, false); } @@ -267,6 +276,15 @@ public function getClientName(): string return $this->originalName; } + /** + * (PHP 8.1+) + * Returns the webkit relative path of the uploaded file on directory uploads. + */ + public function getClientPath(): ?string + { + return $this->clientPath; + } + /** * Gets the temporary filename where the file was uploaded to. */ diff --git a/system/HTTP/Files/UploadedFileInterface.php b/system/HTTP/Files/UploadedFileInterface.php index 4d3835d301fc..170f81c2de9e 100644 --- a/system/HTTP/Files/UploadedFileInterface.php +++ b/system/HTTP/Files/UploadedFileInterface.php @@ -31,8 +31,9 @@ interface UploadedFileInterface * @param string $mimeType The type of file as provided by PHP * @param int $size The size of the file, in bytes * @param int $error The error constant of the upload (one of PHP's UPLOADERRXXX constants) + * @param string $clientPath The webkit relative path of the uploaded file. */ - public function __construct(string $path, string $originalName, ?string $mimeType = null, ?int $size = null, ?int $error = null); + public function __construct(string $path, string $originalName, ?string $mimeType = null, ?int $size = null, ?int $error = null, ?string $clientPath = null); /** * Move the uploaded file to a new location. @@ -111,6 +112,12 @@ public function getName(): string; */ public function getTempName(): string; + /** + * (PHP 8.1+) + * Returns the webkit relative path of the uploaded file on directory uploads. + */ + public function getClientPath(): ?string; + /** * Returns the original file extension, based on the file name that * was uploaded. This is NOT a trusted source. diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index 07c788528a13..947f5cc4c668 100755 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -175,7 +175,12 @@ public function __construct($config, ?URI $uri = null, $body = 'php://input', ?U parent::__construct($config); - $this->detectURI($config->uriProtocol, $config->baseURL); + if ($uri instanceof SiteURI) { + $this->setPath($uri->getRoutePath()); + } else { + $this->setPath($uri->getPath()); + } + $this->detectLocale($config); } @@ -227,9 +232,9 @@ public function detectLocale($config) * either provided by the user in the baseURL Config setting, or * determined from the environment as needed. * - * @deprecated $protocol and $baseURL are deprecated. No longer used. - * * @return void + * + * @deprecated 4.4.0 No longer used. */ protected function detectURI(string $protocol, string $baseURL) { @@ -239,6 +244,8 @@ protected function detectURI(string $protocol, string $baseURL) /** * Detects the relative path based on * the URIProtocol Config setting. + * + * @deprecated 4.4.0 Moved to SiteURIFactory. */ public function detectPath(string $protocol = ''): string { @@ -269,6 +276,8 @@ public function detectPath(string $protocol = ''): string * fixing the query string if necessary. * * @return string The URI it found. + * + * @deprecated 4.4.0 Moved to SiteURIFactory. */ protected function parseRequestURI(): string { @@ -327,6 +336,8 @@ protected function parseRequestURI(): string * Parse QUERY_STRING * * Will parse QUERY_STRING and automatically detect the URI from it. + * + * @deprecated 4.4.0 Moved to SiteURIFactory. */ protected function parseQueryString(): string { @@ -441,7 +452,7 @@ public function isSecure(): bool } /** - * Sets the relative path and updates the URI object. + * Sets the URI path relative to baseURL. * * Note: Since current_url() accesses the shared request * instance, this can be used to change the "current URL" @@ -451,85 +462,22 @@ public function isSecure(): bool * @param App|null $config Optional alternate config to use * * @return $this + * + * @deprecated 4.4.0 This method will be private. The parameter $config is deprecated. No longer used. */ public function setPath(string $path, ?App $config = null) { $this->path = $path; - // @TODO remove this. The path of the URI object should be a full URI path, - // not a URI path relative to baseURL. - $this->uri->setPath($path); - - $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 and allowedHostnames provided by the developer - // and HTTP_HOST, set our current domain name, scheme. - if ($baseURL !== '') { - $host = $this->determineHost($config, $baseURL); - - // Set URI::$baseURL - $uri = new URI($baseURL); - $currentBaseURL = (string) $uri->setHost($host); - $this->uri->setBaseURL($currentBaseURL); - - $this->uri->setScheme(parse_url($baseURL, PHP_URL_SCHEME)); - $this->uri->setHost($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 scheme needs to be coerced into its secure version - if ($config->forceGlobalSecureRequests && $this->uri->getScheme() === 'http') { - $this->uri->setScheme('https'); - } - } elseif (! is_cli()) { - // Do not change exit() to exception; Request is initialized before - // setting the exception handler, so if an exception is raised, an - // error will be displayed even if in the production environment. - // @codeCoverageIgnoreStart - exit('You have an empty or invalid baseURL. The baseURL value must be set in app/Config/App.php, or through the .env file.'); - // @codeCoverageIgnoreEnd - } - return $this; } - private function determineHost(App $config, string $baseURL): string - { - $host = parse_url($baseURL, PHP_URL_HOST); - - if (empty($config->allowedHostnames)) { - return $host; - } - - // Update host if it is valid. - $httpHostPort = $this->getServer('HTTP_HOST'); - if ($httpHostPort !== null) { - [$httpHost] = explode(':', $httpHostPort, 2); - - if (in_array($httpHost, $config->allowedHostnames, true)) { - $host = $httpHost; - } - } - - return $host; - } - /** * Returns the URI path relative to baseURL, * running detection as necessary. */ public function getPath(): string { - if ($this->path === null) { - $this->detectPath($this->config->uriProtocol); - } - return $this->path; } @@ -552,6 +500,18 @@ public function setLocale(string $locale) return $this; } + /** + * Set the valid locales. + * + * @return $this + */ + public function setValidLocales(array $locales) + { + $this->validLocales = $locales; + + return $this; + } + /** * Gets the current locale, with a fallback to the default * locale if none is set. @@ -951,7 +911,7 @@ public function getFile(string $fileID) * * Do some final cleaning of the URI and return it, currently only used in static::_parse_request_uri() * - * @deprecated Use URI::removeDotSegments() directly + * @deprecated 4.1.2 Use URI::removeDotSegments() directly */ protected function removeRelativeDirectory(string $uri): string { diff --git a/system/HTTP/MessageTrait.php b/system/HTTP/MessageTrait.php index e565c8d9827f..ac3e5b18938c 100644 --- a/system/HTTP/MessageTrait.php +++ b/system/HTTP/MessageTrait.php @@ -229,7 +229,7 @@ public function setProtocolVersion(string $version): self $version = number_format((float) $version, 1); if (! in_array($version, $this->validProtocolVersions, true)) { - throw HTTPException::forInvalidHTTPProtocol(implode(', ', $this->validProtocolVersions)); + throw HTTPException::forInvalidHTTPProtocol($version); } $this->protocolVersion = $version; diff --git a/system/HTTP/Request.php b/system/HTTP/Request.php index ebe602b18e38..26b7b8f460c8 100644 --- a/system/HTTP/Request.php +++ b/system/HTTP/Request.php @@ -26,7 +26,7 @@ class Request extends OutgoingRequest implements RequestInterface * * @var array * - * @deprecated Check the App config directly + * @deprecated 4.0.5 No longer used. Check the App config directly */ protected $proxyIPs; @@ -35,15 +35,10 @@ class Request extends OutgoingRequest implements RequestInterface * * @param App $config * - * @deprecated The $config is no longer needed and will be removed in a future version + * @deprecated 4.0.5 The $config is no longer needed and will be removed in a future version */ - public function __construct($config = null) + public function __construct($config = null) // @phpstan-ignore-line { - /** - * @deprecated $this->proxyIps property will be removed in the future - */ - $this->proxyIPs = $config->proxyIPs; - if (empty($this->method)) { $this->method = $this->getServer('REQUEST_METHOD') ?? 'GET'; } @@ -59,7 +54,7 @@ public function __construct($config = null) * @param string $ip IP Address * @param string $which IP protocol: 'ipv4' or 'ipv6' * - * @deprecated Use Validation instead + * @deprecated 4.0.5 Use Validation instead * * @codeCoverageIgnore */ @@ -73,7 +68,7 @@ public function isValidIP(?string $ip = null, ?string $which = null): bool * * @param bool $upper Whether to return in upper or lower case. * - * @deprecated The $upper functionality will be removed and this will revert to its PSR-7 equivalent + * @deprecated 4.0.5 The $upper functionality will be removed and this will revert to its PSR-7 equivalent * * @codeCoverageIgnore */ @@ -87,7 +82,7 @@ public function getMethod(bool $upper = false): string * * @return $this * - * @deprecated Use withMethod() instead for immutability + * @deprecated 4.0.5 Use withMethod() instead for immutability * * @codeCoverageIgnore */ diff --git a/system/HTTP/RequestTrait.php b/system/HTTP/RequestTrait.php index 427f7fe354dc..52ffa812a315 100644 --- a/system/HTTP/RequestTrait.php +++ b/system/HTTP/RequestTrait.php @@ -60,17 +60,8 @@ public function getIPAddress(): string 'valid_ip', ]; - /** - * @deprecated $this->proxyIPs property will be removed in the future - */ - // @phpstan-ignore-next-line - $proxyIPs = $this->proxyIPs ?? config(App::class)->proxyIPs; - // @phpstan-ignore-next-line - - // Workaround for old Config\App file. App::$proxyIPs may be empty string. - if ($proxyIPs === '') { - $proxyIPs = []; - } + $proxyIPs = config(App::class)->proxyIPs; + if (! empty($proxyIPs) && (! is_array($proxyIPs) || is_int(array_key_first($proxyIPs)))) { throw new ConfigException( 'You must set an array with Proxy IP address key and HTTP header name value in Config\App::$proxyIPs.' diff --git a/system/HTTP/ResponsableInterface.php b/system/HTTP/ResponsableInterface.php new file mode 100644 index 000000000000..0cca5356f1f7 --- /dev/null +++ b/system/HTTP/ResponsableInterface.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +interface ResponsableInterface +{ + public function getResponse(): ResponseInterface; +} diff --git a/system/HTTP/Response.php b/system/HTTP/Response.php index f573905d1edd..a0d5658fd8a9 100644 --- a/system/HTTP/Response.php +++ b/system/HTTP/Response.php @@ -13,7 +13,6 @@ use CodeIgniter\Cookie\Cookie; use CodeIgniter\Cookie\CookieStore; -use CodeIgniter\Cookie\Exceptions\CookieException; use CodeIgniter\HTTP\Exceptions\HTTPException; use Config\App; use Config\Cookie as CookieConfig; @@ -157,31 +156,11 @@ public function __construct($config) $this->CSPEnabled = $config->CSPEnabled; - // DEPRECATED COOKIE MANAGEMENT - - $this->cookiePrefix = $config->cookiePrefix; - $this->cookieDomain = $config->cookieDomain; - $this->cookiePath = $config->cookiePath; - $this->cookieSecure = $config->cookieSecure; - $this->cookieHTTPOnly = $config->cookieHTTPOnly; - $this->cookieSameSite = $config->cookieSameSite ?? Cookie::SAMESITE_LAX; + $this->cookieStore = new CookieStore([]); - $config->cookieSameSite ??= Cookie::SAMESITE_LAX; + $cookie = config(CookieConfig::class); - if (! in_array(strtolower($config->cookieSameSite ?: Cookie::SAMESITE_LAX), Cookie::ALLOWED_SAMESITE_VALUES, true)) { - throw CookieException::forInvalidSameSite($config->cookieSameSite); - } - - $this->cookieStore = new CookieStore([]); - Cookie::setDefaults(config(CookieConfig::class) ?? [ - // @todo Remove this fallback when deprecated `App` members are removed - 'prefix' => $config->cookiePrefix, - 'path' => $config->cookiePath, - 'domain' => $config->cookieDomain, - 'secure' => $config->cookieSecure, - 'httponly' => $config->cookieHTTPOnly, - 'samesite' => $config->cookieSameSite ?? Cookie::SAMESITE_LAX, - ]); + Cookie::setDefaults($cookie); // Default to an HTML Content-Type. Devs can override if needed. $this->setContentType('text/html'); diff --git a/system/HTTP/SiteURI.php b/system/HTTP/SiteURI.php new file mode 100644 index 000000000000..362c8ee32362 --- /dev/null +++ b/system/HTTP/SiteURI.php @@ -0,0 +1,429 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use BadMethodCallException; +use CodeIgniter\Exceptions\ConfigException; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use Config\App; + +/** + * URI for the application site + */ +class SiteURI extends URI +{ + /** + * The current baseURL. + */ + private URI $baseURL; + + /** + * The path part of baseURL. + * + * The baseURL "http://example.com/" → '/' + * The baseURL "http://localhost:8888/ci431/public/" → '/ci431/public/' + */ + private string $basePathWithoutIndexPage; + + /** + * The Index File. + */ + private string $indexPage; + + /** + * List of URI segments in baseURL and indexPage. + * + * If the URI is "http://localhost:8888/ci431/public/index.php/test?a=b", + * and the baseURL is "http://localhost:8888/ci431/public/", then: + * $baseSegments = [ + * 0 => 'ci431', + * 1 => 'public', + * 2 => 'index.php', + * ]; + */ + private array $baseSegments; + + /** + * List of URI segments after indexPage. + * + * The word "URI Segments" originally means only the URI path part relative + * to the baseURL. + * + * If the URI is "http://localhost:8888/ci431/public/index.php/test?a=b", + * and the baseURL is "http://localhost:8888/ci431/public/", then: + * $segments = [ + * 0 => 'test', + * ]; + * + * @var array + * + * @deprecated This property will be private. + */ + protected $segments; + + /** + * URI path relative to baseURL. + * + * If the baseURL contains sub folders, this value will be different from + * the current URI path. + * + * This value never starts with '/'. + */ + private string $routePath; + + /** + * @param string $relativePath URI path relative to baseURL. May include + * queries or fragments. + * @param string|null $host Optional current hostname. + * @param string|null $scheme Optional scheme. 'http' or 'https'. + * @phpstan-param 'http'|'https'|null $scheme + */ + public function __construct( + App $configApp, + string $relativePath = '', + ?string $host = null, + ?string $scheme = null + ) { + $this->indexPage = $configApp->indexPage; + + $this->baseURL = $this->determineBaseURL($configApp, $host, $scheme); + + $this->setBasePath(); + + // Fix routePath, query, fragment + [$routePath, $query, $fragment] = $this->parseRelativePath($relativePath); + + // Fix indexPage and routePath + $indexPageRoutePath = $this->getIndexPageRoutePath($routePath); + + // Fix the current URI + $uri = $this->baseURL . $indexPageRoutePath; + + // applyParts + $parts = parse_url($uri); + if ($parts === false) { + throw HTTPException::forUnableToParseURI($uri); + } + $parts['query'] = $query; + $parts['fragment'] = $fragment; + $this->applyParts($parts); + + $this->setRoutePath($routePath); + } + + private function parseRelativePath(string $relativePath): array + { + $parts = parse_url('http://dummy/' . $relativePath); + if ($parts === false) { + throw HTTPException::forUnableToParseURI($relativePath); + } + + $routePath = $relativePath === '/' ? '/' : ltrim($parts['path'], '/'); + + $query = $parts['query'] ?? ''; + $fragment = $parts['fragment'] ?? ''; + + return [$routePath, $query, $fragment]; + } + + private function determineBaseURL( + App $configApp, + ?string $host, + ?string $scheme + ): URI { + $baseURL = $this->normalizeBaseURL($configApp); + + $uri = new URI($baseURL); + + // Update scheme + if ($scheme !== null && $scheme !== '') { + $uri->setScheme($scheme); + } elseif ($configApp->forceGlobalSecureRequests) { + $uri->setScheme('https'); + } + + // Update host + if ($host !== null) { + $uri->setHost($host); + } + + return $uri; + } + + private function getIndexPageRoutePath(string $routePath): string + { + // Remove starting slash unless it is `/`. + if ($routePath !== '' && $routePath[0] === '/' && $routePath !== '/') { + $routePath = ltrim($routePath, '/'); + } + + // Check for an index page + $indexPage = ''; + if ($this->indexPage !== '') { + $indexPage = $this->indexPage; + + // Check if we need a separator + if ($routePath !== '' && $routePath[0] !== '/' && $routePath[0] !== '?') { + $indexPage .= '/'; + } + } + + $indexPageRoutePath = $indexPage . $routePath; + + if ($indexPageRoutePath === '/') { + $indexPageRoutePath = ''; + } + + return $indexPageRoutePath; + } + + private function normalizeBaseURL(App $configApp): string + { + // It's possible the user forgot a trailing slash on their + // baseURL, so let's help them out. + $baseURL = rtrim($configApp->baseURL, '/ ') . '/'; + + // Validate baseURL + if (filter_var($baseURL, FILTER_VALIDATE_URL) === false) { + throw new ConfigException( + 'Config\App::$baseURL is invalid.' + ); + } + + return $baseURL; + } + + /** + * Sets basePathWithoutIndexPage and baseSegments. + */ + private function setBasePath(): void + { + $this->basePathWithoutIndexPage = $this->baseURL->getPath(); + + $this->baseSegments = $this->convertToSegments($this->basePathWithoutIndexPage); + + if ($this->indexPage) { + $this->baseSegments[] = $this->indexPage; + } + } + + /** + * @deprecated + */ + public function setBaseURL(string $baseURL): void + { + throw new BadMethodCallException('Cannot use this method.'); + } + + /** + * @deprecated + */ + public function setURI(?string $uri = null) + { + throw new BadMethodCallException('Cannot use this method.'); + } + + /** + * Returns the baseURL. + * + * @interal + */ + public function getBaseURL(): string + { + return (string) $this->baseURL; + } + + /** + * Returns the URI path relative to baseURL. + * + * @return string The Route path. + */ + public function getRoutePath(): string + { + return $this->routePath; + } + + /** + * Formats the URI as a string. + */ + public function __toString(): string + { + return static::createURIString( + $this->getScheme(), + $this->getAuthority(), + $this->getPath(), + $this->getQuery(), + $this->getFragment() + ); + } + + /** + * Sets the route path (and segments). + * + * @return $this + */ + public function setPath(string $path) + { + $this->setRoutePath($path); + + return $this; + } + + /** + * Sets the route path (and segments). + */ + private function setRoutePath(string $routePath): void + { + $routePath = $this->filterPath($routePath); + + $indexPageRoutePath = $this->getIndexPageRoutePath($routePath); + + $this->path = $this->basePathWithoutIndexPage . $indexPageRoutePath; + + $this->routePath = ltrim($routePath, '/'); + + $this->segments = $this->convertToSegments($this->routePath); + } + + /** + * Converts path to segments + */ + private function convertToSegments(string $path): array + { + $tempPath = trim($path, '/'); + + return ($tempPath === '') ? [] : explode('/', $tempPath); + } + + /** + * Sets the path portion of the URI based on segments. + * + * @return $this + * + * @deprecated This method will be private. + */ + public function refreshPath() + { + $allSegments = array_merge($this->baseSegments, $this->segments); + $this->path = '/' . $this->filterPath(implode('/', $allSegments)); + + if ($this->routePath === '/' && $this->path !== '/') { + $this->path .= '/'; + } + + $this->routePath = $this->filterPath(implode('/', $this->segments)); + + return $this; + } + + /** + * Saves our parts from a parse_url() call. + */ + protected function applyParts(array $parts): void + { + if (! empty($parts['host'])) { + $this->host = $parts['host']; + } + if (! empty($parts['user'])) { + $this->user = $parts['user']; + } + if (isset($parts['path']) && $parts['path'] !== '') { + $this->path = $this->filterPath($parts['path']); + } + if (! empty($parts['query'])) { + $this->setQuery($parts['query']); + } + if (! empty($parts['fragment'])) { + $this->fragment = $parts['fragment']; + } + + // Scheme + if (isset($parts['scheme'])) { + $this->setScheme(rtrim($parts['scheme'], ':/')); + } else { + $this->setScheme('http'); + } + + // Port + if (isset($parts['port']) && $parts['port'] !== null) { + // Valid port numbers are enforced by earlier parse_url() or setPort() + $this->port = $parts['port']; + } + + if (isset($parts['pass'])) { + $this->password = $parts['pass']; + } + } + + /** + * For base_url() helper. + * + * @param array|string $relativePath URI string or array of URI segments + * @param string|null $scheme URI scheme. E.g., http, ftp + */ + public function baseUrl($relativePath = '', ?string $scheme = null): string + { + $relativePath = $this->stringifyRelativePath($relativePath); + + $config = clone config(App::class); + $config->indexPage = ''; + + $host = $this->getHost(); + + $uri = new self($config, $relativePath, $host, $scheme); + + // Support protocol-relative links + if ($scheme === '') { + return substr((string) $uri, strlen($uri->getScheme()) + 1); + } + + return (string) $uri; + } + + /** + * @param array|string $relativePath URI string or array of URI segments + */ + private function stringifyRelativePath($relativePath): string + { + if (is_array($relativePath)) { + $relativePath = implode('/', $relativePath); + } + + return $relativePath; + } + + /** + * For site_url() helper. + * + * @param array|string $relativePath URI string or array of URI segments + * @param string|null $scheme URI scheme. E.g., http, ftp + * @param App|null $config Alternate configuration to use + */ + public function siteUrl($relativePath = '', ?string $scheme = null, ?App $config = null): string + { + $relativePath = $this->stringifyRelativePath($relativePath); + + // Check current host. + $host = $config === null ? $this->getHost() : null; + + $config ??= config(App::class); + + $uri = new self($config, $relativePath, $host, $scheme); + + // Support protocol-relative links + if ($scheme === '') { + return substr((string) $uri, strlen($uri->getScheme()) + 1); + } + + return (string) $uri; + } +} diff --git a/system/HTTP/SiteURIFactory.php b/system/HTTP/SiteURIFactory.php new file mode 100644 index 000000000000..e250c559058c --- /dev/null +++ b/system/HTTP/SiteURIFactory.php @@ -0,0 +1,252 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\Superglobals; +use Config\App; + +/** + * Creates SiteURI using superglobals. + * + * This class also updates superglobal $_SERVER and $_GET. + */ +final class SiteURIFactory +{ + private App $appConfig; + private Superglobals $superglobals; + + public function __construct(App $appConfig, Superglobals $superglobals) + { + $this->appConfig = $appConfig; + $this->superglobals = $superglobals; + } + + /** + * Create the current URI object from superglobals. + * + * This method updates superglobal $_SERVER and $_GET. + */ + public function createFromGlobals(): SiteURI + { + $routePath = $this->detectRoutePath(); + + return $this->createURIFromRoutePath($routePath); + } + + /** + * Create the SiteURI object from URI string. + * + * @internal Used for testing purposes only. + * @testTag + */ + public function createFromString(string $uri): SiteURI + { + // Validate URI + if (filter_var($uri, FILTER_VALIDATE_URL) === false) { + throw HTTPException::forUnableToParseURI($uri); + } + + $parts = parse_url($uri); + + if ($parts === false) { + throw HTTPException::forUnableToParseURI($uri); + } + + $query = $fragment = ''; + if (isset($parts['query'])) { + $query = '?' . $parts['query']; + } + if (isset($parts['fragment'])) { + $fragment = '#' . $parts['fragment']; + } + + $relativePath = ($parts['path'] ?? '') . $query . $fragment; + $host = $this->getValidHost($parts['host']); + + return new SiteURI($this->appConfig, $relativePath, $host, $parts['scheme']); + } + + /** + * Detects the current URI path relative to baseURL based on the URIProtocol + * Config setting. + * + * @param string $protocol URIProtocol + * + * @return string The route path + * + * @internal Used for testing purposes only. + * @testTag + */ + public function detectRoutePath(string $protocol = ''): string + { + if ($protocol === '') { + $protocol = $this->appConfig->uriProtocol; + } + + switch ($protocol) { + case 'REQUEST_URI': + $routePath = $this->parseRequestURI(); + break; + + case 'QUERY_STRING': + $routePath = $this->parseQueryString(); + break; + + case 'PATH_INFO': + default: + $routePath = $this->superglobals->server($protocol) ?? $this->parseRequestURI(); + break; + } + + return ($routePath === '/' || $routePath === '') ? '/' : ltrim($routePath, '/'); + } + + /** + * Will parse the REQUEST_URI and automatically detect the URI from it, + * fixing the query string if necessary. + * + * This method updates superglobal $_SERVER and $_GET. + * + * @return string The route path (before normalization). + */ + private function parseRequestURI(): string + { + if ( + $this->superglobals->server('REQUEST_URI') === null + || $this->superglobals->server('SCRIPT_NAME') === null + ) { + return ''; + } + + // parse_url() returns false if no host is present, but the path or query + // string contains a colon followed by a number. So we attach a dummy + // host since REQUEST_URI does not include the host. This allows us to + // parse out the query string and path. + $parts = parse_url('http://dummy' . $this->superglobals->server('REQUEST_URI')); + $query = $parts['query'] ?? ''; + $path = $parts['path'] ?? ''; + + // Strip the SCRIPT_NAME path from the URI + if ( + $path !== '' && $this->superglobals->server('SCRIPT_NAME') !== '' + && pathinfo($this->superglobals->server('SCRIPT_NAME'), PATHINFO_EXTENSION) === 'php' + ) { + // Compare each segment, dropping them until there is no match + $segments = $keep = explode('/', $path); + + foreach (explode('/', $this->superglobals->server('SCRIPT_NAME')) as $i => $segment) { + // If these segments are not the same then we're done + if (! isset($segments[$i]) || $segment !== $segments[$i]) { + break; + } + + array_shift($keep); + } + + $path = implode('/', $keep); + } + + // This section ensures that even on servers that require the URI to + // contain the query string (Nginx) a correct URI is found, and also + // fixes the QUERY_STRING Server var and $_GET array. + if (trim($path, '/') === '' && strncmp($query, '/', 1) === 0) { + $parts = explode('?', $query, 2); + $path = $parts[0]; + $newQuery = $query[1] ?? ''; + + $this->superglobals->setServer('QUERY_STRING', $newQuery); + } else { + $this->superglobals->setServer('QUERY_STRING', $query); + } + + // Update our global GET for values likely to have been changed + parse_str($this->superglobals->server('QUERY_STRING'), $get); + $this->superglobals->setGetArray($get); + + return URI::removeDotSegments($path); + } + + /** + * Will parse QUERY_STRING and automatically detect the URI from it. + * + * This method updates superglobal $_SERVER and $_GET. + * + * @return string The route path (before normalization). + */ + private function parseQueryString(): string + { + $query = $this->superglobals->server('QUERY_STRING') ?? (string) getenv('QUERY_STRING'); + + if (trim($query, '/') === '') { + return '/'; + } + + if (strncmp($query, '/', 1) === 0) { + $parts = explode('?', $query, 2); + $path = $parts[0]; + $newQuery = $parts[1] ?? ''; + + $this->superglobals->setServer('QUERY_STRING', $newQuery); + } else { + $path = $query; + } + + // Update our global GET for values likely to have been changed + parse_str($this->superglobals->server('QUERY_STRING'), $get); + $this->superglobals->setGetArray($get); + + return URI::removeDotSegments($path); + } + + /** + * Create current URI object. + * + * @param string $routePath URI path relative to baseURL + */ + private function createURIFromRoutePath(string $routePath): SiteURI + { + $query = $this->superglobals->server('QUERY_STRING') ?? ''; + + $relativePath = $query !== '' ? $routePath . '?' . $query : $routePath; + + return new SiteURI($this->appConfig, $relativePath, $this->getHost()); + } + + /** + * @return string|null The current hostname. Returns null if no valid host. + */ + private function getHost(): ?string + { + $httpHostPort = $this->superglobals->server('HTTP_HOST') ?? null; + + if ($httpHostPort !== null) { + [$httpHost] = explode(':', $httpHostPort, 2); + + return $this->getValidHost($httpHost); + } + + return null; + } + + /** + * @return string|null The valid hostname. Returns null if not valid. + */ + private function getValidHost(string $host): ?string + { + if (in_array($host, $this->appConfig->allowedHostnames, true)) { + return $host; + } + + return null; + } +} diff --git a/system/HTTP/URI.php b/system/HTTP/URI.php index ab52fa46f5c9..fb9d780c1485 100644 --- a/system/HTTP/URI.php +++ b/system/HTTP/URI.php @@ -14,6 +14,7 @@ use BadMethodCallException; use CodeIgniter\HTTP\Exceptions\HTTPException; use Config\App; +use InvalidArgumentException; /** * Abstraction for a uniform resource identifier (URI). @@ -34,11 +35,15 @@ class URI * Current URI string * * @var string + * + * @deprecated 4.4.0 Not used. */ protected $uriString; /** * The Current baseURL. + * + * @deprecated 4.4.0 Use SiteURI instead. */ private ?string $baseURL = null; @@ -89,11 +94,6 @@ class URI /** * URI path. * - * Note: The constructor of the IncomingRequest class changes the path of - * the URI object held by the IncomingRequest class to a path relative - * to the baseURL. If the baseURL contains subfolders, this value - * will be different from the current URI path. - * * @var string */ protected $path; @@ -259,6 +259,8 @@ public function __construct(?string $uri = null) * If $silent == true, then will not throw exceptions and will * attempt to continue gracefully. * + * @deprecated 4.4.0 Method not in PSR-7 + * * @return URI */ public function setSilent(bool $silent = true) @@ -272,6 +274,8 @@ public function setSilent(bool $silent = true) * If $raw == true, then will use parseStr() method * instead of native parse_str() function. * + * Note: Method not in PSR-7 + * * @return URI */ public function useRawQueryString(bool $raw = true) @@ -287,6 +291,8 @@ public function useRawQueryString(bool $raw = true) * @return URI * * @throws HTTPException + * + * @deprecated 4.4.0 This method will be private. */ public function setURI(?string $uri = null) { @@ -404,6 +410,8 @@ public function getUserInfo() * Temporarily sets the URI to show a password in userInfo. Will * reset itself after the first call to authority(). * + * Note: Method not in PSR-7 + * * @return URI */ public function showPassword(bool $val = true) @@ -533,23 +541,29 @@ public function getSegments(): array /** * Returns the value of a specific segment of the URI path. + * Allows to get only existing segments or the next one. * - * @param int $number Segment number + * @param int $number Segment number starting at 1 * @param string $default Default value * - * @return string The value of the segment. If no segment is found, - * throws InvalidArgumentError + * @return string The value of the segment. If you specify the last +1 + * segment, the $default value. If you specify the last +2 + * or more throws HTTPException. */ public function getSegment(int $number, string $default = ''): string { - // The segment should treat the array as 1-based for the user - // but we still have to deal with a zero-based array. - $number--; + if ($number < 1) { + throw HTTPException::forURISegmentOutOfRange($number); + } - if ($number > count($this->segments) && ! $this->silent) { + if ($number > count($this->segments) + 1 && ! $this->silent) { throw HTTPException::forURISegmentOutOfRange($number); } + // The segment should treat the array as 1-based for the user + // but we still have to deal with a zero-based array. + $number--; + return $this->segments[$number] ?? $default; } @@ -557,15 +571,18 @@ public function getSegment(int $number, string $default = ''): string * Set the value of a specific segment of the URI path. * Allows to set only existing segments or add new one. * - * @param int|string $value (string or int) + * Note: Method not in PSR-7 + * + * @param int $number Segment number starting at 1 + * @param int|string $value * * @return $this */ public function setSegment(int $number, $value) { - // The segment should treat the array as 1-based for the user - // but we still have to deal with a zero-based array. - $number--; + if ($number < 1) { + throw HTTPException::forURISegmentOutOfRange($number); + } if ($number > count($this->segments) + 1) { if ($this->silent) { @@ -575,6 +592,10 @@ public function setSegment(int $number, $value) throw HTTPException::forURISegmentOutOfRange($number); } + // The segment should treat the array as 1-based for the user + // but we still have to deal with a zero-based array. + $number--; + $this->segments[$number] = $value; $this->refreshPath(); @@ -583,6 +604,8 @@ public function setSegment(int $number, $value) /** * Returns the total number of segments. + * + * Note: Method not in PSR-7 */ public function getTotalSegments(): int { @@ -650,6 +673,8 @@ private function changeSchemeAndPath(string $scheme, string $path): array /** * Parses the given string and saves the appropriate authority pieces. * + * Note: Method not in PSR-7 + * * @return $this */ public function setAuthority(string $str) @@ -679,6 +704,8 @@ public function setAuthority(string $str) * @see https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml * * @return $this + * + * @deprecated 4.4.0 Use `withScheme()` instead. */ public function setScheme(string $str) { @@ -688,6 +715,34 @@ public function setScheme(string $str) return $this; } + /** + * Return an instance with the specified scheme. + * + * This method MUST retain the state of the current instance, and return + * an instance that contains the specified scheme. + * + * Implementations MUST support the schemes "http" and "https" case + * insensitively, and MAY accommodate other schemes if required. + * + * An empty scheme is equivalent to removing the scheme. + * + * @param string $scheme The scheme to use with the new instance. + * + * @return static A new instance with the specified scheme. + * + * @throws InvalidArgumentException for invalid or unsupported schemes. + */ + public function withScheme(string $scheme) + { + $uri = clone $this; + + $scheme = strtolower($scheme); + + $uri->scheme = preg_replace('#:(//)?$#', '', $scheme); + + return $uri; + } + /** * Sets the userInfo/Authority portion of the URI. * @@ -695,6 +750,8 @@ public function setScheme(string $str) * @param string $pass The user's password * * @return $this + * + * @TODO PSR-7: Should be `withUserInfo($user, $password = null)`. */ public function setUserInfo(string $user, string $pass) { @@ -708,6 +765,8 @@ public function setUserInfo(string $user, string $pass) * Sets the host name to use. * * @return $this + * + * @TODO PSR-7: Should be `withHost($host)`. */ public function setHost(string $str) { @@ -722,6 +781,8 @@ public function setHost(string $str) * @param int $port * * @return $this + * + * @TODO PSR-7: Should be `withPort($port)`. */ public function setPort(?int $port = null) { @@ -746,6 +807,8 @@ public function setPort(?int $port = null) * Sets the path portion of the URI. * * @return $this + * + * @TODO PSR-7: Should be `withPath($port)`. */ public function setPath(string $path) { @@ -762,6 +825,8 @@ public function setPath(string $path) * Sets the current baseURL. * * @interal + * + * @deprecated Use SiteURI instead. */ public function setBaseURL(string $baseURL): void { @@ -772,6 +837,8 @@ public function setBaseURL(string $baseURL): void * Returns the current baseURL. * * @interal + * + * @deprecated Use SiteURI instead. */ public function getBaseURL(): string { @@ -786,6 +853,8 @@ public function getBaseURL(): string * Sets the path portion of the URI based on segments. * * @return $this + * + * @deprecated This method will be private. */ public function refreshPath() { @@ -803,6 +872,8 @@ public function refreshPath() * to clean the various parts of the query keys and values. * * @return $this + * + * @TODO PSR-7: Should be `withQuery($query)`. */ public function setQuery(string $query) { @@ -833,6 +904,8 @@ public function setQuery(string $query) * portion of the URI. * * @return URI + * + * @TODO: PSR-7: Should be `withQueryParams(array $query)` */ public function setQueryArray(array $query) { @@ -844,7 +917,9 @@ public function setQueryArray(array $query) /** * Adds a single new element to the query vars. * - * @param int|string $value + * Note: Method not in PSR-7 + * + * @param int|string|null $value * * @return $this */ @@ -858,6 +933,8 @@ public function addQuery(string $key, $value = null) /** * Removes one or more query vars from the URI. * + * Note: Method not in PSR-7 + * * @param string ...$params * * @return $this @@ -875,6 +952,8 @@ public function stripQuery(...$params) * Filters the query variables so that only the keys passed in * are kept. The rest are removed from the object. * + * Note: Method not in PSR-7 + * * @param string ...$params * * @return $this @@ -902,6 +981,8 @@ public function keepQuery(...$params) * @see https://tools.ietf.org/html/rfc3986#section-3.5 * * @return $this + * + * @TODO PSR-7: Should be `withFragment($fragment)`. */ public function setFragment(string $string) { diff --git a/system/HTTP/UserAgent.php b/system/HTTP/UserAgent.php index 4b579229d88f..1ccc2109c119 100644 --- a/system/HTTP/UserAgent.php +++ b/system/HTTP/UserAgent.php @@ -102,7 +102,7 @@ class UserAgent */ public function __construct(?UserAgents $config = null) { - $this->config = $config ?? new UserAgents(); + $this->config = $config ?? config(UserAgents::class); if (isset($_SERVER['HTTP_USER_AGENT'])) { $this->agent = trim($_SERVER['HTTP_USER_AGENT']); diff --git a/system/Helpers/array_helper.php b/system/Helpers/array_helper.php index c5c9d956a411..65645c3cc316 100644 --- a/system/Helpers/array_helper.php +++ b/system/Helpers/array_helper.php @@ -218,3 +218,68 @@ function array_flatten_with_dots(iterable $array, string $id = ''): array return $flattened; } } + +if (! function_exists('array_group_by')) { + /** + * Groups all rows by their index values. Result's depth equals number of indexes + * + * @param array $array Data array (i.e. from query result) + * @param array $indexes Indexes to group by. Dot syntax used. Returns $array if empty + * @param bool $includeEmpty If true, null and '' are also added as valid keys to group + * + * @return array Result array where rows are grouped together by indexes values. + */ + function array_group_by(array $array, array $indexes, bool $includeEmpty = false): array + { + if ($indexes === []) { + return $array; + } + + $result = []; + + foreach ($array as $row) { + $result = _array_attach_indexed_value($result, $row, $indexes, $includeEmpty); + } + + return $result; + } +} + +if (! function_exists('_array_attach_indexed_value')) { + /** + * Used by `array_group_by` to recursively attach $row to the $indexes path of values found by + * `dot_array_search` + * + * @internal This should not be used on its own + */ + function _array_attach_indexed_value(array $result, array $row, array $indexes, bool $includeEmpty): array + { + if (($index = array_shift($indexes)) === null) { + $result[] = $row; + + return $result; + } + + $value = dot_array_search($index, $row); + + if (! is_scalar($value)) { + $value = ''; + } + + if (is_bool($value)) { + $value = (int) $value; + } + + if (! $includeEmpty && $value === '') { + return $result; + } + + if (! array_key_exists($value, $result)) { + $result[$value] = []; + } + + $result[$value] = _array_attach_indexed_value($result[$value], $row, $indexes, $includeEmpty); + + return $result; + } +} diff --git a/system/Helpers/cookie_helper.php b/system/Helpers/cookie_helper.php index 222671694d2b..de8fb6146a63 100755 --- a/system/Helpers/cookie_helper.php +++ b/system/Helpers/cookie_helper.php @@ -10,7 +10,6 @@ */ use CodeIgniter\Cookie\Cookie; -use Config\App; use Config\Cookie as CookieConfig; use Config\Services; @@ -71,11 +70,9 @@ function set_cookie( function get_cookie($index, bool $xssClean = false, ?string $prefix = '') { if ($prefix === '') { - /** @var CookieConfig|null $cookie */ $cookie = config(CookieConfig::class); - // @TODO Remove Config\App fallback when deprecated `App` members are removed. - $prefix = $cookie instanceof CookieConfig ? $cookie->prefix : config(App::class)->cookiePrefix; + $prefix = $cookie->prefix; } $request = Services::request(); diff --git a/system/Helpers/url_helper.php b/system/Helpers/url_helper.php index c6c810b66da6..a74fe944f148 100644 --- a/system/Helpers/url_helper.php +++ b/system/Helpers/url_helper.php @@ -10,8 +10,8 @@ */ use CodeIgniter\HTTP\CLIRequest; -use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\URI; use CodeIgniter\Router\Exceptions\RouterException; use Config\App; @@ -19,92 +19,6 @@ // CodeIgniter URL Helpers -if (! function_exists('_get_uri')) { - /** - * Used by the other URL functions to build a framework-specific URI - * based on $request->getUri()->getBaseURL() and the App config. - * - * @internal Outside the framework this should not be used directly. - * - * @param array|string $relativePath URI string or array of URI segments. - * May include queries or fragments. - * @param App|null $config Alternative Config to use - * - * @throws HTTPException For invalid paths. - * @throws InvalidArgumentException For invalid config. - */ - function _get_uri($relativePath = '', ?App $config = null): URI - { - $appConfig = null; - if ($config === null) { - /** @var App $appConfig */ - $appConfig = config(App::class); - - if ($appConfig->baseURL === '') { - throw new InvalidArgumentException( - '_get_uri() requires a valid baseURL.' - ); - } - } elseif ($config->baseURL === '') { - throw new InvalidArgumentException( - '_get_uri() requires a valid baseURL.' - ); - } - - // Convert array of segments to a string - if (is_array($relativePath)) { - $relativePath = implode('/', $relativePath); - } - - // If a full URI was passed then convert it - if (strpos($relativePath, '://') !== false) { - $full = new URI($relativePath); - $relativePath = URI::createURIString( - null, - null, - $full->getPath(), - $full->getQuery(), - $full->getFragment() - ); - } - - $relativePath = URI::removeDotSegments($relativePath); - - $request = Services::request(); - - if ($config === null) { - $baseURL = $request instanceof CLIRequest - ? rtrim($appConfig->baseURL, '/ ') . '/' - // Use the current baseURL for multiple domain support - : $request->getUri()->getBaseURL(); - - $config = $appConfig; - } else { - $baseURL = rtrim($config->baseURL, '/ ') . '/'; - } - - // Check for an index page - $indexPage = ''; - if ($config->indexPage !== '') { - $indexPage = $config->indexPage; - - // Check if we need a separator - if ($relativePath !== '' && $relativePath[0] !== '/' && $relativePath[0] !== '?') { - $indexPage .= '/'; - } - } - - $uri = new URI($baseURL . $indexPage . $relativePath); - - // Check if the baseURL scheme needs to be coerced into its secure version - if ($config->forceGlobalSecureRequests && $uri->getScheme() === 'http') { - $uri->setScheme('https'); - } - - return $uri; - } -} - if (! function_exists('site_url')) { /** * Returns a site URL as defined by the App config. @@ -115,22 +29,11 @@ function _get_uri($relativePath = '', ?App $config = null): URI */ function site_url($relativePath = '', ?string $scheme = null, ?App $config = null): string { - $uri = _get_uri($relativePath, $config); - - $uriString = URI::createURIString( - $scheme ?? $uri->getScheme(), - $uri->getAuthority(), - $uri->getPath(), - $uri->getQuery(), - $uri->getFragment() - ); - - // For protocol-relative links - if ($scheme === '') { - $uriString = '//' . $uriString; - } + $currentURI = Services::request()->getUri(); + + assert($currentURI instanceof SiteURI); - return $uriString; + return $currentURI->siteUrl($relativePath, $scheme, $config); } } @@ -144,18 +47,11 @@ function site_url($relativePath = '', ?string $scheme = null, ?App $config = nul */ function base_url($relativePath = '', ?string $scheme = null): string { - /** @var App $config */ - $config = clone config(App::class); - - // Use the current baseURL for multiple domain support - $request = Services::request(); - $config->baseURL = $request instanceof CLIRequest - ? rtrim($config->baseURL, '/ ') . '/' - : $request->getUri()->getBaseURL(); + $currentURI = Services::request()->getUri(); - $config->indexPage = ''; + assert($currentURI instanceof SiteURI); - return site_url($relativePath, $scheme, $config); + return $currentURI->baseUrl($relativePath, $scheme); } } @@ -173,18 +69,7 @@ function current_url(bool $returnObject = false, ?IncomingRequest $request = nul { $request ??= Services::request(); /** @var CLIRequest|IncomingRequest $request */ - $routePath = $request->getPath(); - $currentURI = $request->getUri(); - - // Append queries and fragments - if ($query = $currentURI->getQuery()) { - $query = '?' . $query; - } - if ($fragment = $currentURI->getFragment()) { - $fragment = '#' . $fragment; - } - - $uri = _get_uri($routePath . $query . $fragment); + $uri = $request->getUri(); return $returnObject ? $uri : URI::createURIString($uri->getScheme(), $uri->getAuthority(), $uri->getPath()); } @@ -220,10 +105,13 @@ function previous_url(bool $returnObject = false) */ function uri_string(): string { - // The value of Services::request()->getUri()->getPath() is overridden - // by IncomingRequest constructor. If we use it here, the current tests - // in CurrentUrlTest will fail. - return ltrim(Services::request()->getPath(), '/'); + // The value of Services::request()->getUri()->getPath() returns + // full URI path. + $uri = Services::request()->getUri(); + + $path = $uri instanceof SiteURI ? $uri->getRoutePath() : $uri->getPath(); + + return ltrim($path, '/'); } } diff --git a/system/HotReloader/DirectoryHasher.php b/system/HotReloader/DirectoryHasher.php new file mode 100644 index 000000000000..3d9914d8c8f8 --- /dev/null +++ b/system/HotReloader/DirectoryHasher.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HotReloader; + +use CodeIgniter\Exceptions\FrameworkException; +use Config\Toolbar; +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; + +/** + * @internal + */ +final class DirectoryHasher +{ + /** + * Generates an md5 value of all directories that are watched by the + * Hot Reloader, as defined in the Config\Toolbar. + * + * This is the current app fingerprint. + */ + public function hash(): string + { + return md5(implode('', $this->hashApp())); + } + + /** + * Generates an array of md5 hashes for all directories that are + * watched by the Hot Reloader, as defined in the Config\Toolbar. + */ + public function hashApp(): array + { + $hashes = []; + + $watchedDirectories = config(Toolbar::class)->watchedDirectories; + + foreach ($watchedDirectories as $directory) { + if (is_dir(ROOTPATH . $directory)) { + $hashes[$directory] = $this->hashDirectory(ROOTPATH . $directory); + } + } + + return array_unique(array_filter($hashes)); + } + + /** + * Generates an md5 hash of a given directory and all of its files + * that match the watched extensions defined in Config\Toolbar. + */ + public function hashDirectory(string $path): string + { + if (! is_dir($path)) { + throw FrameworkException::forInvalidDirectory($path); + } + + $directory = new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS); + $filter = new IteratorFilter($directory); + $iterator = new RecursiveIteratorIterator($filter); + + $hashes = []; + + foreach ($iterator as $file) { + if ($file->isFile()) { + $hashes[] = md5_file($file->getRealPath()); + } + } + + return md5(implode('', $hashes)); + } +} diff --git a/system/HotReloader/HotReloader.php b/system/HotReloader/HotReloader.php new file mode 100644 index 000000000000..564ba544283a --- /dev/null +++ b/system/HotReloader/HotReloader.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HotReloader; + +/** + * @internal + */ +final class HotReloader +{ + public function run(): void + { + ini_set('zlib.output_compression', 'Off'); + + header('Cache-Control: no-store'); + header('Content-Type: text/event-stream'); + header('Access-Control-Allow-Methods: GET'); + + ob_end_clean(); + set_time_limit(0); + + $hasher = new DirectoryHasher(); + $appHash = $hasher->hash(); + + while (true) { + if (connection_status() !== CONNECTION_NORMAL || connection_aborted()) { + break; + } + + $currentHash = $hasher->hash(); + + // If hash has changed, tell the browser to reload. + if ($currentHash !== $appHash) { + $appHash = $currentHash; + + $this->sendEvent('reload', ['time' => date('Y-m-d H:i:s')]); + break; + } + if (mt_rand(1, 10) > 8) { + $this->sendEvent('ping', ['time' => date('Y-m-d H:i:s')]); + } + + sleep(1); + } + } + + /** + * Send an event to the browser. + */ + private function sendEvent(string $event, array $data): void + { + echo "event: {$event}\n"; + echo 'data: ' . json_encode($data) . "\n\n"; + + ob_flush(); + flush(); + } +} diff --git a/system/HotReloader/IteratorFilter.php b/system/HotReloader/IteratorFilter.php new file mode 100644 index 000000000000..db775f90061e --- /dev/null +++ b/system/HotReloader/IteratorFilter.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HotReloader; + +use Config\Toolbar; +use RecursiveFilterIterator; +use RecursiveIterator; + +/** + * @internal + * + * @psalm-suppress MissingTemplateParam + */ +final class IteratorFilter extends RecursiveFilterIterator implements RecursiveIterator +{ + private array $watchedExtensions = []; + + public function __construct(RecursiveIterator $iterator) + { + parent::__construct($iterator); + + $this->watchedExtensions = config(Toolbar::class)->watchedExtensions; + } + + /** + * Apply filters to the files in the iterator. + */ + public function accept(): bool + { + if (! $this->current()->isFile()) { + return true; + } + + $filename = $this->current()->getFilename(); + + // Skip hidden files and directories. + if ($filename[0] === '.') { + return false; + } + + // Only consume files of interest. + $ext = trim(strtolower($this->current()->getExtension()), '. '); + + return in_array($ext, $this->watchedExtensions, true); + } +} diff --git a/system/Images/Handlers/GDHandler.php b/system/Images/Handlers/GDHandler.php index 840c1bb2e4eb..99403a164137 100644 --- a/system/Images/Handlers/GDHandler.php +++ b/system/Images/Handlers/GDHandler.php @@ -267,7 +267,7 @@ public function save(?string $target = null, int $quality = 90): bool throw ImageException::forInvalidImageCreate(lang('Images.webpNotSupported')); } - if (! @imagewebp($this->resource, $target)) { + if (! @imagewebp($this->resource, $target, $quality)) { throw ImageException::forSaveFailed(); } break; diff --git a/system/Language/en/Core.php b/system/Language/en/Core.php index 9ab549a4c8fe..1adfbe446560 100644 --- a/system/Language/en/Core.php +++ b/system/Language/en/Core.php @@ -14,6 +14,7 @@ 'copyError' => 'An error was encountered while attempting to replace the file "{0}". Please make sure your file directory is writable.', 'enabledZlibOutputCompression' => 'Your zlib.output_compression ini directive is turned on. This will not work well with output buffers.', 'invalidFile' => 'Invalid file: "{0}"', + 'invalidDirectory' => 'Directory does not exist: "{0}"', 'invalidPhpVersion' => 'Your PHP version must be {0} or higher to run CodeIgniter. Current version: {1}', 'missingExtension' => 'The framework needs the following extension(s) installed and loaded: "{0}".', 'noHandlers' => '"{0}" must provide at least one Handler.', diff --git a/system/Language/en/HTTP.php b/system/Language/en/HTTP.php index 942e3cf9dd2a..a7861c7188d1 100644 --- a/system/Language/en/HTTP.php +++ b/system/Language/en/HTTP.php @@ -21,7 +21,7 @@ 'invalidNegotiationType' => '"{0}" is not a valid negotiation type. Must be one of: media, charset, encoding, language.', // Message - 'invalidHTTPProtocol' => 'Invalid HTTP Protocol Version. Must be one of: {0}', + 'invalidHTTPProtocol' => 'Invalid HTTP Protocol Version: {0}', // Negotiate 'emptySupportedNegotiations' => 'You must provide an array of supported values to all Negotiations.', diff --git a/system/Modules/Modules.php b/system/Modules/Modules.php index ade3e693ceea..f99e7215e733 100644 --- a/system/Modules/Modules.php +++ b/system/Modules/Modules.php @@ -15,6 +15,8 @@ * Modules Class * * @see https://codeigniter.com/user_guide/general/modules.html + * + * @phpstan-consistent-constructor */ class Modules { @@ -39,6 +41,11 @@ class Modules */ public $aliases = []; + public function __construct() + { + // For @phpstan-consistent-constructor + } + /** * Should the application auto-discover the requested resource. */ @@ -50,4 +57,17 @@ public function shouldDiscover(string $alias): bool return in_array(strtolower($alias), $this->aliases, true); } + + public static function __set_state(array $array) + { + $obj = new static(); + + $properties = array_keys(get_object_vars($obj)); + + foreach ($properties as $property) { + $obj->{$property} = $array[$property]; + } + + return $obj; + } } diff --git a/system/Router/AutoRouterImproved.php b/system/Router/AutoRouterImproved.php index 42fe18e937af..b6b81c70f2ef 100644 --- a/system/Router/AutoRouterImproved.php +++ b/system/Router/AutoRouterImproved.php @@ -12,6 +12,8 @@ namespace CodeIgniter\Router; use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\Router\Exceptions\MethodNotFoundException; +use Config\Routing; use ReflectionClass; use ReflectionException; @@ -32,11 +34,6 @@ final class AutoRouterImproved implements AutoRouterInterface */ private ?string $directory = null; - /** - * Sub-namespace that contains the requested controller class. - */ - private ?string $subNamespace = null; - /** * The name of the controller class. */ @@ -49,6 +46,8 @@ final class AutoRouterImproved implements AutoRouterInterface /** * An array of params to the controller method. + * + * @phpstan-var list */ private array $params = []; @@ -73,6 +72,31 @@ final class AutoRouterImproved implements AutoRouterInterface */ private string $defaultMethod; + /** + * The URI segments. + * + * @phpstan-var list + */ + 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; + /** * @param class-string[] $protectedControllers * @param string $defaultController Short classname @@ -88,7 +112,7 @@ public function __construct(// @phpstan-ignore-line string $httpVerb ) { $this->protectedControllers = $protectedControllers; - $this->namespace = rtrim($namespace, '\\') . '\\'; + $this->namespace = rtrim($namespace, '\\'); $this->translateURIDashes = $translateURIDashes; $this->defaultController = $defaultController; $this->defaultMethod = $defaultMethod; @@ -97,6 +121,121 @@ public function __construct(// @phpstan-ignore-line $this->controller = $this->defaultController; } + private function createSegments(string $uri): array + { + $segments = explode('/', $uri); + $segments = array_filter($segments, static fn ($segment) => $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->translateURIDashes(ucfirst($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; + + // 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) => $this->translateURIDashes(ucfirst($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. * @@ -112,42 +251,69 @@ public function getRoute(string $uri, string $httpVerb): array $defaultMethod = $httpVerb . ucfirst($this->defaultMethod); $this->method = $defaultMethod; - $segments = explode('/', $uri); + $this->segments = $this->createSegments($uri); - // WARNING: Directories get shifted out of the segments array. - $nonDirSegments = $this->scanControllers($segments); - - $controllerSegment = ''; - $baseControllerName = $this->defaultController; + // Check for Module Routes. + if ( + $this->segments !== [] + && ($routingConfig = config(Routing::class)) + && array_key_exists($this->segments[0], $routingConfig->moduleRoutes) + ) { + $uriSegment = array_shift($this->segments); + $this->namespace = rtrim($routingConfig->moduleRoutes[$uriSegment], '\\'); + } - // 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); + if ($this->searchFirstController()) { + // Controller is found. + $baseControllerName = class_basename($this->controller); - $baseControllerName = $this->translateURIDashes(ucfirst($controllerSegment)); + // 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); } - if (! $this->isValidSegment($baseControllerName)) { - throw new PageNotFoundException($baseControllerName . ' is not a valid controller name'); - } + // The first item may be a method name. + /** @phpstan-var list $params */ + $params = $this->params; - // 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.' - ); + $methodParam = array_shift($params); + + $method = ''; + if ($methodParam !== null) { + $method = $httpVerb . ucfirst($this->translateURIDashes($methodParam)); } - // Use the method name if it exists. - if (! empty($nonDirSegments)) { - $methodSegment = $this->translateURIDashes(array_shift($nonDirSegments)); + 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++; + } - // Prefix HTTP verb - $this->method = $httpVerb . ucfirst($methodSegment); + // 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)) { @@ -155,38 +321,75 @@ public function getRoute(string $uri, string $httpVerb): array '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); } - 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. + // Ensure the controller is not defined in routes. $this->protectDefinedRoutes(); - // Check _remap() + // Ensure the controller does not have _remap() method. $this->checkRemap(); - // Check parameters + // Ensure the URI segments for the controller and method do not contain + // underscores when $translateURIDashes is true. + $this->checkUnderscore($uri); + + // Check parameter count try { $this->checkParameters($uri); - } catch (ReflectionException $e) { + } catch (MethodNotFoundException $e) { 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 + */ + 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); @@ -204,12 +407,21 @@ private function protectDefinedRoutes(): void private function checkParameters(string $uri): void { - $refClass = new ReflectionClass($this->controller); - $refMethod = $refClass->getMethod($this->method); - $refParams = $refMethod->getParameters(); + try { + $refClass = new ReflectionClass($this->controller); + } catch (ReflectionException $e) { + throw PageNotFoundException::forControllerNotFound($this->controller, $this->method); + } + + try { + $refMethod = $refClass->getMethod($this->method); + $refParams = $refMethod->getParameters(); + } catch (ReflectionException $e) { + throw new MethodNotFoundException(); + } if (! $refMethod->isPublic()) { - throw PageNotFoundException::forMethodNotFound($this->method); + throw new MethodNotFoundException(); } if (count($refParams) < count($this->params)) { @@ -236,48 +448,26 @@ private function checkRemap(): void } } - /** - * 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 + private function checkUnderscore(string $uri): void { - $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 = $this->translateURIDashes(ucfirst($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); + if ($this->translateURIDashes === false) { + return; + } - $this->directory .= $this->directory . $segmentConvert . '/'; + $paramPos = $this->paramPos ?? count($this->segments); - continue; + for ($i = 0; $i < $paramPos; $i++) { + if (strpos($this->segments[$i], '_') !== false) { + 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:' . $uri + ); } - - return $segments; } - - // This means that all segments were actually directories - return $segments; } /** @@ -290,34 +480,10 @@ 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 + private function translateURIDashes(string $segment): string { return $this->translateURIDashes - ? str_replace('-', '_', $classname) - : $classname; + ? str_replace('-', '_', $segment) + : $segment; } } diff --git a/system/Router/DefinedRouteCollector.php b/system/Router/DefinedRouteCollector.php new file mode 100644 index 000000000000..9d211415a8a0 --- /dev/null +++ b/system/Router/DefinedRouteCollector.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Router; + +use Closure; +use Generator; + +/** + * Collect all defined routes for display. + */ +final class DefinedRouteCollector +{ + private RouteCollection $routeCollection; + + public function __construct(RouteCollection $routes) + { + $this->routeCollection = $routes; + } + + /** + * @phpstan-return Generator + */ + public function collect(): Generator + { + $methods = [ + 'get', + 'head', + 'post', + 'patch', + 'put', + 'delete', + 'options', + 'trace', + 'connect', + 'cli', + ]; + + foreach ($methods as $method) { + $routes = $this->routeCollection->getRoutes($method); + + foreach ($routes as $route => $handler) { + if (is_string($handler) || $handler instanceof Closure) { + if ($handler instanceof Closure) { + $view = $this->routeCollection->getRoutesOptions($route, $method)['view'] ?? false; + + $handler = $view ? '(View) ' . $view : '(Closure)'; + } + + $routeName = $this->routeCollection->getRoutesOptions($route)['as'] ?? $route; + + yield [ + 'method' => $method, + 'route' => $route, + 'name' => $routeName, + 'handler' => $handler, + ]; + } + } + } + } +} diff --git a/system/Router/Exceptions/MethodNotFoundException.php b/system/Router/Exceptions/MethodNotFoundException.php new file mode 100644 index 000000000000..d9ad45a5fa3d --- /dev/null +++ b/system/Router/Exceptions/MethodNotFoundException.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\Exceptions; + +use RuntimeException; + +/** + * @internal + */ +final class MethodNotFoundException extends RuntimeException +{ +} diff --git a/system/Router/Exceptions/RedirectException.php b/system/Router/Exceptions/RedirectException.php index a23d651855de..5e19e2e9ac5c 100644 --- a/system/Router/Exceptions/RedirectException.php +++ b/system/Router/Exceptions/RedirectException.php @@ -16,6 +16,8 @@ /** * RedirectException + * + * @deprecated Use \CodeIgniter\HTTP\Exceptions\RedirectException instead */ class RedirectException extends Exception implements HTTPExceptionInterface { diff --git a/system/Router/RouteCollection.php b/system/Router/RouteCollection.php index 8fa3054edf1d..82c6a6dd5bbe 100644 --- a/system/Router/RouteCollection.php +++ b/system/Router/RouteCollection.php @@ -16,6 +16,7 @@ use CodeIgniter\Router\Exceptions\RouterException; use Config\App; use Config\Modules; +use Config\Routing; use Config\Services; use InvalidArgumentException; use Locale; @@ -88,6 +89,11 @@ class RouteCollection implements RouteCollectionInterface */ protected $override404; + /** + * An array of files that would contain route definitions. + */ + protected array $routeFiles = []; + /** * Defined placeholders that can be used * within the @@ -110,12 +116,20 @@ class RouteCollection implements RouteCollectionInterface * * [ * verb => [ - * routeName => [ - * 'route' => [ - * routeKey(regex) => handler, - * ], + * routeKey(regex) => [ + * 'name' => routeName + * 'handler' => handler, + * 'from' => from, + * ], + * ], + * // redirect route + * '*' => [ + * routeKey(regex)(from) => [ + * 'name' => routeName + * 'handler' => [routeKey(regex)(to) => handler], + * 'from' => from, * 'redirect' => statusCode, - * ] + * ], * ], * ] */ @@ -132,6 +146,30 @@ class RouteCollection implements RouteCollectionInterface 'cli' => [], ]; + /** + * Array of routes names + * + * @var array + * + * [ + * verb => [ + * routeName => routeKey(regex) + * ], + * ] + */ + protected $routesNames = [ + '*' => [], + 'options' => [], + 'get' => [], + 'head' => [], + 'post' => [], + 'put' => [], + 'delete' => [], + 'trace' => [], + 'connect' => [], + 'cli' => [], + ]; + /** * Array of routes options * @@ -242,12 +280,27 @@ class RouteCollection implements RouteCollectionInterface /** * Constructor */ - public function __construct(FileLocator $locator, Modules $moduleConfig) + public function __construct(FileLocator $locator, Modules $moduleConfig, Routing $routing) { $this->fileLocator = $locator; $this->moduleConfig = $moduleConfig; $this->httpHost = Services::request()->getServer('HTTP_HOST'); + + // Setup based on config file. Let routes file override. + $this->defaultNamespace = $routing->defaultNamespace; + $this->defaultController = $routing->defaultController; + $this->defaultMethod = $routing->defaultMethod; + $this->translateURIDashes = $routing->translateURIDashes; + $this->override404 = $routing->override404; + $this->autoRoute = $routing->autoRoute; + $this->routeFiles = $routing->routeFiles; + $this->prioritize = $routing->prioritize; + + // Normalize the path string in routeFiles array. + foreach ($this->routeFiles as $routeKey => $routesFile) { + $this->routeFiles[$routeKey] = realpath($routesFile) ?: $routesFile; + } } /** @@ -263,8 +316,26 @@ public function loadRoutes(string $routesFile = APPPATH . 'Config/Routes.php') return $this; } + // Include the passed in routesFile if it doesn't exist. + // Only keeping that around for BC purposes for now. + $routeFiles = $this->routeFiles; + if (! in_array($routesFile, $routeFiles, true)) { + $routeFiles[] = $routesFile; + } + + // We need this var in local scope + // so route files can access it. $routes = $this; - require $routesFile; + + foreach ($routeFiles as $routesFile) { + if (! is_file($routesFile)) { + log_message('warning', sprintf('Routes file not found: "%s"', $routesFile)); + + continue; + } + + require $routesFile; + } $this->discoverRoutes(); @@ -290,14 +361,9 @@ protected function discoverRoutes() if ($this->moduleConfig->shouldDiscover('routes')) { $files = $this->fileLocator->search('Config/Routes.php'); - $excludes = [ - APPPATH . 'Config' . DIRECTORY_SEPARATOR . 'Routes.php', - SYSTEMPATH . 'Config' . DIRECTORY_SEPARATOR . 'Routes.php', - ]; - foreach ($files as $file) { // Don't include our main file again... - if (in_array($file, $excludes, true)) { + if (in_array($file, $this->routeFiles, true)) { continue; } @@ -507,9 +573,8 @@ public function getRoutes(?string $verb = null, bool $includeWildcard = true): a // before any of the generic, "add" routes. $collection = $includeWildcard ? $this->routes[$verb] + ($this->routes['*'] ?? []) : $this->routes[$verb]; - foreach ($collection as $r) { - $key = key($r['route']); - $routes[$key] = $r['route'][$key]; + foreach ($collection as $routeKey => $r) { + $routes[$routeKey] = $r['handler']; } } @@ -608,27 +673,41 @@ public function add(string $from, $to, ?array $options = null): RouteCollectionI public function addRedirect(string $from, string $to, int $status = 302) { // Use the named route's pattern if this is a named route. - if (array_key_exists($to, $this->routes['*'])) { - $to = $this->routes['*'][$to]['route']; - } elseif (array_key_exists($to, $this->routes['get'])) { - $to = $this->routes['get'][$to]['route']; + if (array_key_exists($to, $this->routesNames['*'])) { + $routeName = $to; + $routeKey = $this->routesNames['*'][$routeName]; + $redirectTo = [$routeKey => $this->routes['*'][$routeKey]['handler']]; + } elseif (array_key_exists($to, $this->routesNames['get'])) { + $routeName = $to; + $routeKey = $this->routesNames['get'][$routeName]; + $redirectTo = [$routeKey => $this->routes['get'][$routeKey]['handler']]; + } else { + // The named route is not found. + $redirectTo = $to; } - $this->create('*', $from, $to, ['redirect' => $status]); + $this->create('*', $from, $redirectTo, ['redirect' => $status]); return $this; } /** * Determines if the route is a redirecting route. + * + * @param string $routeKey routeKey or route name */ - public function isRedirect(string $from): bool + public function isRedirect(string $routeKey): bool { - foreach ($this->routes['*'] as $name => $route) { - // Named route? - if ($name === $from || key($route['route']) === $from) { - return isset($route['redirect']) && is_numeric($route['redirect']); - } + if (isset($this->routes['*'][$routeKey]['redirect'])) { + return true; + } + + // This logic is not used. Should be deprecated? + $routeName = $this->routes['*'][$routeKey]['name'] ?? null; + if ($routeName === $routeKey) { + $routeKey = $this->routesNames['*'][$routeName]; + + return isset($this->routes['*'][$routeKey]['redirect']); } return false; @@ -636,14 +715,21 @@ public function isRedirect(string $from): bool /** * Grabs the HTTP status code from a redirecting Route. + * + * @param string $routeKey routeKey or route name */ - public function getRedirectCode(string $from): int + public function getRedirectCode(string $routeKey): int { - foreach ($this->routes['*'] as $name => $route) { - // Named route? - if ($name === $from || key($route['route']) === $from) { - return $route['redirect'] ?? 0; - } + if (isset($this->routes['*'][$routeKey]['redirect'])) { + return $this->routes['*'][$routeKey]['redirect']; + } + + // This logic is not used. Should be deprecated? + $routeName = $this->routes['*'][$routeKey]['name'] ?? null; + if ($routeName === $routeKey) { + $routeKey = $this->routesNames['*'][$routeName]; + + return $this->routes['*'][$routeKey]['redirect']; } return 0; @@ -1021,7 +1107,10 @@ public function view(string $from, string $view, ?array $options = null): RouteC ->setData(['segments' => $data], 'raw') ->render($view, $options); - $this->create('get', $from, $to, $options); + $routeOptions = $options ?? []; + $routeOptions = array_merge($routeOptions, ['view' => $view]); + + $this->create('get', $from, $to, $routeOptions); return $this; } @@ -1060,9 +1149,11 @@ public function environment(string $env, Closure $callback): RouteCollectionInte public function reverseRoute(string $search, ...$params) { // Named routes get higher priority. - foreach ($this->routes as $collection) { + foreach ($this->routesNames as $collection) { if (array_key_exists($search, $collection)) { - return $this->buildReverseRoute(key($collection[$search]['route']), $params); + $routeKey = $collection[$search]; + + return $this->buildReverseRoute($routeKey, $params); } } @@ -1078,9 +1169,8 @@ public function reverseRoute(string $search, ...$params) // If it's not a named route, then loop over // all routes to find a match. foreach ($this->routes as $collection) { - foreach ($collection as $route) { - $from = key($route['route']); - $to = $route['route'][$from]; + foreach ($collection as $routeKey => $route) { + $to = $route['handler']; // ignore closures if (! is_string($to)) { @@ -1104,7 +1194,7 @@ public function reverseRoute(string $search, ...$params) continue; } - return $this->buildReverseRoute($from, $params); + return $this->buildReverseRoute($routeKey, $params); } } @@ -1219,21 +1309,21 @@ protected function fillRouteParams(string $from, ?array $params = null): string * @param array $params One or more parameters to be passed to the route. * The last parameter allows you to set the locale. */ - protected function buildReverseRoute(string $from, array $params): string + protected function buildReverseRoute(string $routeKey, array $params): string { $locale = null; // Find all of our back-references in the original route - preg_match_all('/\(([^)]+)\)/', $from, $matches); + preg_match_all('/\(([^)]+)\)/', $routeKey, $matches); if (empty($matches[0])) { - if (strpos($from, '{locale}') !== false) { + if (strpos($routeKey, '{locale}') !== false) { $locale = $params[0] ?? null; } - $from = $this->replaceLocale($from, $locale); + $routeKey = $this->replaceLocale($routeKey, $locale); - return '/' . ltrim($from, '/'); + return '/' . ltrim($routeKey, '/'); } // Locale is passed? @@ -1247,7 +1337,7 @@ protected function buildReverseRoute(string $from, array $params): string foreach ($matches[0] as $index => $pattern) { if (! isset($params[$index])) { throw new InvalidArgumentException( - 'Missing argument for "' . $pattern . '" in route "' . $from . '".' + 'Missing argument for "' . $pattern . '" in route "' . $routeKey . '".' ); } if (! preg_match('#^' . $pattern . '$#u', $params[$index])) { @@ -1256,13 +1346,13 @@ protected function buildReverseRoute(string $from, array $params): string // Ensure that the param we're inserting matches // the expected param type. - $pos = strpos($from, $pattern); - $from = substr_replace($from, $params[$index], $pos, strlen($pattern)); + $pos = strpos($routeKey, $pattern); + $routeKey = substr_replace($routeKey, $params[$index], $pos, strlen($pattern)); } - $from = $this->replaceLocale($from, $locale); + $routeKey = $this->replaceLocale($routeKey, $locale); - return '/' . ltrim($from, '/'); + return '/' . ltrim($routeKey, '/'); } /** @@ -1312,7 +1402,7 @@ protected function create(string $verb, string $from, $to, ?array $options = nul } // When redirecting to named route, $to is an array like `['zombies' => '\Zombies::index']`. - if (is_array($to) && count($to) === 2) { + if (is_array($to) && isset($to[0])) { $to = $this->processArrayCallableSyntax($from, $to); } @@ -1364,10 +1454,12 @@ protected function create(string $verb, string $from, $to, ?array $options = nul } } + $routeKey = $from; + // Replace our regex pattern placeholders with the actual thing // so that the Router doesn't need to know about any of this. foreach ($this->placeholders as $tag => $pattern) { - $from = str_ireplace(':' . $tag, $pattern, $from); + $routeKey = str_ireplace(':' . $tag, $pattern, $routeKey); } // If is redirect, No processing @@ -1382,7 +1474,7 @@ protected function create(string $verb, string $from, $to, ?array $options = nul $to = '\\' . ltrim($to, '\\'); } - $name = $options['as'] ?? $from; + $name = $options['as'] ?? $routeKey; helper('array'); @@ -1391,20 +1483,22 @@ protected function create(string $verb, string $from, $to, ?array $options = nul // routes should always be the "source of truth". // this works only because discovered routes are added just prior // to attempting to route the request. - $fromExists = dot_array_search('*.route.' . $from, $this->routes[$verb] ?? []) !== null; - if ((isset($this->routes[$verb][$name]) || $fromExists) && ! $overwrite) { + $routeKeyExists = isset($this->routes[$verb][$routeKey]); + if ((isset($this->routesNames[$verb][$name]) || $routeKeyExists) && ! $overwrite) { return; } - $this->routes[$verb][$name] = [ - 'route' => [$from => $to], + $this->routes[$verb][$routeKey] = [ + 'name' => $name, + 'handler' => $to, + 'from' => $from, ]; - - $this->routesOptions[$verb][$from] = $options; + $this->routesOptions[$verb][$routeKey] = $options; + $this->routesNames[$verb][$name] = $routeKey; // Is this a redirect? if (isset($options['redirect']) && is_numeric($options['redirect'])) { - $this->routes['*'][$name]['redirect'] = $options['redirect']; + $this->routes['*'][$routeKey]['redirect'] = $options['redirect']; } } @@ -1547,12 +1641,15 @@ private function determineCurrentSubdomain() */ public function resetRoutes() { - $this->routes = ['*' => []]; + $this->routes = $this->routesNames = ['*' => []]; foreach ($this->defaultHTTPMethods as $verb) { - $this->routes[$verb] = []; + $this->routes[$verb] = []; + $this->routesNames[$verb] = []; } + $this->routesOptions = []; + $this->prioritizeDetected = false; $this->didDiscover = false; } @@ -1566,7 +1663,7 @@ public function resetRoutes() * array{ * filter?: string|list, namespace?: string, hostname?: string, * subdomain?: string, offset?: int, priority?: int, as?: string, - * redirect?: string + * redirect?: int * } * > */ @@ -1619,8 +1716,7 @@ public function getRegisteredControllers(?string $verb = '*'): array if ($verb === '*') { foreach ($this->defaultHTTPMethods as $tmpVerb) { foreach ($this->routes[$tmpVerb] as $route) { - $routeKey = key($route['route']); - $controller = $this->getControllerName($route['route'][$routeKey]); + $controller = $this->getControllerName($route['handler']); if ($controller !== null) { $controllers[] = $controller; } diff --git a/system/Router/RouteCollectionInterface.php b/system/Router/RouteCollectionInterface.php index 701e893dcd16..0ca43523a89b 100644 --- a/system/Router/RouteCollectionInterface.php +++ b/system/Router/RouteCollectionInterface.php @@ -179,12 +179,12 @@ public function reverseRoute(string $search, ...$params); /** * Determines if the route is a redirecting route. */ - public function isRedirect(string $from): bool; + public function isRedirect(string $routeKey): bool; /** * Grabs the HTTP status code from a redirecting Route. */ - public function getRedirectCode(string $from): int; + public function getRedirectCode(string $routeKey): int; /** * Get the flag that limit or not the routes with {locale} placeholder to App::$supportedLocales diff --git a/system/Router/Router.php b/system/Router/Router.php index 5dee59b31c25..bd57435f8020 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -13,8 +13,8 @@ use Closure; use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\Request; -use CodeIgniter\Router\Exceptions\RedirectException; use CodeIgniter\Router\Exceptions\RouterException; use Config\App; use Config\Feature; diff --git a/system/Security/Security.php b/system/Security/Security.php index c31cea42288f..c67c13ce0c1c 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -18,7 +18,6 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Security\Exceptions\SecurityException; use CodeIgniter\Session\Session; -use Config\App; use Config\Cookie as CookieConfig; use Config\Security as SecurityConfig; use Config\Services; @@ -44,6 +43,8 @@ class Security implements SecurityInterface * Protection Method for Cross Site Request Forgery protection. * * @var string 'cookie' or 'session' + * + * @deprecated 4.4.0 Use $this->config->csrfProtection. */ protected $csrfProtection = self::CSRF_PROTECTION_COOKIE; @@ -51,6 +52,8 @@ class Security implements SecurityInterface * CSRF Token Randomization * * @var bool + * + * @deprecated 4.4.0 Use $this->config->tokenRandomize. */ protected $tokenRandomize = false; @@ -69,6 +72,8 @@ class Security implements SecurityInterface * Token name for Cross Site Request Forgery protection. * * @var string + * + * @deprecated 4.4.0 Use $this->config->tokenName. */ protected $tokenName = 'csrf_token_name'; @@ -78,6 +83,8 @@ class Security implements SecurityInterface * Header name for Cross Site Request Forgery protection. * * @var string + * + * @deprecated 4.4.0 Use $this->config->headerName. */ protected $headerName = 'X-CSRF-TOKEN'; @@ -105,6 +112,8 @@ class Security implements SecurityInterface * Defaults to two hours (in seconds). * * @var int + * + * @deprecated 4.4.0 Use $this->config->expires. */ protected $expires = 7200; @@ -114,6 +123,8 @@ class Security implements SecurityInterface * Regenerate CSRF Token on every request. * * @var bool + * + * @deprecated 4.4.0 Use $this->config->regenerate. */ protected $regenerate = true; @@ -123,6 +134,8 @@ class Security implements SecurityInterface * Redirect to previous page with error on failure. * * @var bool + * + * @deprecated 4.4.0 Use $this->config->redirect. */ protected $redirect = false; @@ -163,39 +176,27 @@ class Security implements SecurityInterface */ private ?string $hashInCookie = null; + /** + * Security Config + */ + protected SecurityConfig $config; + /** * Constructor. * * Stores our configuration and fires off the init() method to setup * initial state. */ - public function __construct(App $config) + public function __construct(SecurityConfig $config) { - /** @var SecurityConfig|null $security */ - $security = config(SecurityConfig::class); - - // Store CSRF-related configurations - if ($security instanceof SecurityConfig) { - $this->csrfProtection = $security->csrfProtection ?? $this->csrfProtection; - $this->tokenName = $security->tokenName ?? $this->tokenName; - $this->headerName = $security->headerName ?? $this->headerName; - $this->regenerate = $security->regenerate ?? $this->regenerate; - $this->redirect = $security->redirect ?? $this->redirect; - $this->rawCookieName = $security->cookieName ?? $this->rawCookieName; - $this->expires = $security->expires ?? $this->expires; - $this->tokenRandomize = $security->tokenRandomize ?? $this->tokenRandomize; - } else { - // `Config/Security.php` is absence - $this->tokenName = $config->CSRFTokenName ?? $this->tokenName; - $this->headerName = $config->CSRFHeaderName ?? $this->headerName; - $this->regenerate = $config->CSRFRegenerate ?? $this->regenerate; - $this->rawCookieName = $config->CSRFCookieName ?? $this->rawCookieName; - $this->expires = $config->CSRFExpire ?? $this->expires; - $this->redirect = $config->CSRFRedirect ?? $this->redirect; - } + $this->config = $config; + + $this->rawCookieName = $config->cookieName; if ($this->isCSRFCookie()) { - $this->configureCookie($config); + $cookie = config(CookieConfig::class); + + $this->configureCookie($cookie); } else { // Session based CSRF protection $this->configureSession(); @@ -212,7 +213,7 @@ public function __construct(App $config) private function isCSRFCookie(): bool { - return $this->csrfProtection === self::CSRF_PROTECTION_COOKIE; + return $this->config->csrfProtection === self::CSRF_PROTECTION_COOKIE; } private function configureSession(): void @@ -220,20 +221,11 @@ private function configureSession(): void $this->session = Services::session(); } - private function configureCookie(App $config): void + private function configureCookie(CookieConfig $cookie): void { - /** @var CookieConfig|null $cookie */ - $cookie = config(CookieConfig::class); - - if ($cookie instanceof CookieConfig) { - $cookiePrefix = $cookie->prefix; - $this->cookieName = $cookiePrefix . $this->rawCookieName; - Cookie::setDefaults($cookie); - } else { - // `Config/Cookie.php` is absence - $cookiePrefix = $config->cookiePrefix; - $this->cookieName = $cookiePrefix . $this->rawCookieName; - } + $cookiePrefix = $cookie->prefix; + $this->cookieName = $cookiePrefix . $this->rawCookieName; + Cookie::setDefaults($cookie); } /** @@ -295,7 +287,7 @@ public function verify(RequestInterface $request) $postedToken = $this->getPostedToken($request); try { - $token = ($postedToken !== null && $this->tokenRandomize) + $token = ($postedToken !== null && $this->config->tokenRandomize) ? $this->derandomize($postedToken) : $postedToken; } catch (InvalidArgumentException $e) { $token = null; @@ -308,7 +300,7 @@ public function verify(RequestInterface $request) $this->removeTokenInRequest($request); - if ($this->regenerate) { + if ($this->config->regenerate) { $this->generateHash(); } @@ -326,13 +318,13 @@ private function removeTokenInRequest(RequestInterface $request): void $json = json_decode($request->getBody() ?? ''); - if (isset($_POST[$this->tokenName])) { + if (isset($_POST[$this->config->tokenName])) { // We kill this since we're done and we don't want to pollute the POST array. - unset($_POST[$this->tokenName]); + unset($_POST[$this->config->tokenName]); $request->setGlobal('post', $_POST); - } elseif (isset($json->{$this->tokenName})) { + } elseif (isset($json->{$this->config->tokenName})) { // We kill this since we're done and we don't want to pollute the JSON data. - unset($json->{$this->tokenName}); + unset($json->{$this->config->tokenName}); $request->setBody(json_encode($json)); } } @@ -343,19 +335,19 @@ private function getPostedToken(RequestInterface $request): ?string // Does the token exist in POST, HEADER or optionally php:://input - json data. - if ($tokenValue = $request->getPost($this->tokenName)) { + if ($tokenValue = $request->getPost($this->config->tokenName)) { return $tokenValue; } - if ($request->hasHeader($this->headerName) && ! empty($request->header($this->headerName)->getValue())) { - return $request->header($this->headerName)->getValue(); + if ($request->hasHeader($this->config->headerName) && ! empty($request->header($this->config->headerName)->getValue())) { + return $request->header($this->config->headerName)->getValue(); } $body = (string) $request->getBody(); $json = json_decode($body); if ($body !== '' && ! empty($json) && json_last_error() === JSON_ERROR_NONE) { - return $json->{$this->tokenName} ?? null; + return $json->{$this->config->tokenName} ?? null; } return null; @@ -366,7 +358,7 @@ private function getPostedToken(RequestInterface $request): ?string */ public function getHash(): ?string { - return $this->tokenRandomize ? $this->randomize($this->hash) : $this->hash; + return $this->config->tokenRandomize ? $this->randomize($this->hash) : $this->hash; } /** @@ -415,7 +407,7 @@ protected function derandomize(string $token): string */ public function getTokenName(): string { - return $this->tokenName; + return $this->config->tokenName; } /** @@ -423,7 +415,7 @@ public function getTokenName(): string */ public function getHeaderName(): string { - return $this->headerName; + return $this->config->headerName; } /** @@ -431,7 +423,7 @@ public function getHeaderName(): string */ public function getCookieName(): string { - return $this->cookieName; + return $this->config->cookieName; } /** @@ -451,7 +443,7 @@ public function isExpired(): bool */ public function shouldRedirect(): bool { - return $this->redirect; + return $this->config->redirect; } /** @@ -529,9 +521,9 @@ private function restoreHash(): void if ($this->isHashInCookie()) { $this->hash = $this->hashInCookie; } - } elseif ($this->session->has($this->tokenName)) { + } elseif ($this->session->has($this->config->tokenName)) { // Session based CSRF protection - $this->hash = $this->session->get($this->tokenName); + $this->hash = $this->session->get($this->config->tokenName); } } @@ -570,7 +562,7 @@ private function saveHashInCookie(): void $this->rawCookieName, $this->hash, [ - 'expires' => $this->expires === 0 ? 0 : Time::now()->getTimestamp() + $this->expires, + 'expires' => $this->config->expires === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expires, ] ); @@ -614,6 +606,6 @@ protected function doSendCookie(): void private function saveHashInSession(): void { - $this->session->set($this->tokenName, $this->hash); + $this->session->set($this->config->tokenName, $this->hash); } } diff --git a/system/Session/Handlers/BaseHandler.php b/system/Session/Handlers/BaseHandler.php index 9ea9df971d1e..086cdc2f2276 100644 --- a/system/Session/Handlers/BaseHandler.php +++ b/system/Session/Handlers/BaseHandler.php @@ -11,7 +11,6 @@ namespace CodeIgniter\Session\Handlers; -use Config\App as AppConfig; use Config\Cookie as CookieConfig; use Config\Session as SessionConfig; use Psr\Log\LoggerAwareTrait; @@ -105,39 +104,19 @@ abstract class BaseHandler implements SessionHandlerInterface */ protected $ipAddress; - public function __construct(AppConfig $config, string $ipAddress) + public function __construct(SessionConfig $config, string $ipAddress) { - /** @var SessionConfig|null $session */ - $session = config(SessionConfig::class); - // Store Session configurations - if ($session instanceof SessionConfig) { - $this->cookieName = $session->cookieName; - $this->matchIP = $session->matchIP; - $this->savePath = $session->savePath; - } else { - // `Config/Session.php` is absence - $this->cookieName = $config->sessionCookieName; - $this->matchIP = $config->sessionMatchIP; - $this->savePath = $config->sessionSavePath; - } - - /** @var CookieConfig|null $cookie */ + $this->cookieName = $config->cookieName; + $this->matchIP = $config->matchIP; + $this->savePath = $config->savePath; + $cookie = config(CookieConfig::class); - if ($cookie instanceof CookieConfig) { - // Session cookies have no prefix. - $this->cookieDomain = $cookie->domain; - $this->cookiePath = $cookie->path; - $this->cookieSecure = $cookie->secure; - } else { - // @TODO Remove this fallback when deprecated `App` members are removed. - // `Config/Cookie.php` is absence - // Session cookies have no prefix. - $this->cookieDomain = $config->cookieDomain; - $this->cookiePath = $config->cookiePath; - $this->cookieSecure = $config->cookieSecure; - } + // Session cookies have no prefix. + $this->cookieDomain = $cookie->domain; + $this->cookiePath = $cookie->path; + $this->cookieSecure = $cookie->secure; $this->ipAddress = $ipAddress; } diff --git a/system/Session/Handlers/DatabaseHandler.php b/system/Session/Handlers/DatabaseHandler.php index fa474faae54a..edde55ca34c6 100644 --- a/system/Session/Handlers/DatabaseHandler.php +++ b/system/Session/Handlers/DatabaseHandler.php @@ -14,7 +14,6 @@ use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\BaseConnection; use CodeIgniter\Session\Exceptions\SessionException; -use Config\App as AppConfig; use Config\Database; use Config\Session as SessionConfig; use ReturnTypeWillChange; @@ -69,24 +68,14 @@ class DatabaseHandler extends BaseHandler /** * @throws SessionException */ - public function __construct(AppConfig $config, string $ipAddress) + public function __construct(SessionConfig $config, string $ipAddress) { parent::__construct($config, $ipAddress); - /** @var SessionConfig|null $session */ - $session = config(SessionConfig::class); - // Store Session configurations - if ($session instanceof SessionConfig) { - $this->DBGroup = $session->DBGroup ?? config(Database::class)->defaultGroup; - // Add sessionCookieName for multiple session cookies. - $this->idPrefix = $session->cookieName . ':'; - } else { - // `Config/Session.php` is absence - $this->DBGroup = $config->sessionDBGroup ?? config(Database::class)->defaultGroup; - // Add sessionCookieName for multiple session cookies. - $this->idPrefix = $config->sessionCookieName . ':'; - } + $this->DBGroup = $config->DBGroup ?? config(Database::class)->defaultGroup; + // Add sessionCookieName for multiple session cookies. + $this->idPrefix = $config->cookieName . ':'; $this->table = $this->savePath; if (empty($this->table)) { diff --git a/system/Session/Handlers/FileHandler.php b/system/Session/Handlers/FileHandler.php index cc8a6694f692..9eb642408381 100644 --- a/system/Session/Handlers/FileHandler.php +++ b/system/Session/Handlers/FileHandler.php @@ -13,7 +13,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Session\Exceptions\SessionException; -use Config\App as AppConfig; +use Config\Session as SessionConfig; use ReturnTypeWillChange; /** @@ -63,7 +63,7 @@ class FileHandler extends BaseHandler */ protected $sessionIDRegex = ''; - public function __construct(AppConfig $config, string $ipAddress) + public function __construct(SessionConfig $config, string $ipAddress) { parent::__construct($config, $ipAddress); diff --git a/system/Session/Handlers/MemcachedHandler.php b/system/Session/Handlers/MemcachedHandler.php index e6f575e94b84..abccfafb5ba4 100644 --- a/system/Session/Handlers/MemcachedHandler.php +++ b/system/Session/Handlers/MemcachedHandler.php @@ -13,7 +13,6 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Session\Exceptions\SessionException; -use Config\App as AppConfig; use Config\Session as SessionConfig; use Memcached; use ReturnTypeWillChange; @@ -54,23 +53,18 @@ class MemcachedHandler extends BaseHandler /** * @throws SessionException */ - public function __construct(AppConfig $config, string $ipAddress) + public function __construct(SessionConfig $config, string $ipAddress) { parent::__construct($config, $ipAddress); - /** @var SessionConfig|null $session */ - $session = config(SessionConfig::class); - - $this->sessionExpiration = ($session instanceof SessionConfig) - ? $session->expiration : $config->sessionExpiration; + $this->sessionExpiration = $config->expiration; if (empty($this->savePath)) { throw SessionException::forEmptySavepath(); } // Add sessionCookieName for multiple session cookies. - $this->keyPrefix .= ($session instanceof SessionConfig) - ? $session->cookieName : $config->sessionCookieName . ':'; + $this->keyPrefix .= $config->cookieName . ':'; if ($this->matchIP === true) { $this->keyPrefix .= $this->ipAddress . ':'; diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index 9d427f381152..a85a16c68ca5 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -13,7 +13,6 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Session\Exceptions\SessionException; -use Config\App as AppConfig; use Config\Session as SessionConfig; use Redis; use RedisException; @@ -67,28 +66,16 @@ class RedisHandler extends BaseHandler * * @throws SessionException */ - public function __construct(AppConfig $config, string $ipAddress) + public function __construct(SessionConfig $config, string $ipAddress) { parent::__construct($config, $ipAddress); - /** @var SessionConfig|null $session */ - $session = config(SessionConfig::class); - // Store Session configurations - if ($session instanceof SessionConfig) { - $this->sessionExpiration = empty($session->expiration) - ? (int) ini_get('session.gc_maxlifetime') - : $session->expiration; - // Add sessionCookieName for multiple session cookies. - $this->keyPrefix .= $session->cookieName . ':'; - } else { - // `Config/Session.php` is absence - $this->sessionExpiration = empty($config->sessionExpiration) - ? (int) ini_get('session.gc_maxlifetime') - : $config->sessionExpiration; - // Add sessionCookieName for multiple session cookies. - $this->keyPrefix .= $config->sessionCookieName . ':'; - } + $this->sessionExpiration = empty($config->expiration) + ? (int) ini_get('session.gc_maxlifetime') + : $config->expiration; + // Add sessionCookieName for multiple session cookies. + $this->keyPrefix .= $config->cookieName . ':'; $this->setSavePath(); diff --git a/system/Session/Session.php b/system/Session/Session.php index efbd67d513ee..fcdaee1ebce8 100644 --- a/system/Session/Session.php +++ b/system/Session/Session.php @@ -43,6 +43,8 @@ class Session implements SessionInterface * The storage driver to use: files, database, redis, memcached * * @var string + * + * @deprecated Use $this->config->driver. */ protected $sessionDriverName; @@ -50,6 +52,8 @@ class Session implements SessionInterface * The session cookie name, must contain only [0-9a-z_-] characters. * * @var string + * + * @deprecated Use $this->config->cookieName. */ protected $sessionCookieName = 'ci_session'; @@ -58,11 +62,13 @@ class Session implements SessionInterface * Setting it to 0 (zero) means expire when the browser is closed. * * @var int + * + * @deprecated Use $this->config->expiration. */ protected $sessionExpiration = 7200; /** - * The location to save sessions to, driver dependent.. + * The location to save sessions to, driver dependent. * * For the 'files' driver, it's a path to a writable directory. * WARNING: Only absolute paths are supported! @@ -74,6 +80,8 @@ class Session implements SessionInterface * IMPORTANT: You are REQUIRED to set a valid save path! * * @var string + * + * @deprecated Use $this->config->savePath. */ protected $sessionSavePath; @@ -84,6 +92,8 @@ class Session implements SessionInterface * your session table's PRIMARY KEY when changing this setting. * * @var bool + * + * @deprecated Use $this->config->matchIP. */ protected $sessionMatchIP = false; @@ -91,6 +101,8 @@ class Session implements SessionInterface * How many seconds between CI regenerating the session ID. * * @var int + * + * @deprecated Use $this->config->timeToUpdate. */ protected $sessionTimeToUpdate = 300; @@ -100,6 +112,8 @@ class Session implements SessionInterface * will be later deleted by the garbage collector. * * @var bool + * + * @deprecated Use $this->config->regenerateDestroy. */ protected $sessionRegenerateDestroy = false; @@ -156,54 +170,31 @@ class Session implements SessionInterface */ protected $sidRegexp; + /** + * Session Config + */ + protected SessionConfig $config; + /** * Constructor. * * Extract configuration settings and save them here. */ - public function __construct(SessionHandlerInterface $driver, App $config) + public function __construct(SessionHandlerInterface $driver, SessionConfig $config) { $this->driver = $driver; - /** @var SessionConfig|null $session */ - $session = config(SessionConfig::class); - - // Store Session configurations - if ($session instanceof SessionConfig) { - $this->sessionDriverName = $session->driver; - $this->sessionCookieName = $session->cookieName ?? $this->sessionCookieName; - $this->sessionExpiration = $session->expiration ?? $this->sessionExpiration; - $this->sessionSavePath = $session->savePath; - $this->sessionMatchIP = $session->matchIP ?? $this->sessionMatchIP; - $this->sessionTimeToUpdate = $session->timeToUpdate ?? $this->sessionTimeToUpdate; - $this->sessionRegenerateDestroy = $session->regenerateDestroy ?? $this->sessionRegenerateDestroy; - } else { - // `Config/Session.php` is absence - $this->sessionDriverName = $config->sessionDriver; - $this->sessionCookieName = $config->sessionCookieName ?? $this->sessionCookieName; - $this->sessionExpiration = $config->sessionExpiration ?? $this->sessionExpiration; - $this->sessionSavePath = $config->sessionSavePath; - $this->sessionMatchIP = $config->sessionMatchIP ?? $this->sessionMatchIP; - $this->sessionTimeToUpdate = $config->sessionTimeToUpdate ?? $this->sessionTimeToUpdate; - $this->sessionRegenerateDestroy = $config->sessionRegenerateDestroy ?? $this->sessionRegenerateDestroy; - } - - // DEPRECATED COOKIE MANAGEMENT - $this->cookiePath = $config->cookiePath ?? $this->cookiePath; - $this->cookieDomain = $config->cookieDomain ?? $this->cookieDomain; - $this->cookieSecure = $config->cookieSecure ?? $this->cookieSecure; - $this->cookieSameSite = $config->cookieSameSite ?? $this->cookieSameSite; - - /** @var CookieConfig $cookie */ + $this->config = $config; + $cookie = config(CookieConfig::class); - $this->cookie = (new Cookie($this->sessionCookieName, '', [ - 'expires' => $this->sessionExpiration === 0 ? 0 : Time::now()->getTimestamp() + $this->sessionExpiration, - 'path' => $cookie->path ?? $config->cookiePath, - 'domain' => $cookie->domain ?? $config->cookieDomain, - 'secure' => $cookie->secure ?? $config->cookieSecure, + $this->cookie = (new Cookie($this->config->cookieName, '', [ + 'expires' => $this->config->expiration === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expiration, + 'path' => $cookie->path, + 'domain' => $cookie->domain, + 'secure' => $cookie->secure, 'httponly' => true, // for security - 'samesite' => $cookie->samesite ?? $config->cookieSameSite ?? Cookie::SAMESITE_LAX, + 'samesite' => $cookie->samesite ?? Cookie::SAMESITE_LAX, 'raw' => $cookie->raw ?? false, ]))->withPrefix(''); // Cookie prefix should be ignored. @@ -241,32 +232,32 @@ public function start() $this->setSaveHandler(); // Sanitize the cookie, because apparently PHP doesn't do that for userspace handlers - if (isset($_COOKIE[$this->sessionCookieName]) - && (! is_string($_COOKIE[$this->sessionCookieName]) || ! preg_match('#\A' . $this->sidRegexp . '\z#', $_COOKIE[$this->sessionCookieName])) + if (isset($_COOKIE[$this->config->cookieName]) + && (! is_string($_COOKIE[$this->config->cookieName]) || ! preg_match('#\A' . $this->sidRegexp . '\z#', $_COOKIE[$this->config->cookieName])) ) { - unset($_COOKIE[$this->sessionCookieName]); + unset($_COOKIE[$this->config->cookieName]); } $this->startSession(); // Is session ID auto-regeneration configured? (ignoring ajax requests) if ((empty($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') - && ($regenerateTime = $this->sessionTimeToUpdate) > 0 + && ($regenerateTime = $this->config->timeToUpdate) > 0 ) { if (! isset($_SESSION['__ci_last_regenerate'])) { $_SESSION['__ci_last_regenerate'] = Time::now()->getTimestamp(); } elseif ($_SESSION['__ci_last_regenerate'] < (Time::now()->getTimestamp() - $regenerateTime)) { - $this->regenerate((bool) $this->sessionRegenerateDestroy); + $this->regenerate((bool) $this->config->regenerateDestroy); } } // Another work-around ... PHP doesn't seem to send the session cookie // unless it is being currently created or regenerated - elseif (isset($_COOKIE[$this->sessionCookieName]) && $_COOKIE[$this->sessionCookieName] === session_id()) { + elseif (isset($_COOKIE[$this->config->cookieName]) && $_COOKIE[$this->config->cookieName] === session_id()) { $this->setCookie(); } $this->initVars(); - $this->logger->info("Session: Class initialized using '" . $this->sessionDriverName . "' driver."); + $this->logger->info("Session: Class initialized using '" . $this->config->driver . "' driver."); return $this; } @@ -288,16 +279,12 @@ public function stop() */ protected function configure() { - if (empty($this->sessionCookieName)) { - $this->sessionCookieName = ini_get('session.name'); - } else { - ini_set('session.name', $this->sessionCookieName); - } + ini_set('session.name', $this->config->cookieName); $sameSite = $this->cookie->getSameSite() ?: ucfirst(Cookie::SAMESITE_LAX); $params = [ - 'lifetime' => $this->sessionExpiration, + 'lifetime' => $this->config->expiration, 'path' => $this->cookie->getPath(), 'domain' => $this->cookie->getDomain(), 'secure' => $this->cookie->isSecure(), @@ -308,14 +295,12 @@ protected function configure() ini_set('session.cookie_samesite', $sameSite); session_set_cookie_params($params); - if (! isset($this->sessionExpiration)) { - $this->sessionExpiration = (int) ini_get('session.gc_maxlifetime'); - } elseif ($this->sessionExpiration > 0) { - ini_set('session.gc_maxlifetime', (string) $this->sessionExpiration); + if ($this->config->expiration > 0) { + ini_set('session.gc_maxlifetime', (string) $this->config->expiration); } - if (! empty($this->sessionSavePath)) { - ini_set('session.save_path', $this->sessionSavePath); + if (! empty($this->config->savePath)) { + ini_set('session.save_path', $this->config->savePath); } // Security is king @@ -422,12 +407,12 @@ private function removeOldSessionCookie(): void $response = Services::response(); $cookieStoreInResponse = $response->getCookieStore(); - if (! $cookieStoreInResponse->has($this->sessionCookieName)) { + if (! $cookieStoreInResponse->has($this->config->cookieName)) { return; } // CookieStore is immutable. - $newCookieStore = $cookieStoreInResponse->remove($this->sessionCookieName); + $newCookieStore = $cookieStoreInResponse->remove($this->config->cookieName); // But clear() method clears cookies in the object (not immutable). $cookieStoreInResponse->clear(); @@ -449,6 +434,20 @@ public function destroy() session_destroy(); } + /** + * Writes session data and close the current session. + * + * @return void + */ + public function close() + { + if (ENVIRONMENT === 'testing') { + return; + } + + session_write_close(); + } + /** * Sets user data into the session. * @@ -927,7 +926,7 @@ protected function startSession() */ protected function setCookie() { - $expiration = $this->sessionExpiration === 0 ? 0 : Time::now()->getTimestamp() + $this->sessionExpiration; + $expiration = $this->config->expiration === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expiration; $this->cookie = $this->cookie->withValue(session_id())->withExpires($expiration); $response = Services::response(); diff --git a/system/Superglobals.php b/system/Superglobals.php new file mode 100644 index 000000000000..b126af130f34 --- /dev/null +++ b/system/Superglobals.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter; + +/** + * Superglobals manipulation. + * + * @internal + */ +final class Superglobals +{ + private array $server; + private array $get; + + public function __construct(?array $server = null, ?array $get = null) + { + $this->server = $server ?? $_SERVER; + $this->get = $get ?? $_GET; + } + + public function server(string $key): ?string + { + return $this->server[$key] ?? null; + } + + public function setServer(string $key, string $value): void + { + $this->server[$key] = $value; + $_SERVER[$key] = $value; + } + + /** + * @return array|string|null + */ + public function get(string $key) + { + return $this->get[$key] ?? null; + } + + public function setGet(string $key, string $value): void + { + $this->get[$key] = $value; + $_GET[$key] = $value; + } + + public function setGetArray(array $array): void + { + $this->get = $array; + $_GET = $array; + } +} diff --git a/system/Test/CIUnitTestCase.php b/system/Test/CIUnitTestCase.php index 0ed50a500c55..a0c84c48f2b7 100644 --- a/system/Test/CIUnitTestCase.php +++ b/system/Test/CIUnitTestCase.php @@ -28,6 +28,7 @@ use Config\Email; use Config\Modules; use Config\Services; +use Config\Session; use Exception; use PHPUnit\Framework\TestCase; @@ -334,7 +335,7 @@ protected function mockSession() { $_SESSION = []; - $config = config(App::class); + $config = config(Session::class); $session = new MockSession(new ArrayHandler($config, '0.0.0.0'), $config); Services::injectMock('session', $session); diff --git a/system/Test/ControllerTestTrait.php b/system/Test/ControllerTestTrait.php index 828dabb9e946..7e4091be6175 100644 --- a/system/Test/ControllerTestTrait.php +++ b/system/Test/ControllerTestTrait.php @@ -100,7 +100,8 @@ protected function setUpControllerTestTrait(): void } if (! $this->uri instanceof URI) { - $this->uri = Services::uri($this->appConfig->baseURL ?? 'http://example.com/', false); + $factory = Services::siteurifactory($this->appConfig, Services::superglobals(), false); + $this->uri = $factory->createFromGlobals(); } if (empty($this->request)) { @@ -277,7 +278,13 @@ public function withLogger($logger) */ public function withUri(string $uri) { - $this->uri = new URI($uri); + $factory = Services::siteurifactory(); + $this->uri = $factory->createFromString($uri); + Services::injectMock('uri', $this->uri); + + // Update the Request instance, because Request has the SiteURI instance. + $this->request = Services::incomingrequest(null, false); + Services::injectMock('request', $this->request); return $this; } diff --git a/system/Test/FeatureTestCase.php b/system/Test/FeatureTestCase.php index 0bef873297e4..371e302c59ae 100644 --- a/system/Test/FeatureTestCase.php +++ b/system/Test/FeatureTestCase.php @@ -12,11 +12,12 @@ namespace CodeIgniter\Test; use CodeIgniter\Events\Events; +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Request; use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\UserAgent; -use CodeIgniter\Router\Exceptions\RedirectException; use CodeIgniter\Router\RouteCollection; use Config\App; use Config\Services; @@ -328,11 +329,13 @@ protected function setupHeaders(IncomingRequest $request) * * Always populate the GET vars based on the URI. * - * @return Request + * @param CLIRequest|IncomingRequest $request + * + * @return CLIRequest|IncomingRequest * * @throws ReflectionException */ - protected function populateGlobals(string $method, Request $request, ?array $params = null) + protected function populateGlobals(string $method, $request, ?array $params = null) { // $params should set the query vars if present, // otherwise set it from the URL. @@ -357,10 +360,13 @@ protected function populateGlobals(string $method, Request $request, ?array $par * This allows the body to be formatted in a way that the controller is going to * expect as in the case of testing a JSON or XML API. * - * @param array|null $params The parameters to be formatted and put in the body. If this is empty, it will get the - * what has been loaded into the request global of the request class. + * @param CLIRequest|IncomingRequest $request + * @param array|null $params The parameters to be formatted and put in the body. If this is empty, it will get the + * what has been loaded into the request global of the request class. + * + * @return CLIRequest|IncomingRequest */ - protected function setRequestBody(Request $request, ?array $params = null): Request + protected function setRequestBody($request, ?array $params = null) { if (isset($this->requestBody) && $this->requestBody !== '') { $request->setBody($this->requestBody); diff --git a/system/Test/FeatureTestTrait.php b/system/Test/FeatureTestTrait.php index 74529e77abca..b117a1d890ae 100644 --- a/system/Test/FeatureTestTrait.php +++ b/system/Test/FeatureTestTrait.php @@ -12,11 +12,11 @@ namespace CodeIgniter\Test; use CodeIgniter\Events\Events; +use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Request; +use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\URI; -use CodeIgniter\Router\Exceptions\RedirectException; -use CodeIgniter\Router\RouteCollection; use Config\App; use Config\Services; use Exception; @@ -143,14 +143,6 @@ public function skipEvents() */ public function call(string $method, string $path, ?array $params = null) { - $buffer = \ob_get_level(); - - // Clean up any open output buffers - // not relevant to unit testing - if (\ob_get_level() > 0 && (! isset($this->clean) || $this->clean === true)) { - \ob_end_clean(); // @codeCoverageIgnore - } - // Simulate having a blank session $_SESSION = []; $_SERVER['REQUEST_METHOD'] = $method; @@ -182,29 +174,17 @@ public function call(string $method, string $path, ?array $params = null) ->setRequest($request) ->run($routes, true); - $output = \ob_get_contents(); - if (empty($response->getBody()) && ! empty($output)) { - $response->setBody($output); - } - // Reset directory if it has been set Services::router()->setDirectory(null); - // Ensure the output buffer is identical so no tests are risky - while (\ob_get_level() > $buffer) { - \ob_end_clean(); // @codeCoverageIgnore - } - - while (\ob_get_level() < $buffer) { - \ob_start(); // @codeCoverageIgnore - } - return new TestResponse($response); } /** * Performs a GET request. * + * @param string $path URI path relative to baseURL. May include query. + * * @return TestResponse * * @throws RedirectException @@ -288,15 +268,25 @@ public function options(string $path, ?array $params = null) */ protected function setupRequest(string $method, ?string $path = null): IncomingRequest { - $path = URI::removeDotSegments($path); - $config = config(App::class); - $request = Services::request($config, false); + $config = config(App::class); + $uri = new SiteURI($config); // $path may have a query in it - $parts = explode('?', $path); - $_SERVER['QUERY_STRING'] = $parts[1] ?? ''; + $path = URI::removeDotSegments($path); + $parts = explode('?', $path); + $path = $parts[0]; + $query = $parts[1] ?? ''; + + $superglobals = Services::superglobals(); + $superglobals->setServer('QUERY_STRING', $query); + + $uri->setPath($path); + $uri->setQuery($query); + + Services::injectMock('uri', $uri); + + $request = Services::request($config, false); - $request->setPath($parts[0]); $request->setMethod($method); $request->setProtocolVersion('1.1'); diff --git a/system/Test/Mock/MockAppConfig.php b/system/Test/Mock/MockAppConfig.php index e21f7a6b7adf..0f1d5bf59975 100644 --- a/system/Test/Mock/MockAppConfig.php +++ b/system/Test/Mock/MockAppConfig.php @@ -17,21 +17,7 @@ class MockAppConfig extends App { public string $baseURL = 'http://example.com/'; public string $uriProtocol = 'REQUEST_URI'; - public string $cookiePrefix = ''; - public string $cookieDomain = ''; - public string $cookiePath = '/'; - public bool $cookieSecure = false; - public bool $cookieHTTPOnly = false; - public ?string $cookieSameSite = 'Lax'; public array $proxyIPs = []; - public string $CSRFTokenName = 'csrf_test_name'; - public string $CSRFHeaderName = 'X-CSRF-TOKEN'; - public string $CSRFCookieName = 'csrf_cookie_name'; - public int $CSRFExpire = 7200; - public bool $CSRFRegenerate = true; - public array $CSRFExcludeURIs = ['http://example.com']; - public bool $CSRFRedirect = false; - public string $CSRFSameSite = 'Lax'; public bool $CSPEnabled = false; public string $defaultLocale = 'en'; public bool $negotiateLocale = false; diff --git a/system/Test/Mock/MockCLIConfig.php b/system/Test/Mock/MockCLIConfig.php index 261284887d8d..a1756f41e248 100644 --- a/system/Test/Mock/MockCLIConfig.php +++ b/system/Test/Mock/MockCLIConfig.php @@ -17,12 +17,6 @@ class MockCLIConfig extends App { public string $baseURL = 'http://example.com/'; public string $uriProtocol = 'REQUEST_URI'; - public string $cookiePrefix = ''; - public string $cookieDomain = ''; - public string $cookiePath = '/'; - public bool $cookieSecure = false; - public bool $cookieHTTPOnly = false; - public ?string $cookieSameSite = 'Lax'; public array $proxyIPs = []; public string $CSRFTokenName = 'csrf_test_name'; public string $CSRFCookieName = 'csrf_cookie_name'; diff --git a/system/Test/Mock/MockCodeIgniter.php b/system/Test/Mock/MockCodeIgniter.php index e6d6ac0f6c61..200c92fa0396 100644 --- a/system/Test/Mock/MockCodeIgniter.php +++ b/system/Test/Mock/MockCodeIgniter.php @@ -17,6 +17,11 @@ class MockCodeIgniter extends CodeIgniter { protected ?string $context = 'web'; + /** + * @param int $code + * + * @deprecated 4.4.0 No longer Used. Moved to index.php. + */ protected function callExit($code) { // Do not call exit() in testing. diff --git a/system/Test/Mock/MockSession.php b/system/Test/Mock/MockSession.php index f5290f26525d..9f558e1034ad 100644 --- a/system/Test/Mock/MockSession.php +++ b/system/Test/Mock/MockSession.php @@ -57,7 +57,7 @@ protected function startSession() */ protected function setCookie() { - $expiration = $this->sessionExpiration === 0 ? 0 : Time::now()->getTimestamp() + $this->sessionExpiration; + $expiration = $this->config->expiration === 0 ? 0 : Time::now()->getTimestamp() + $this->config->expiration; $this->cookie = $this->cookie->withValue(session_id())->withExpires($expiration); $this->cookies[] = $this->cookie; diff --git a/system/Validation/DotArrayFilter.php b/system/Validation/DotArrayFilter.php new file mode 100644 index 000000000000..cead4f6bb414 --- /dev/null +++ b/system/Validation/DotArrayFilter.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Validation; + +final class DotArrayFilter +{ + /** + * Creates a new array with only the elements specified in dot array syntax. + * + * This code comes from the dot_array_search() function. + * + * @param array $indexes The dot array syntax pattern to use for filtering. + * @param array $array The array to filter. + * + * @return array The filtered array. + */ + public static function run(array $indexes, array $array): array + { + $result = []; + + foreach ($indexes as $index) { + // See https://regex101.com/r/44Ipql/1 + $segments = preg_split( + '/(? str_replace('\.', '.', $key), + $segments + ); + + $result = array_merge_recursive($result, self::filter($segments, $array)); + } + + return $result; + } + + /** + * Used by `run()` to recursively filter the array with wildcards. + * + * @param array $indexes The dot array syntax pattern to use for filtering. + * @param array $array The array to filter. + * + * @return array The filtered array. + */ + private static function filter(array $indexes, array $array): array + { + // If index is empty, returns empty array. + if ($indexes === []) { + return []; + } + + // Grab the current index. + $currentIndex = array_shift($indexes); + + if (! isset($array[$currentIndex]) && $currentIndex !== '*') { + return []; + } + + // Handle Wildcard (*) + if ($currentIndex === '*') { + $answer = []; + + foreach ($array as $key => $value) { + if (! is_array($value)) { + continue; + } + + $result = self::filter($indexes, $value); + + if ($result !== []) { + $answer[$key] = $result; + } + } + + return $answer; + } + + // If this is the last index, make sure to return it now, + // and not try to recurse through things. + if (empty($indexes)) { + return [$currentIndex => $array[$currentIndex]]; + } + + // Do we need to recursively filter this value? + if (is_array($array[$currentIndex]) && $array[$currentIndex] !== []) { + $result = self::filter($indexes, $array[$currentIndex]); + + if ($result !== []) { + return [$currentIndex => $result]; + } + } + + // Otherwise, not found. + return []; + } +} diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 4ed4d089c4cb..bfb89cef5649 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -65,6 +65,13 @@ class Validation implements ValidationInterface */ protected $data = []; + /** + * The data that was actually validated. + * + * @var array + */ + protected $validated = []; + /** * Any generated errors during validation. * 'key' is the alias, 'value' is the message. @@ -124,7 +131,12 @@ public function __construct($config, RendererInterface $view) */ public function run(?array $data = null, ?string $group = null, ?string $dbGroup = null): bool { - $data ??= $this->data; + if ($data === null) { + $data = $this->data; + } else { + // Store data to validate. + $this->data = $data; + } // `DBGroup` is a reserved name. For is_unique and is_not_unique $data['DBGroup'] = $dbGroup; @@ -184,16 +196,26 @@ public function run(?array $data = null, ?string $group = null, ?string $dbGroup } } - return $this->getErrors() === []; + if ($this->getErrors() === []) { + // Store data that was actually validated. + $this->validated = DotArrayFilter::run( + array_keys($this->rules), + $this->data + ); + + return true; + } + + return false; } /** * Runs the validation process, returning true or false determining whether * validation was successful or not. * - * @param array|bool|float|int|object|string|null $value - * @param array|string $rules - * @param string[] $errors + * @param array|bool|float|int|object|string|null $value The data to validate. + * @param array|string $rules The validation rules. + * @param string[] $errors The custom error message. * @param string|null $dbGroup The database group to use. */ public function check($value, $rules, array $errors = [], $dbGroup = null): bool @@ -212,6 +234,14 @@ public function check($value, $rules, array $errors = [], $dbGroup = null): bool ); } + /** + * Returns the actual validated data. + */ + public function getValidated(): array + { + return $this->validated; + } + /** * Runs all of $rules against $field, until one fails, or * all of them have been processed. If one fails, it adds @@ -473,7 +503,8 @@ public function withRequest(RequestInterface $request): ValidationInterface * 'rule2' => 'message2', * ] * - * @param array|string $rules + * @param array|string $rules The validation rules. + * @param array $errors The custom error message. * * @return $this * @@ -917,6 +948,7 @@ protected function splitRules(string $rules): array public function reset(): ValidationInterface { $this->data = []; + $this->validated = []; $this->rules = []; $this->errors = []; $this->customErrors = []; diff --git a/system/Validation/ValidationInterface.php b/system/Validation/ValidationInterface.php index ef4004f43a96..78c38468ddd0 100644 --- a/system/Validation/ValidationInterface.php +++ b/system/Validation/ValidationInterface.php @@ -152,4 +152,9 @@ public function listErrors(string $template = 'list'): string; * Displays a single error in formatted HTML as defined in the $template view. */ public function showError(string $field, string $template = 'single'): string; + + /** + * Returns the actual validated data. + */ + public function getValidated(): array; } diff --git a/system/View/Table.php b/system/View/Table.php index bb1ef5dd858d..e2bacac17938 100644 --- a/system/View/Table.php +++ b/system/View/Table.php @@ -83,6 +83,11 @@ class Table */ public $function; + /** + * Order each inserted row by heading keys + */ + private bool $syncRowsWithHeading = false; + /** * Set the template from the table config file if it exists * @@ -161,7 +166,8 @@ public function makeColumns($array = [], $columnLimit = 0) // Turn off the auto-heading feature since it's doubtful we // will want headings from a one-dimensional array - $this->autoHeading = false; + $this->autoHeading = false; + $this->syncRowsWithHeading = false; if ($columnLimit === 0) { return $array; @@ -207,7 +213,40 @@ public function setEmpty($value) */ public function addRow() { - $this->rows[] = $this->_prepArgs(func_get_args()); + $tmpRow = $this->_prepArgs(func_get_args()); + + if ($this->syncRowsWithHeading && ! empty($this->heading)) { + // each key has an index + $keyIndex = array_flip(array_keys($this->heading)); + + // figure out which keys need to be added + $missingKeys = array_diff_key($keyIndex, $tmpRow); + + // Remove all keys which don't exist in $keyIndex + $tmpRow = array_filter($tmpRow, static fn ($k) => array_key_exists($k, $keyIndex), ARRAY_FILTER_USE_KEY); + + // add missing keys to row, but use $this->emptyCells + $tmpRow = array_merge($tmpRow, array_map(fn ($v) => ['data' => $this->emptyCells], $missingKeys)); + + // order keys by $keyIndex values + uksort($tmpRow, static fn ($k1, $k2) => $keyIndex[$k1] <=> $keyIndex[$k2]); + } + $this->rows[] = $tmpRow; + + return $this; + } + + /** + * Set to true if each row column should be synced by keys defined in heading. + * + * If a row has a key which does not exist in heading, it will be filtered out + * If a row does not have a key which exists in heading, the field will stay empty + * + * @return $this + */ + public function setSyncRowsWithHeading(bool $orderByKey) + { + $this->syncRowsWithHeading = $orderByKey; return $this; } @@ -440,7 +479,7 @@ protected function _setFromArray($data) } foreach ($data as &$row) { - $this->rows[] = $this->_prepArgs($row); + $this->addRow($row); } } diff --git a/system/View/View.php b/system/View/View.php index 63969f3bd184..6c633482fcce 100644 --- a/system/View/View.php +++ b/system/View/View.php @@ -436,9 +436,12 @@ public function endSection() /** * Renders a section's contents. * + * @param bool $saveData If true, saves data for subsequent calls, + * if false, cleans the data after displaying. + * * @return void */ - public function renderSection(string $sectionName) + public function renderSection(string $sectionName, bool $saveData = false) { if (! isset($this->sections[$sectionName])) { echo ''; @@ -448,7 +451,9 @@ public function renderSection(string $sectionName) foreach ($this->sections[$sectionName] as $key => $contents) { echo $contents; - unset($this->sections[$sectionName][$key]); + if ($saveData === false) { + unset($this->sections[$sectionName][$key]); + } } } diff --git a/tests/_support/Config/Routes.php b/tests/_support/Config/Routes.php index ea288729aa9d..4ba439d4d730 100644 --- a/tests/_support/Config/Routes.php +++ b/tests/_support/Config/Routes.php @@ -14,3 +14,6 @@ // This is a simple file to include for testing the RouteCollection class. $routes->add('testing', 'TestController::index', ['as' => 'testing-index']); $routes->get('closure', static fn () => 'closure test'); +$routes->get('/', 'Blog::index', ['hostname' => 'blog.example.com']); +$routes->get('/', 'Sub::index', ['subdomain' => 'sub']); +$routes->get('/all', 'AllDomain::index', ['subdomain' => '*']); diff --git a/tests/_support/Config/Services.php b/tests/_support/Config/Services.php index 4a942a053433..e051ea8d6b24 100644 --- a/tests/_support/Config/Services.php +++ b/tests/_support/Config/Services.php @@ -11,7 +11,9 @@ namespace Tests\Support\Config; +use CodeIgniter\HTTP\SiteURIFactory; use CodeIgniter\HTTP\URI; +use Config\App; use Config\Services as BaseServices; use RuntimeException; @@ -41,6 +43,13 @@ public static function uri(?string $uri = null, bool $getShared = true) return static::getSharedInstance('uri', $uri); } + if ($uri === null) { + $appConfig = config(App::class); + $factory = new SiteURIFactory($appConfig, Services::superglobals()); + + return $factory->createFromGlobals(); + } + return new URI($uri); } } diff --git a/tests/_support/Controllers/Newautorouting.php b/tests/_support/Controllers/Newautorouting.php index f3c8479f3685..2c31547ed2de 100644 --- a/tests/_support/Controllers/Newautorouting.php +++ b/tests/_support/Controllers/Newautorouting.php @@ -15,7 +15,7 @@ class Newautorouting extends Controller { - public function getIndex() + public function getIndex(string $m = '') { return 'Hello'; } diff --git a/tests/system/API/ResponseTraitTest.php b/tests/system/API/ResponseTraitTest.php index 693eff53f33d..c867f1b4a94e 100644 --- a/tests/system/API/ResponseTraitTest.php +++ b/tests/system/API/ResponseTraitTest.php @@ -11,6 +11,7 @@ namespace CodeIgniter\API; +use CodeIgniter\Config\Factories; use CodeIgniter\Format\FormatterInterface; use CodeIgniter\Format\JSONFormatter; use CodeIgniter\Format\XMLFormatter; @@ -20,6 +21,7 @@ use CodeIgniter\Test\Mock\MockIncomingRequest; use CodeIgniter\Test\Mock\MockResponse; use Config\App; +use Config\Cookie; use stdClass; /** @@ -51,17 +53,25 @@ protected function makeController(array $userConfig = [], string $uri = 'http:// 'negotiateLocale' => false, 'supportedLocales' => ['en'], 'CSPEnabled' => false, - 'cookiePrefix' => '', - 'cookieDomain' => '', - 'cookiePath' => '/', - 'cookieSecure' => false, - 'cookieHTTPOnly' => false, 'proxyIPs' => [], - 'cookieSameSite' => 'Lax', ] as $key => $value) { $config->{$key} = $value; } + $cookie = new Cookie(); + + foreach ([ + 'prefix' => '', + 'domain' => '', + 'path' => '/', + 'secure' => false, + 'httponly' => false, + 'samesite' => 'Lax', + ] as $key => $value) { + $cookie->{$key} = $value; + } + Factories::injectMock('config', 'Cookie', $cookie); + if ($this->request === null) { $this->request = new MockIncomingRequest($config, new URI($uri), null, new UserAgent()); $this->response = new MockResponse($config); @@ -525,17 +535,25 @@ public function testFormatByRequestNegotiateIfFormatIsNotJsonOrXML(): void 'negotiateLocale' => false, 'supportedLocales' => ['en'], 'CSPEnabled' => false, - 'cookiePrefix' => '', - 'cookieDomain' => '', - 'cookiePath' => '/', - 'cookieSecure' => false, - 'cookieHTTPOnly' => false, 'proxyIPs' => [], - 'cookieSameSite' => 'Lax', ] as $key => $value) { $config->{$key} = $value; } + $cookie = new Cookie(); + + foreach ([ + 'prefix' => '', + 'domain' => '', + 'path' => '/', + 'secure' => false, + 'httponly' => false, + 'samesite' => 'Lax', + ] as $key => $value) { + $cookie->{$key} = $value; + } + Factories::injectMock('config', 'Cookie', $cookie); + $request = new MockIncomingRequest($config, new URI($config->baseURL), null, new UserAgent()); $response = new MockResponse($config); diff --git a/tests/system/Autoloader/AutoloaderTest.php b/tests/system/Autoloader/AutoloaderTest.php index a116bc9515d6..4c81537dbf09 100644 --- a/tests/system/Autoloader/AutoloaderTest.php +++ b/tests/system/Autoloader/AutoloaderTest.php @@ -12,8 +12,10 @@ namespace CodeIgniter\Autoloader; use App\Controllers\Home; +use Closure; use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\ReflectionHelper; use Config\Autoload; use Config\Modules; use Config\Services; @@ -28,8 +30,15 @@ */ final class AutoloaderTest extends CIUnitTestCase { + use ReflectionHelper; + private Autoloader $loader; + /** + * @phpstan-var Closure(string): (false|string) + */ + private Closure $classLoader; + protected function setUp(): void { parent::setUp(); @@ -50,13 +59,15 @@ protected function setUp(): void $this->loader = new Autoloader(); $this->loader->initialize($config, $modules)->register(); + + $this->classLoader = $this->getPrivateMethodInvoker($this->loader, 'loadInNamespace'); } protected function tearDown(): void { - $this->loader->unregister(); - parent::tearDown(); + + $this->loader->unregister(); } public function testLoadStoredClass(): void @@ -96,9 +107,10 @@ public function testInitializeTwice(): void public function testServiceAutoLoaderFromShareInstances(): void { - $autoloader = Services::autoloader(); + $classLoader = $this->getPrivateMethodInvoker(Services::autoloader(), 'loadInNamespace'); + // look for Home controller, as that should be in base repo - $actual = $autoloader->loadClass(Home::class); + $actual = $classLoader(Home::class); $expected = APPPATH . 'Controllers' . DIRECTORY_SEPARATOR . 'Home.php'; $this->assertSame($expected, realpath($actual) ?: $actual); } @@ -109,8 +121,10 @@ public function testServiceAutoLoader(): void $autoloader->initialize(new Autoload(), new Modules()); $autoloader->register(); + $classLoader = $this->getPrivateMethodInvoker($autoloader, 'loadInNamespace'); + // look for Home controller, as that should be in base repo - $actual = $autoloader->loadClass(Home::class); + $actual = $classLoader(Home::class); $expected = APPPATH . 'Controllers' . DIRECTORY_SEPARATOR . 'Home.php'; $this->assertSame($expected, realpath($actual) ?: $actual); @@ -119,41 +133,34 @@ public function testServiceAutoLoader(): void public function testExistingFile(): void { - $actual = $this->loader->loadClass(Home::class); + $actual = ($this->classLoader)(Home::class); $expected = APPPATH . 'Controllers' . DIRECTORY_SEPARATOR . 'Home.php'; $this->assertSame($expected, $actual); - $actual = $this->loader->loadClass('CodeIgniter\Helpers\array_helper'); + $actual = ($this->classLoader)('CodeIgniter\Helpers\array_helper'); $expected = SYSTEMPATH . 'Helpers' . DIRECTORY_SEPARATOR . 'array_helper.php'; $this->assertSame($expected, $actual); } public function testMatchesWithPrecedingSlash(): void { - $actual = $this->loader->loadClass(Home::class); - $expected = APPPATH . 'Controllers' . DIRECTORY_SEPARATOR . 'Home.php'; - $this->assertSame($expected, $actual); - } - - public function testMatchesWithFileExtension(): void - { - $actual = $this->loader->loadClass('\App\Controllers\Home.php'); + $actual = ($this->classLoader)(Home::class); $expected = APPPATH . 'Controllers' . DIRECTORY_SEPARATOR . 'Home.php'; $this->assertSame($expected, $actual); } public function testMissingFile(): void { - $this->assertFalse($this->loader->loadClass('\App\Missing\Classname')); + $this->assertFalse(($this->classLoader)('\App\Missing\Classname')); } public function testAddNamespaceWorks(): void { - $this->assertFalse($this->loader->loadClass('My\App\Class')); + $this->assertFalse(($this->classLoader)('My\App\Class')); $this->loader->addNamespace('My\App', __DIR__); - $actual = $this->loader->loadClass('My\App\AutoloaderTest'); + $actual = ($this->classLoader)('My\App\AutoloaderTest'); $expected = __FILE__; $this->assertSame($expected, $actual); @@ -168,11 +175,11 @@ public function testAddNamespaceMultiplePathsWorks(): void ], ]); - $actual = $this->loader->loadClass('My\App\App'); + $actual = ($this->classLoader)('My\App\App'); $expected = APPPATH . 'Config' . DIRECTORY_SEPARATOR . 'App.php'; $this->assertSame($expected, $actual); - $actual = $this->loader->loadClass('My\App\AutoloaderTest'); + $actual = ($this->classLoader)('My\App\AutoloaderTest'); $expected = __FILE__; $this->assertSame($expected, $actual); } @@ -183,7 +190,7 @@ public function testAddNamespaceStringToArray(): void $this->assertSame( __FILE__, - $this->loader->loadClass('App\Controllers\AutoloaderTest') + ($this->classLoader)('App\Controllers\AutoloaderTest') ); } @@ -201,15 +208,15 @@ public function testGetNamespaceGivesArray(): void public function testRemoveNamespace(): void { $this->loader->addNamespace('My\App', __DIR__); - $this->assertSame(__FILE__, $this->loader->loadClass('My\App\AutoloaderTest')); + $this->assertSame(__FILE__, ($this->classLoader)('My\App\AutoloaderTest')); $this->loader->removeNamespace('My\App'); - $this->assertFalse((bool) $this->loader->loadClass('My\App\AutoloaderTest')); + $this->assertFalse(($this->classLoader)('My\App\AutoloaderTest')); } public function testloadClassNonNamespaced(): void { - $this->assertFalse($this->loader->loadClass('Modules')); + $this->assertFalse(($this->classLoader)('Modules')); } public function testSanitizationContailsSpecialChars(): void diff --git a/tests/system/Cache/FactoriesCacheFileHandlerTest.php b/tests/system/Cache/FactoriesCacheFileHandlerTest.php new file mode 100644 index 000000000000..b5434bbb1b1b --- /dev/null +++ b/tests/system/Cache/FactoriesCacheFileHandlerTest.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +use Config\Cache as CacheConfig; + +/** + * @internal + * + * @group Others + */ +final class FactoriesCacheFileHandlerTest extends FactoriesCacheFileVarExportHandlerTest +{ + /** + * @var @var FileVarExportHandler|CacheInterface + */ + protected $handler; + + protected function createFactoriesCache(): void + { + $this->handler = CacheFactory::getHandler(new CacheConfig(), 'file'); + $this->cache = new FactoriesCache($this->handler); + } +} diff --git a/tests/system/Cache/FactoriesCacheFileVarExportHandlerTest.php b/tests/system/Cache/FactoriesCacheFileVarExportHandlerTest.php new file mode 100644 index 000000000000..a3aff35b000e --- /dev/null +++ b/tests/system/Cache/FactoriesCacheFileVarExportHandlerTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +use CodeIgniter\Cache\FactoriesCache\FileVarExportHandler; +use CodeIgniter\Config\Factories; +use CodeIgniter\Test\CIUnitTestCase; +use Config\App; +use Config\Modules; + +/** + * @internal + * @no-final + * + * @group Others + */ +class FactoriesCacheFileVarExportHandlerTest extends CIUnitTestCase +{ + protected FactoriesCache $cache; + + /** + * @var CacheInterface|FileVarExportHandler + */ + protected $handler; + + protected function createFactoriesCache(): void + { + $this->handler = new FileVarExportHandler(); + $this->cache = new FactoriesCache($this->handler); + } + + public function testInstantiate() + { + $this->createFactoriesCache(); + + $this->assertInstanceOf(FactoriesCache::class, $this->cache); + } + + public function testSave() + { + Factories::reset(); + Factories::config('App'); + + $this->createFactoriesCache(); + + $this->cache->save('config'); + + $cachedData = $this->handler->get('FactoriesCache_config'); + + $this->assertArrayHasKey('aliases', $cachedData); + $this->assertArrayHasKey('instances', $cachedData); + $this->assertArrayHasKey(Modules::class, $cachedData['aliases']); + $this->assertArrayHasKey('App', $cachedData['aliases']); + } + + public function testLoad() + { + Factories::reset(); + /** @var App $appConfig */ + $appConfig = Factories::config('App'); + $appConfig->baseURL = 'http://test.example.jp/this-is-test/'; + + $this->createFactoriesCache(); + $this->cache->save('config'); + + Factories::reset(); + + $this->cache->load('config'); + + $appConfig = Factories::config('App'); + $this->assertSame('http://test.example.jp/this-is-test/', $appConfig->baseURL); + } + + public function testDelete() + { + $this->createFactoriesCache(); + + $this->cache->delete('config'); + + $this->assertFalse($this->cache->load('config')); + } +} diff --git a/tests/system/Cache/ResponseCacheTest.php b/tests/system/Cache/ResponseCacheTest.php new file mode 100644 index 000000000000..a1079e63a463 --- /dev/null +++ b/tests/system/Cache/ResponseCacheTest.php @@ -0,0 +1,249 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Cache; + +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\Response; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\URI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Test\CIUnitTestCase; +use Config\App as AppConfig; +use Config\Cache as CacheConfig; +use ErrorException; +use Exception; + +/** + * @backupGlobals enabled + * + * @internal + * + * @group Others + */ +final class ResponseCacheTest extends CIUnitTestCase +{ + private AppConfig $appConfig; + + protected function setUp(): void + { + parent::setUp(); + + $this->appConfig = new AppConfig(); + } + + private function createIncomingRequest( + string $uri = '', + array $query = [], + ?AppConfig $appConfig = null + ): IncomingRequest { + $_POST = $_GET = $_SERVER = $_REQUEST = $_ENV = $_COOKIE = $_SESSION = []; + + $_SERVER['REQUEST_URI'] = '/' . $uri . ($query ? '?' . http_build_query($query) : ''); + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $appConfig ??= $this->appConfig; + + $siteUri = new URI($appConfig->baseURL . $uri); + if ($query !== []) { + $_GET = $_REQUEST = $query; + $siteUri->setQueryArray($query); + } + + return new IncomingRequest( + $appConfig, + $siteUri, + null, + new UserAgent() + ); + } + + /** + * @phpstan-param list $params + */ + private function createCLIRequest(array $params = [], ?AppConfig $appConfig = null): CLIRequest + { + $_POST = $_GET = $_SERVER = $_REQUEST = $_ENV = $_COOKIE = $_SESSION = []; + + $_SERVER['argv'] = ['public/index.php', ...$params]; + $_SERVER['SCRIPT_NAME'] = 'public/index.php'; + + $appConfig ??= $this->appConfig; + + return new CLIRequest($appConfig); + } + + private function createResponseCache(?CacheConfig $cacheConfig = null): ResponseCache + { + $cache = mock(CacheFactory::class); + + $cacheConfig ??= new CacheConfig(); + + return (new ResponseCache($cacheConfig, $cache))->setTtl(300); + } + + public function testCachePageIncomingRequest() + { + $pageCache = $this->createResponseCache(); + + $request = $this->createIncomingRequest('foo/bar'); + + $response = new Response($this->appConfig); + $response->setHeader('ETag', 'abcd1234'); + $response->setBody('The response body.'); + + $return = $pageCache->make($request, $response); + + $this->assertTrue($return); + + // Check cache with a request with the same URI path. + $request = $this->createIncomingRequest('foo/bar'); + $cachedResponse = $pageCache->get($request, new Response($this->appConfig)); + + $this->assertInstanceOf(ResponseInterface::class, $cachedResponse); + $this->assertSame('The response body.', $cachedResponse->getBody()); + $this->assertSame('abcd1234', $cachedResponse->getHeaderLine('ETag')); + + // Check cache with a request with the same URI path and different query string. + $request = $this->createIncomingRequest('foo/bar', ['foo' => 'bar', 'bar' => 'baz']); + $cachedResponse = $pageCache->get($request, new Response($this->appConfig)); + + $this->assertInstanceOf(ResponseInterface::class, $cachedResponse); + $this->assertSame('The response body.', $cachedResponse->getBody()); + $this->assertSame('abcd1234', $cachedResponse->getHeaderLine('ETag')); + + // Check cache with another request with the different URI path. + $request = $this->createIncomingRequest('another'); + + $cachedResponse = $pageCache->get($request, new Response($this->appConfig)); + + $this->assertNull($cachedResponse); + } + + public function testCachePageIncomingRequestWithCacheQueryString() + { + $cacheConfig = new CacheConfig(); + $cacheConfig->cacheQueryString = true; + $pageCache = $this->createResponseCache($cacheConfig); + + $request = $this->createIncomingRequest('foo/bar', ['foo' => 'bar', 'bar' => 'baz']); + + $response = new Response($this->appConfig); + $response->setHeader('ETag', 'abcd1234'); + $response->setBody('The response body.'); + + $return = $pageCache->make($request, $response); + + $this->assertTrue($return); + + // Check cache with a request with the same URI path and same query string. + $this->createIncomingRequest('foo/bar', ['foo' => 'bar', 'bar' => 'baz']); + $cachedResponse = $pageCache->get($request, new Response($this->appConfig)); + + $this->assertInstanceOf(ResponseInterface::class, $cachedResponse); + $this->assertSame('The response body.', $cachedResponse->getBody()); + $this->assertSame('abcd1234', $cachedResponse->getHeaderLine('ETag')); + + // Check cache with a request with the same URI path and different query string. + $request = $this->createIncomingRequest('foo/bar', ['xfoo' => 'bar', 'bar' => 'baz']); + $cachedResponse = $pageCache->get($request, new Response($this->appConfig)); + + $this->assertNull($cachedResponse); + + // Check cache with another request with the different URI path. + $request = $this->createIncomingRequest('another'); + + $cachedResponse = $pageCache->get($request, new Response($this->appConfig)); + + $this->assertNull($cachedResponse); + } + + public function testCachePageCLIRequest() + { + $pageCache = $this->createResponseCache(); + + $request = $this->createCLIRequest(['foo', 'bar']); + + $response = new Response($this->appConfig); + $response->setBody('The response body.'); + + $return = $pageCache->make($request, $response); + + $this->assertTrue($return); + + // Check cache with a request with the same params. + $request = $this->createCLIRequest(['foo', 'bar']); + $cachedResponse = $pageCache->get($request, new Response($this->appConfig)); + + $this->assertInstanceOf(ResponseInterface::class, $cachedResponse); + $this->assertSame('The response body.', $cachedResponse->getBody()); + + // Check cache with another request with the different params. + $request = $this->createCLIRequest(['baz']); + + $cachedResponse = $pageCache->get($request, new Response($this->appConfig)); + + $this->assertNull($cachedResponse); + } + + public function testUnserializeError() + { + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('unserialize(): Error at offset 0 of 12 bytes'); + + $cache = mock(CacheFactory::class); + $cacheConfig = new CacheConfig(); + $pageCache = new ResponseCache($cacheConfig, $cache); + + $request = $this->createIncomingRequest('foo/bar'); + + $response = new Response($this->appConfig); + $response->setHeader('ETag', 'abcd1234'); + $response->setBody('The response body.'); + + $pageCache->make($request, $response); + + $cacheKey = $pageCache->generateCacheKey($request); + + // Save invalid data. + $cache->save($cacheKey, 'Invalid data'); + + // Check cache with a request with the same URI path. + $pageCache->get($request, new Response($this->appConfig)); + } + + public function testInvalidCacheError() + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Error unserializing page cache'); + + $cache = mock(CacheFactory::class); + $cacheConfig = new CacheConfig(); + $pageCache = new ResponseCache($cacheConfig, $cache); + + $request = $this->createIncomingRequest('foo/bar'); + + $response = new Response($this->appConfig); + $response->setHeader('ETag', 'abcd1234'); + $response->setBody('The response body.'); + + $pageCache->make($request, $response); + + $cacheKey = $pageCache->generateCacheKey($request); + + // Save invalid data. + $cache->save($cacheKey, serialize(['a' => '1'])); + + // Check cache with a request with the same URI path. + $pageCache->get($request, new Response($this->appConfig)); + } +} diff --git a/tests/system/CodeIgniterTest.php b/tests/system/CodeIgniterTest.php index 4a4b8a2021b2..5fae784b51c5 100644 --- a/tests/system/CodeIgniterTest.php +++ b/tests/system/CodeIgniterTest.php @@ -12,15 +12,18 @@ namespace CodeIgniter; use CodeIgniter\Config\Services; +use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\HTTP\Response; +use CodeIgniter\Router\Exceptions\RedirectException; use CodeIgniter\Router\RouteCollection; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Filters\CITestStreamFilter; use CodeIgniter\Test\Mock\MockCodeIgniter; use Config\App; use Config\Cache; -use Config\Filters; +use Config\Filters as FiltersConfig; use Config\Modules; +use Config\Routing; use Tests\Support\Filters\Customfilter; /** @@ -52,10 +55,6 @@ protected function tearDown(): void { parent::tearDown(); - if (count(ob_list_handlers()) > 1) { - ob_end_clean(); - } - $this->resetServices(); } @@ -71,6 +70,16 @@ public function testRunEmptyDefaultRoute(): void $this->assertStringContainsString('Welcome to CodeIgniter', $output); } + public function testOutputBufferingControl() + { + ob_start(); + $this->codeigniter->run(); + ob_get_clean(); + + // 1 phpunit output buffering level + $this->assertSame(1, ob_get_level()); + } + public function testRunEmptyDefaultRouteReturnResponse(): void { $_SERVER['argv'] = ['index.php']; @@ -87,6 +96,7 @@ public function testRunClosureRoute(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/pages/about'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; // Inject mock router. $routes = Services::routes(); @@ -134,11 +144,10 @@ public function testRun404OverrideControllerReturnsResponse(): void $router = Services::router($routes, Services::incomingrequest()); Services::injectMock('router', $router); - ob_start(); - $this->codeigniter->run($routes); - $output = ob_get_clean(); + $response = $this->codeigniter->run($routes, true); - $this->assertStringContainsString('Oops', $output); + $this->assertStringContainsString('Oops', $response->getBody()); + $this->assertSame(567, $response->getStatusCode()); } public function testRun404OverrideReturnResponse(): void @@ -164,7 +173,7 @@ public function testRun404OverrideByClosure(): void $_SERVER['argc'] = 2; // Inject mock router. - $routes = new RouteCollection(Services::locator(), new Modules()); + $routes = new RouteCollection(Services::locator(), new Modules(), new Routing()); $routes->setAutoRoute(false); $routes->set404Override(static function (): void { echo '404 Override by Closure.'; @@ -185,10 +194,14 @@ public function testControllersCanReturnString(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/pages/about'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; // Inject mock router. $routes = Services::routes(); - $routes->add('pages/(:segment)', static fn ($segment) => 'You want to see "' . esc($segment) . '" page.'); + $routes->add( + 'pages/(:segment)', + static fn ($segment) => 'You want to see "' . esc($segment) . '" page.' + ); $router = Services::router($routes, Services::incomingrequest()); Services::injectMock('router', $router); @@ -205,6 +218,7 @@ public function testControllersCanReturnResponseObject(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/pages/about'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; // Inject mock router. $routes = Services::routes(); @@ -233,6 +247,7 @@ public function testControllersCanReturnDownloadResponseObject(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/pages/about'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; // Inject mock router. $routes = Services::routes(); @@ -251,16 +266,21 @@ public function testControllersCanReturnDownloadResponseObject(): void $this->assertSame('some text', $output); } - public function testControllersRunFilterByClassName(): void + public function testRunExecuteFilterByClassName(): void { $_SERVER['argv'] = ['index.php', 'pages/about']; $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/pages/about'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; // Inject mock router. $routes = Services::routes(); - $routes->add('pages/about', static fn () => Services::incomingrequest()->getBody(), ['filter' => Customfilter::class]); + $routes->add( + 'pages/about', + static fn () => Services::incomingrequest()->getBody(), + ['filter' => Customfilter::class] + ); $router = Services::router($routes, Services::incomingrequest()); Services::injectMock('router', $router); @@ -274,12 +294,50 @@ public function testControllersRunFilterByClassName(): void $this->resetServices(); } + public function testRegisterSameFilterTwiceWithDifferentArgument() + { + $this->expectException(ConfigException::class); + $this->expectExceptionMessage('"test-customfilter" already has arguments: null'); + + $_SERVER['argv'] = ['index.php', 'pages/about']; + $_SERVER['argc'] = 2; + + $_SERVER['REQUEST_URI'] = '/pages/about'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $routes = Services::routes(); + $routes->add( + 'pages/about', + static fn () => Services::incomingrequest()->getBody(), + // Set filter with no argument. + ['filter' => 'test-customfilter'] + ); + + $router = Services::router($routes, Services::incomingrequest()); + Services::injectMock('router', $router); + + /** @var FiltersConfig $filterConfig */ + $filterConfig = config('Filters'); + $filterConfig->filters = [ + // Set filter with argument. + 'test-customfilter:arg1' => [ + 'before' => ['pages/*'], + ], + ]; + Services::filters($filterConfig); + + $this->codeigniter->run(); + + $this->resetServices(); + } + public function testDisableControllerFilters(): void { $_SERVER['argv'] = ['index.php', 'pages/about']; $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/pages/about'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; // Inject mock router. $routes = Services::routes(); @@ -390,11 +448,9 @@ public function testRunForceSecure(): void $response = $this->getPrivateProperty($codeigniter, 'response'); $this->assertNull($response->header('Location')); - ob_start(); - $codeigniter->run(); - ob_get_clean(); + $response = $codeigniter->run(null, true); - $this->assertSame('https://example.com/', $response->header('Location')->getValue()); + $this->assertSame('https://example.com/index.php/', $response->header('Location')->getValue()); } public function testRunRedirectionWithNamed(): void @@ -403,6 +459,7 @@ public function testRunRedirectionWithNamed(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/example'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; // Inject mock router. $routes = Services::routes(); @@ -426,6 +483,7 @@ public function testRunRedirectionWithURI(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/example'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; // Inject mock router. $routes = Services::routes(); @@ -452,6 +510,7 @@ public function testRunRedirectionWithGET(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/example'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -478,6 +537,7 @@ public function testRunRedirectionWithGETAndHTTPCode301(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/example'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -502,6 +562,7 @@ public function testRunRedirectionWithPOSTAndHTTPCode301(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/example'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['REQUEST_METHOD'] = 'POST'; @@ -520,6 +581,29 @@ public function testRunRedirectionWithPOSTAndHTTPCode301(): void $this->assertSame(301, $response->getStatusCode()); } + /** + * test for deprecated \CodeIgniter\Router\Exceptions\RedirectException for backward compatibility + */ + public function testRedirectExceptionDeprecated(): void + { + $_SERVER['argv'] = ['index.php', '/']; + $_SERVER['argc'] = 2; + + // Inject mock router. + $routes = Services::routes(); + $routes->get('/', static function () { + throw new RedirectException('redirect-exception', 503); + }); + + $router = Services::router($routes, Services::incomingrequest()); + Services::injectMock('router', $router); + + $response = $this->codeigniter->run($routes, true); + + $this->assertSame(503, $response->getStatusCode()); + $this->assertSame('http://example.com/redirect-exception', $response->getHeaderLine('Location')); + } + public function testStoresPreviousURL(): void { $_SERVER['argv'] = ['index.php', '/']; @@ -534,7 +618,7 @@ public function testStoresPreviousURL(): void ob_get_clean(); $this->assertArrayHasKey('_ci_previous_url', $_SESSION); - $this->assertSame('http://example.com/index.php', $_SESSION['_ci_previous_url']); + $this->assertSame('http://example.com/index.php/', $_SESSION['_ci_previous_url']); } public function testNotStoresPreviousURL(): void @@ -543,6 +627,7 @@ public function testNotStoresPreviousURL(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/example'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -566,6 +651,7 @@ public function testNotStoresPreviousURLByCheckingContentType(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/image'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; // Inject mock router. $routes = Services::routes(); @@ -607,6 +693,7 @@ public function testRunCLIRoute(): void $_SERVER['argc'] = 2; $_SERVER['REQUEST_URI'] = '/cli'; + $_SERVER['SCRIPT_NAME'] = 'public/index.php'; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['REQUEST_METHOD'] = 'CLI'; @@ -626,6 +713,7 @@ public function testSpoofRequestMethodCanUsePUT(): void $_SERVER['argc'] = 1; $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['REQUEST_METHOD'] = 'POST'; @@ -650,6 +738,7 @@ public function testSpoofRequestMethodCannotUseGET(): void $_SERVER['argc'] = 1; $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.1'; $_SERVER['REQUEST_METHOD'] = 'POST'; @@ -682,6 +771,7 @@ public function testPageCacheSendSecureHeaders(): void command('cache:clear'); $_SERVER['REQUEST_URI'] = '/test'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; $routes = Services::routes(); $routes->add('test', static function () { @@ -695,7 +785,7 @@ public function testPageCacheSendSecureHeaders(): void $router = Services::router($routes, Services::incomingrequest()); Services::injectMock('router', $router); - /** @var Filters $filterConfig */ + /** @var FiltersConfig $filterConfig */ $filterConfig = config('Filters'); $filterConfig->globals['after'] = ['secureheaders']; Services::filters($filterConfig); @@ -735,15 +825,18 @@ public function testPageCacheSendSecureHeaders(): void * * @see https://github.com/codeigniter4/CodeIgniter4/pull/6410 */ - public function testPageCacheWithCacheQueryString($cacheQueryStringValue, int $expectedPagesInCache, array $testingUrls): void - { + public function testPageCacheWithCacheQueryString( + $cacheQueryStringValue, + int $expectedPagesInCache, + array $testingUrls + ): void { // Suppress command() output CITestStreamFilter::registration(); CITestStreamFilter::addOutputFilter(); CITestStreamFilter::addErrorFilter(); // Create cache config with cacheQueryString value from the dataProvider - $cacheConfig = new Cache(); + $cacheConfig = config(Cache::class); $cacheConfig->cacheQueryString = $cacheQueryStringValue; // Clear cache before starting the test @@ -755,12 +848,17 @@ public function testPageCacheWithCacheQueryString($cacheQueryStringValue, int $e // Generate request to each URL from the testing array foreach ($testingUrls as $testingUrl) { + $this->resetServices(); $_SERVER['REQUEST_URI'] = '/' . $testingUrl; - $routes = Services::routes(true); - $routes->add($testingUrl, static function () { - CodeIgniter::cache(0); // Don't cache the page in the run() function because CodeIgniter class will create default $cacheConfig and overwrite settings from the dataProvider + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $this->codeigniter = new MockCodeIgniter(new App()); + + $routes = Services::routes(true); + $routePath = explode('?', $testingUrl)[0]; + $string = 'This is a test page, to check cache configuration'; + $routes->add($routePath, static function () use ($string) { + Services::responsecache()->setTtl(60); $response = Services::response(); - $string = 'This is a test page, to check cache configuration'; return $response->setBody($string); }); @@ -769,9 +867,13 @@ public function testPageCacheWithCacheQueryString($cacheQueryStringValue, int $e $router = Services::router($routes, Services::incomingrequest(null, false)); Services::injectMock('router', $router); - // Cache the page output using default caching function and $cacheConfig with value from the data provider + // Cache the page output using default caching function and $cacheConfig + // with value from the data provider + ob_start(); $this->codeigniter->run(); - $this->codeigniter->cachePage($cacheConfig); // Cache the page using our own $cacheConfig confugration + $output = ob_get_clean(); + + $this->assertSame($string, $output); } // Calculate how much cached items exist in the cache after the test requests @@ -792,17 +894,34 @@ public function testPageCacheWithCacheQueryString($cacheQueryStringValue, int $e public static function providePageCacheWithCacheQueryString(): iterable { $testingUrls = [ - 'test', // URL #1 - 'test?important_parameter=1', // URL #2 - 'test?important_parameter=2', // URL #3 - 'test?important_parameter=1¬_important_parameter=2', // URL #4 - 'test?important_parameter=1¬_important_parameter=2&another_not_important_parameter=3', // URL #5 + // URL #1 + 'test', + // URL #2 + 'test?important_parameter=1', + // URL #3 + 'test?important_parameter=2', + // URL #4 + 'test?important_parameter=1¬_important_parameter=2', + // URL #5 + 'test?important_parameter=1¬_important_parameter=2&another_not_important_parameter=3', ]; return [ - '$cacheQueryString=false' => [false, 1, $testingUrls], // We expect only 1 page in the cache, because when cacheQueryString is set to false, all GET parameter should be ignored, and page URI will be absolutely same "/test" string for all 5 requests - '$cacheQueryString=true' => [true, 5, $testingUrls], // We expect all 5 pages in the cache, because when cacheQueryString is set to true, all GET parameter should be processed as unique requests - '$cacheQueryString=array' => [['important_parameter'], 3, $testingUrls], // We expect only 3 pages in the cache, because when cacheQueryString is set to array with important parameters, we should ignore all parameters thats not in the array. Only URL #1, URL #2 and URL #3 should be cached. URL #4 and URL #5 is duplication of URL #2 (with value ?important_parameter=1), so they should not be processed as new unique requests and application should return already cached page for URL #2 + // We expect only 1 page in the cache, because when cacheQueryString + // is set to false, all GET parameter should be ignored, and page URI + // will be absolutely same "/test" string for all 5 requests + '$cacheQueryString=false' => [false, 1, $testingUrls], + // We expect all 5 pages in the cache, because when cacheQueryString + // is set to true, all GET parameter should be processed as unique requests + '$cacheQueryString=true' => [true, 5, $testingUrls], + // We expect only 3 pages in the cache, because when cacheQueryString + // is set to array with important parameters, we should ignore all + // parameters thats not in the array. Only URL #1, URL #2 and URL #3 + // should be cached. URL #4 and URL #5 is duplication of URL #2 + // (with value ?important_parameter=1), so they should not be processed + // as new unique requests and application should return already cached + // page for URL #2 + '$cacheQueryString=array' => [['important_parameter'], 3, $testingUrls], ]; } } diff --git a/tests/system/Commands/RoutesTest.php b/tests/system/Commands/RoutesTest.php index d1625dcfc1bc..059ce964d5e0 100644 --- a/tests/system/Commands/RoutesTest.php +++ b/tests/system/Commands/RoutesTest.php @@ -53,7 +53,7 @@ private function getCleanRoutes(): RouteCollection public function testRoutesCommand(): void { - $this->getCleanRoutes(); + Services::injectMock('routes', null); command('routes'); @@ -79,7 +79,7 @@ public function testRoutesCommand(): void public function testRoutesCommandSortByHandler(): void { - $this->getCleanRoutes(); + Services::injectMock('routes', null); command('routes -h'); @@ -103,6 +103,62 @@ public function testRoutesCommandSortByHandler(): void $this->assertStringContainsString($expected, $this->getBuffer()); } + public function testRoutesCommandHostHostname() + { + Services::injectMock('routes', null); + + command('routes --host blog.example.com'); + + $expected = <<<'EOL' + Host: blog.example.com + +---------+---------+---------------+----------------------------------------+----------------+---------------+ + | Method | Route | Name | Handler | Before Filters | After Filters | + +---------+---------+---------------+----------------------------------------+----------------+---------------+ + | GET | / | » | \App\Controllers\Blog::index | | toolbar | + | GET | closure | » | (Closure) | | toolbar | + | GET | all | » | \App\Controllers\AllDomain::index | | toolbar | + | GET | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | HEAD | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | POST | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | PUT | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | DELETE | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | OPTIONS | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | TRACE | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | CONNECT | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | CLI | testing | testing-index | \App\Controllers\TestController::index | | | + +---------+---------+---------------+----------------------------------------+----------------+---------------+ + EOL; + $this->assertStringContainsString($expected, $this->getBuffer()); + } + + public function testRoutesCommandHostSubdomain() + { + Services::injectMock('routes', null); + + command('routes --host sub.example.com'); + + $expected = <<<'EOL' + Host: sub.example.com + +---------+---------+---------------+----------------------------------------+----------------+---------------+ + | Method | Route | Name | Handler | Before Filters | After Filters | + +---------+---------+---------------+----------------------------------------+----------------+---------------+ + | GET | / | » | \App\Controllers\Sub::index | | toolbar | + | GET | closure | » | (Closure) | | toolbar | + | GET | all | » | \App\Controllers\AllDomain::index | | toolbar | + | GET | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | HEAD | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | POST | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | PUT | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | DELETE | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | OPTIONS | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | TRACE | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | CONNECT | testing | testing-index | \App\Controllers\TestController::index | | toolbar | + | CLI | testing | testing-index | \App\Controllers\TestController::index | | | + +---------+---------+---------------+----------------------------------------+----------------+---------------+ + EOL; + $this->assertStringContainsString($expected, $this->getBuffer()); + } + public function testRoutesCommandAutoRouteImproved(): void { $routes = $this->getCleanRoutes(); @@ -129,7 +185,7 @@ public function testRoutesCommandAutoRouteImproved(): void | TRACE | testing | testing-index | \App\Controllers\TestController::index | | toolbar | | CONNECT | testing | testing-index | \App\Controllers\TestController::index | | toolbar | | CLI | testing | testing-index | \App\Controllers\TestController::index | | | - | GET(auto) | newautorouting | | \Tests\Support\Controllers\Newautorouting::getIndex | | toolbar | + | GET(auto) | newautorouting[/..] | | \Tests\Support\Controllers\Newautorouting::getIndex | | toolbar | | POST(auto) | newautorouting/save/../..[/..] | | \Tests\Support\Controllers\Newautorouting::postSave | | toolbar | +------------+--------------------------------+---------------+-----------------------------------------------------+----------------+---------------+ EOL; @@ -139,6 +195,7 @@ public function testRoutesCommandAutoRouteImproved(): void public function testRoutesCommandRouteLegacy(): void { $routes = $this->getCleanRoutes(); + $routes->loadRoutes(); $routes->setAutoRoute(true); $namespace = 'Tests\Support\Controllers'; diff --git a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollectorTest.php b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollectorTest.php index d9dfee3e98ad..e705dad48869 100644 --- a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollectorTest.php +++ b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollectorTest.php @@ -62,7 +62,7 @@ public function testGetFilterMatches(): void $expected = [ 0 => [ 'GET(auto)', - 'newautorouting', + 'newautorouting[/..]', '', '\\Tests\\Support\\Controllers\\Newautorouting::getIndex', '', @@ -90,7 +90,7 @@ public function testGetFilterDoesNotMatch(): void $expected = [ 0 => [ 'GET(auto)', - 'newautorouting', + 'newautorouting[/..]', '', '\\Tests\\Support\\Controllers\\Newautorouting::getIndex', '', diff --git a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReaderTest.php b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReaderTest.php index e3e23c29dc7a..133d0feff781 100644 --- a/tests/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReaderTest.php +++ b/tests/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReaderTest.php @@ -44,9 +44,11 @@ public function testRead(): void 0 => [ 'method' => 'get', 'route' => 'newautorouting', - 'route_params' => '', + 'route_params' => '[/..]', 'handler' => '\Tests\Support\Controllers\Newautorouting::getIndex', - 'params' => [], + 'params' => [ + 'm' => false, + ], ], [ 'method' => 'post', diff --git a/tests/system/Commands/Utilities/Routes/FilterFinderTest.php b/tests/system/Commands/Utilities/Routes/FilterFinderTest.php index 15b970cd4351..67c9a1ca5815 100644 --- a/tests/system/Commands/Utilities/Routes/FilterFinderTest.php +++ b/tests/system/Commands/Utilities/Routes/FilterFinderTest.php @@ -25,6 +25,7 @@ use CodeIgniter\Test\ConfigFromArrayTrait; use Config\Filters as FiltersConfig; use Config\Modules; +use Config\Routing; /** * @internal @@ -52,7 +53,7 @@ protected function setUp(): void private function createRouteCollection(array $routes = []): RouteCollection { - $collection = new RouteCollection(Services::locator(), $this->moduleConfig); + $collection = new RouteCollection(Services::locator(), $this->moduleConfig, new Routing()); $routes = ($routes !== []) ? $routes : [ 'users' => 'Users::index', diff --git a/tests/system/CommonFunctionsTest.php b/tests/system/CommonFunctionsTest.php index 4a23d3a923e7..c678a0dd1fbb 100644 --- a/tests/system/CommonFunctionsTest.php +++ b/tests/system/CommonFunctionsTest.php @@ -12,7 +12,9 @@ namespace CodeIgniter; use CodeIgniter\Config\BaseService; +use CodeIgniter\Config\Factories; use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\RedirectResponse; use CodeIgniter\HTTP\Response; @@ -28,9 +30,14 @@ use CodeIgniter\Test\Mock\MockSession; use CodeIgniter\Test\TestLogger; use Config\App; +use Config\Cookie; use Config\Logger; use Config\Modules; +use Config\Routing; +use Config\Security as SecurityConfig; use Config\Services; +use Config\Session as SessionConfig; +use Exception; use Kint; use RuntimeException; use stdClass; @@ -115,16 +122,19 @@ public function testEnvBooleans(): void $this->assertNull(env('p4')); } + private function createRouteCollection(): RouteCollection + { + return new RouteCollection(Services::locator(), new Modules(), new Routing()); + } + public function testRedirectReturnsRedirectResponse(): void { $_SERVER['REQUEST_METHOD'] = 'GET'; $response = $this->createMock(Response::class); - $routes = new RouteCollection( - Services::locator(), - new Modules() - ); Services::injectMock('response', $response); + + $routes = $this->createRouteCollection(); Services::injectMock('routes', $routes); $routes->add('home/base', 'Controller::index', ['as' => 'base']); @@ -329,7 +339,7 @@ public function testAppTimezone(): void public function testCSRFToken(): void { - Services::injectMock('security', new MockSecurity(new App())); + Services::injectMock('security', new MockSecurity(new SecurityConfig())); $this->assertSame('csrf_test_name', csrf_token()); } @@ -387,7 +397,7 @@ public function testOldInput(): void $this->config = new App(); $this->config->baseURL = 'http://example.com/'; - $this->routes = new RouteCollection(Services::locator(), new Modules()); + $this->routes = $this->createRouteCollection(); Services::injectMock('routes', $this->routes); $this->request = new MockIncomingRequest($this->config, new URI('http://example.com'), null, new UserAgent()); @@ -422,7 +432,7 @@ public function testOldInputSerializeData(): void $this->config = new App(); $this->config->baseURL = 'http://example.com/'; - $this->routes = new RouteCollection(Services::locator(), new Modules()); + $this->routes = $this->createRouteCollection(); Services::injectMock('routes', $this->routes); $this->request = new MockIncomingRequest($this->config, new URI('http://example.com'), null, new UserAgent()); @@ -457,7 +467,7 @@ public function testOldInputArray(): void $this->config = new App(); $this->config->baseURL = 'http://example.com/'; - $this->routes = new RouteCollection(Services::locator(), new Modules()); + $this->routes = $this->createRouteCollection(); Services::injectMock('routes', $this->routes); $this->request = new MockIncomingRequest($this->config, new URI('http://example.com'), null, new UserAgent()); @@ -488,12 +498,8 @@ public function testReallyWritable(): void public function testSlashItem(): void { - $this->assertSame('/', slash_item('cookiePath')); // / - $this->assertSame('', slash_item('cookieDomain')); // '' $this->assertSame('en/', slash_item('defaultLocale')); // en - $this->assertSame('7200/', slash_item('sessionExpiration')); // int 7200 $this->assertSame('', slash_item('negotiateLocale')); // false - $this->assertSame('1/', slash_item('cookieHTTPOnly')); // true } public function testSlashItemOnInexistentItem(): void @@ -514,28 +520,36 @@ public function testSlashItemThrowsErrorOnNonStringableItem(): void protected function injectSessionMock(): void { + $sessionConfig = new SessionConfig(); + $defaults = [ - 'sessionDriver' => FileHandler::class, - 'sessionCookieName' => 'ci_session', - 'sessionExpiration' => 7200, - 'sessionSavePath' => '', - 'sessionMatchIP' => false, - 'sessionTimeToUpdate' => 300, - 'sessionRegenerateDestroy' => false, - 'cookieDomain' => '', - 'cookiePrefix' => '', - 'cookiePath' => '/', - 'cookieSecure' => false, - 'cookieSameSite' => 'Lax', + 'driver' => FileHandler::class, + 'cookieName' => 'ci_session', + 'expiration' => 7200, + 'savePath' => '', + 'matchIP' => false, + 'timeToUpdate' => 300, + 'regenerateDestroy' => false, ]; - $appConfig = new App(); - foreach ($defaults as $key => $config) { - $appConfig->{$key} = $config; + $sessionConfig->{$key} = $config; + } + + $cookie = new Cookie(); + + foreach ([ + 'prefix' => '', + 'domain' => '', + 'path' => '/', + 'secure' => false, + 'samesite' => 'Lax', + ] as $key => $value) { + $cookie->{$key} = $value; } + Factories::injectMock('config', 'Cookie', $cookie); - $session = new MockSession(new FileHandler($appConfig, '127.0.0.1'), $appConfig); + $session = new MockSession(new FileHandler($sessionConfig, '127.0.0.1'), $sessionConfig); $session->setLogger(new TestLogger(new Logger())); BaseService::injectMock('session', $session); } @@ -587,10 +601,26 @@ public function testViewNotSaveData(): void public function testForceHttpsNullRequestAndResponse(): void { $this->assertNull(Services::response()->header('Location')); + Services::response()->setCookie('force', 'cookie'); + Services::response()->setHeader('Force', 'header'); + Services::response()->setBody('default body'); + + try { + force_https(); + } catch (Exception $e) { + $this->assertInstanceOf(RedirectException::class, $e); + $this->assertSame( + 'https://example.com/index.php/', + $e->getResponse()->header('Location')->getValue() + ); + $this->assertFalse($e->getResponse()->hasCookie('force')); + $this->assertSame('header', $e->getResponse()->getHeaderLine('Force')); + $this->assertSame('', $e->getResponse()->getBody()); + $this->assertSame(307, $e->getResponse()->getStatusCode()); + } + $this->expectException(RedirectException::class); force_https(); - - $this->assertSame('https://example.com/', Services::response()->header('Location')->getValue()); } /** diff --git a/tests/system/CommonSingleServiceTest.php b/tests/system/CommonSingleServiceTest.php index 9a6c094dec35..e95e4c493136 100644 --- a/tests/system/CommonSingleServiceTest.php +++ b/tests/system/CommonSingleServiceTest.php @@ -15,7 +15,7 @@ use CodeIgniter\Config\Services; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockSecurity; -use Config\App; +use Config\Security as SecurityConfig; use ReflectionClass; use ReflectionMethod; @@ -31,7 +31,7 @@ final class CommonSingleServiceTest extends CIUnitTestCase */ public function testSingleServiceWithNoParamsSupplied(string $service): void { - Services::injectMock('security', new MockSecurity(new App())); + Services::injectMock('security', new MockSecurity(new SecurityConfig())); $service1 = single_service($service); $service2 = single_service($service); diff --git a/tests/system/Config/FactoriesTest.php b/tests/system/Config/FactoriesTest.php index c3b12c47d0ab..c5936a7c341f 100644 --- a/tests/system/Config/FactoriesTest.php +++ b/tests/system/Config/FactoriesTest.php @@ -12,9 +12,14 @@ namespace CodeIgniter\Config; use CodeIgniter\Test\CIUnitTestCase; +use Config\Database; +use InvalidArgumentException; use ReflectionClass; use stdClass; +use Tests\Support\Config\TestRegistrar; +use Tests\Support\Models\EntityModel; use Tests\Support\Models\UserModel; +use Tests\Support\View\SampleClass; use Tests\Support\Widgets\OtherWidget; use Tests\Support\Widgets\SomeWidget; @@ -251,7 +256,53 @@ class_alias(SomeWidget::class, $class); $this->assertInstanceOf(SomeWidget::class, $result); } - public function testpreferAppOverridesClassname(): void + public function testShortnameReturnsConfigInApp() + { + // Create a config class in App + $file = APPPATH . 'Config/TestRegistrar.php'; + $source = <<<'EOL' + assertInstanceOf('Config\TestRegistrar', $result); + + // Delete the config class in App + unlink($file); + } + + public function testFullClassnameIgnoresPreferApp() + { + // Create a config class in App + $file = APPPATH . 'Config/TestRegistrar.php'; + $source = <<<'EOL' + assertInstanceOf(TestRegistrar::class, $result); + + Factories::setOptions('config', ['preferApp' => false]); + + $result = Factories::config(TestRegistrar::class); + + $this->assertInstanceOf(TestRegistrar::class, $result); + + // Delete the config class in App + unlink($file); + } + + public function testPreferAppIsIgnored(): void { // Create a fake class in App $class = 'App\Widgets\OtherWidget'; @@ -260,11 +311,147 @@ class_alias(SomeWidget::class, $class); } $result = Factories::widgets(OtherWidget::class); - $this->assertInstanceOf(SomeWidget::class, $result); + $this->assertInstanceOf(OtherWidget::class, $result); + } - Factories::setOptions('widgets', ['preferApp' => false]); + public function testCanLoadTwoCellsWithSameShortName() + { + $cell1 = Factories::cells('\\' . SampleClass::class); + $cell2 = Factories::cells('\\' . \Tests\Support\View\OtherCells\SampleClass::class); - $result = Factories::widgets(OtherWidget::class); - $this->assertInstanceOf(OtherWidget::class, $result); + $this->assertNotSame($cell1, $cell2); + } + + public function testDefineSameAliasTwiceWithDifferentClasses() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Already defined in Factories: models CodeIgniter\Shield\Models\UserModel -> Tests\Support\Models\UserModel' + ); + + Factories::define( + 'models', + 'CodeIgniter\Shield\Models\UserModel', + UserModel::class + ); + Factories::define( + 'models', + 'CodeIgniter\Shield\Models\UserModel', + EntityModel::class + ); + } + + public function testDefineSameAliasAndSameClassTwice() + { + Factories::define( + 'models', + 'CodeIgniter\Shield\Models\UserModel', + UserModel::class + ); + Factories::define( + 'models', + 'CodeIgniter\Shield\Models\UserModel', + UserModel::class + ); + + $model = model('CodeIgniter\Shield\Models\UserModel'); + + $this->assertInstanceOf(UserModel::class, $model); + } + + public function testDefineNonExistentClass() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No such class: App\Models\UserModel'); + + Factories::define( + 'models', + 'CodeIgniter\Shield\Models\UserModel', + 'App\Models\UserModel' + ); + } + + public function testDefineAfterLoading() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Already defined in Factories: models Tests\Support\Models\UserModel -> Tests\Support\Models\UserModel' + ); + + model(UserModel::class); + + Factories::define( + 'models', + UserModel::class, + 'App\Models\UserModel' + ); + } + + public function testDefineAndLoad() + { + Factories::define( + 'models', + UserModel::class, + EntityModel::class + ); + + $model = model(UserModel::class); + + $this->assertInstanceOf(EntityModel::class, $model); + } + + public function testGetComponentInstances() + { + Factories::config('App'); + Factories::config(Database::class); + + $data = Factories::getComponentInstances('config'); + + $this->assertIsArray($data); + $this->assertArrayHasKey('aliases', $data); + $this->assertArrayHasKey('instances', $data); + + return $data; + } + + /** + * @depends testGetComponentInstances + */ + public function testSetComponentInstances(array $data) + { + $before = Factories::getComponentInstances('config'); + $this->assertSame(['aliases' => [], 'instances' => []], $before); + + Factories::setComponentInstances('config', $data); + + $data = Factories::getComponentInstances('config'); + + $this->assertIsArray($data); + $this->assertArrayHasKey('aliases', $data); + $this->assertArrayHasKey('instances', $data); + + return $data; + } + + /** + * @depends testSetComponentInstances + */ + public function testIsUpdated(array $data) + { + Factories::reset(); + + $updated = $this->getFactoriesStaticProperty('updated'); + + $this->assertSame([], $updated); + $this->assertFalse(Factories::isUpdated('config')); + + Factories::config('App'); + + $this->assertTrue(Factories::isUpdated('config')); + $this->assertFalse(Factories::isUpdated('models')); + + Factories::setComponentInstances('config', $data); + + $this->assertFalse(Factories::isUpdated('config')); } } diff --git a/tests/system/Config/ServicesTest.php b/tests/system/Config/ServicesTest.php index 36e97ba1ed2c..b22995b5b218 100644 --- a/tests/system/Config/ServicesTest.php +++ b/tests/system/Config/ServicesTest.php @@ -43,6 +43,7 @@ use CodeIgniter\View\Parser; use Config\App; use Config\Exceptions; +use Config\Security as SecurityConfig; use RuntimeException; use Tests\Support\Config\Services; @@ -53,7 +54,6 @@ */ final class ServicesTest extends CIUnitTestCase { - private App $config; private array $original; protected function setUp(): void @@ -61,7 +61,6 @@ protected function setUp(): void parent::setUp(); $this->original = $_SERVER; - $this->config = new App(); } protected function tearDown(): void @@ -128,13 +127,13 @@ public function testNewUnsharedEmailWithNonEmptyConfig(): void public function testNewExceptions(): void { - $actual = Services::exceptions(new Exceptions(), Services::request(), Services::response()); + $actual = Services::exceptions(new Exceptions()); $this->assertInstanceOf(\CodeIgniter\Debug\Exceptions::class, $actual); } public function testNewExceptionsWithNullConfig(): void { - $actual = Services::exceptions(null, null, null, false); + $actual = Services::exceptions(null, false); $this->assertInstanceOf(\CodeIgniter\Debug\Exceptions::class, $actual); } @@ -242,7 +241,7 @@ public function testNewViewcell(): void */ public function testNewSession(): void { - $actual = Services::session($this->config); + $actual = Services::session(); $this->assertInstanceOf(Session::class, $actual); } @@ -329,7 +328,7 @@ public function testReset(): void public function testResetSingle(): void { Services::injectMock('response', new MockResponse(new App())); - Services::injectMock('security', new MockSecurity(new App())); + Services::injectMock('security', new MockSecurity(new SecurityConfig())); $response = service('response'); $security = service('security'); $this->assertInstanceOf(MockResponse::class, $response); @@ -411,7 +410,7 @@ public function testRouter(): void public function testSecurity(): void { - Services::injectMock('security', new MockSecurity(new App())); + Services::injectMock('security', new MockSecurity(new SecurityConfig())); $result = Services::security(); $this->assertInstanceOf(Security::class, $result); diff --git a/tests/system/ControllerTest.php b/tests/system/ControllerTest.php index c330cde160c2..668cf9b1bc68 100644 --- a/tests/system/ControllerTest.php +++ b/tests/system/ControllerTest.php @@ -12,6 +12,7 @@ namespace CodeIgniter; use CodeIgniter\Config\Factories; +use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Request; use CodeIgniter\HTTP\Response; @@ -74,11 +75,15 @@ public function testConstructorHTTPS(): void { $original = $_SERVER; $_SERVER = ['HTTPS' => 'on']; + // make sure we can instantiate one - $this->controller = new class () extends Controller { - protected $forceHTTPS = 1; - }; - $this->controller->initController($this->request, $this->response, $this->logger); + try { + $this->controller = new class () extends Controller { + protected $forceHTTPS = 1; + }; + $this->controller->initController($this->request, $this->response, $this->logger); + } catch (RedirectException $e) { + } $this->assertInstanceOf(Controller::class, $this->controller); $_SERVER = $original; // restore so code coverage doesn't break diff --git a/tests/system/Database/Live/ForgeTest.php b/tests/system/Database/Live/ForgeTest.php index a1138c99c860..64bb83772169 100644 --- a/tests/system/Database/Live/ForgeTest.php +++ b/tests/system/Database/Live/ForgeTest.php @@ -827,6 +827,41 @@ public function testAddColumn(): void $this->assertSame('username', $fieldNames[1]); } + public function testAddColumnNull() + { + $this->forge->dropTable('forge_test_table', true); + + $this->forge->addField([ + 'col1' => ['type' => 'VARCHAR', 'constraint' => 255], + 'col2' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'col3' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false], + ]); + $this->forge->createTable('forge_test_table'); + + $this->forge->addColumn('forge_test_table', [ + 'col4' => ['type' => 'VARCHAR', 'constraint' => 255], + 'col5' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'col6' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false], + ]); + + $this->db->resetDataCache(); + + $col1 = $this->getMetaData('col1', 'forge_test_table'); + $this->assertFalse($col1->nullable); + $col2 = $this->getMetaData('col2', 'forge_test_table'); + $this->assertTrue($col2->nullable); + $col3 = $this->getMetaData('col3', 'forge_test_table'); + $this->assertFalse($col3->nullable); + $col4 = $this->getMetaData('col4', 'forge_test_table'); + $this->assertTrue($col4->nullable); + $col5 = $this->getMetaData('col5', 'forge_test_table'); + $this->assertTrue($col5->nullable); + $col6 = $this->getMetaData('col6', 'forge_test_table'); + $this->assertFalse($col6->nullable); + + $this->forge->dropTable('forge_test_table', true); + } + public function testAddFields(): void { $tableName = 'forge_test_fields'; @@ -850,6 +885,7 @@ public function testAddFields(): void 'name' => [ 'type' => 'VARCHAR', 'constraint' => 255, + 'null' => true, ], 'active' => [ 'type' => 'INTEGER', @@ -891,7 +927,7 @@ public function testAddFields(): void 'name' => 'name', 'type' => 'varchar', 'max_length' => 255, - 'nullable' => false, + 'nullable' => true, 'default' => null, 'primary_key' => 0, ], @@ -931,7 +967,7 @@ public function testAddFields(): void 2 => [ 'name' => 'name', 'type' => 'character varying', - 'nullable' => false, + 'nullable' => true, 'default' => null, 'max_length' => '255', ], @@ -967,7 +1003,7 @@ public function testAddFields(): void 'max_length' => null, 'default' => null, 'primary_key' => false, - 'nullable' => false, + 'nullable' => true, ], 3 => [ 'name' => 'active', @@ -985,24 +1021,28 @@ public function testAddFields(): void 'type' => 'int', 'default' => null, 'max_length' => 10, + 'nullable' => false, ], 1 => [ 'name' => 'username', 'type' => 'varchar', 'default' => null, 'max_length' => 255, + 'nullable' => false, ], 2 => [ 'name' => 'name', 'type' => 'varchar', 'default' => null, 'max_length' => 255, + 'nullable' => true, ], 3 => [ 'name' => 'active', 'type' => 'int', 'default' => '((0))', // Why? 'max_length' => 10, + 'nullable' => false, ], ]; } elseif ($this->db->DBDriver === 'OCI8') { @@ -1025,8 +1065,8 @@ public function testAddFields(): void 'name' => 'name', 'type' => 'VARCHAR2', 'max_length' => '255', - 'default' => '', - 'nullable' => false, + 'default' => null, + 'nullable' => true, ], 3 => [ 'name' => 'active', @@ -1280,11 +1320,6 @@ public function testModifyColumnRename(): void public function testModifyColumnNullTrue(): void { - // @TODO remove this in `4.4` branch - if ($this->db->DBDriver === 'SQLSRV') { - $this->markTestSkipped('SQLSRV does not support getFieldData() nullable.'); - } - $this->forge->dropTable('forge_test_modify', true); $this->forge->addField([ @@ -1314,11 +1349,6 @@ public function testModifyColumnNullTrue(): void public function testModifyColumnNullFalse(): void { - // @TODO remove this in `4.4` branch - if ($this->db->DBDriver === 'SQLSRV') { - $this->markTestSkipped('SQLSRV does not support getFieldData() nullable.'); - } - $this->forge->dropTable('forge_test_modify', true); $this->forge->addField([ diff --git a/tests/system/Database/Live/MySQLi/NumberNativeTest.php b/tests/system/Database/Live/MySQLi/NumberNativeTest.php new file mode 100644 index 000000000000..1f95dd4e7c59 --- /dev/null +++ b/tests/system/Database/Live/MySQLi/NumberNativeTest.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live\MySQLi; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use Config\Database; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @group DatabaseLive + * + * @internal + */ +final class NumberNativeTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + private $tests; + protected $refresh = true; + protected $seed = CITestSeeder::class; + + protected function setUp(): void + { + parent::setUp(); + + $config = config('Database'); + + $this->tests = $config->tests; + } + + public function testEnableNumberNative() + { + $this->tests['numberNative'] = true; + + $db1 = Database::connect($this->tests); + + if ($db1->DBDriver !== 'MySQLi') { + $this->markTestSkipped('Only MySQLi can complete this test.'); + } + + $this->assertTrue($db1->numberNative); + } + + public function testDisableNumberNative() + { + $this->tests['numberNative'] = false; + + $db1 = Database::connect($this->tests); + + if ($db1->DBDriver !== 'MySQLi') { + $this->markTestSkipped('Only MySQLi can complete this test.'); + } + + $this->assertFalse($db1->numberNative); + } + + public function testQueryDataAfterEnableNumberNative() + { + $this->tests['numberNative'] = true; + + $db1 = Database::connect($this->tests); + + if ($db1->DBDriver !== 'MySQLi') { + $this->markTestSkipped('Only MySQLi can complete this test.'); + } + + $data = $db1->table('db_type_test') + ->get() + ->getRow(); + + $this->assertIsFloat($data->type_float); + $this->assertIsInt($data->type_integer); + } + + public function testQueryDataAfterDisableNumberNative() + { + $this->tests['numberNative'] = false; + + $db1 = Database::connect($this->tests); + + if ($db1->DBDriver !== 'MySQLi') { + $this->markTestSkipped('Only MySQLi can complete this test.'); + } + + $data = $db1->table('db_type_test') + ->get() + ->getRow(); + + $this->assertIsString($data->type_float); + $this->assertIsString($data->type_integer); + } +} diff --git a/tests/system/Database/ModelFactoryTest.php b/tests/system/Database/ModelFactoryTest.php index 58cc77f30891..d8857d5ad5cf 100644 --- a/tests/system/Database/ModelFactoryTest.php +++ b/tests/system/Database/ModelFactoryTest.php @@ -66,12 +66,12 @@ public function testReset(): void $this->assertNull(ModelFactory::get('Banana')); } - public function testBasenameReturnsExistingNamespaceInstance(): void + public function testBasenameDoesNotReturnExistingNamespaceInstance(): void { ModelFactory::injectMock(UserModel::class, new JobModel()); $basenameModel = ModelFactory::get('UserModel'); - $this->assertInstanceOf(JobModel::class, $basenameModel); + $this->assertInstanceOf(UserModel::class, $basenameModel); } } diff --git a/tests/system/Debug/ExceptionHandlerTest.php b/tests/system/Debug/ExceptionHandlerTest.php new file mode 100644 index 000000000000..cd87eff739cf --- /dev/null +++ b/tests/system/Debug/ExceptionHandlerTest.php @@ -0,0 +1,225 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Debug; + +use App\Controllers\Home; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use Config\Exceptions as ExceptionsConfig; +use Config\Services; +use RuntimeException; + +/** + * @internal + * + * @group Others + */ +final class ExceptionHandlerTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + private ExceptionHandler $handler; + + protected function setUp(): void + { + parent::setUp(); + + $this->handler = new ExceptionHandler(new ExceptionsConfig()); + } + + public function testDetermineViewsPageNotFoundException(): void + { + $determineView = $this->getPrivateMethodInvoker($this->handler, 'determineView'); + + $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar'); + $templatePath = APPPATH . 'Views/errors/html'; + $viewFile = $determineView($exception, $templatePath); + + $this->assertSame('error_404.php', $viewFile); + } + + public function testDetermineViewsRuntimeException(): void + { + $determineView = $this->getPrivateMethodInvoker($this->handler, 'determineView'); + + $exception = new RuntimeException('Exception'); + $templatePath = APPPATH . 'Views/errors/html'; + $viewFile = $determineView($exception, $templatePath); + + $this->assertSame('error_exception.php', $viewFile); + } + + public function testDetermineViewsRuntimeExceptionCode404(): void + { + $determineView = $this->getPrivateMethodInvoker($this->handler, 'determineView'); + + $exception = new RuntimeException('foo', 404); + $templatePath = APPPATH . 'Views/errors/html'; + $viewFile = $determineView($exception, $templatePath); + + $this->assertSame('error_404.php', $viewFile); + } + + public function testCollectVars(): void + { + $collectVars = $this->getPrivateMethodInvoker($this->handler, 'collectVars'); + + $vars = $collectVars(new RuntimeException('This.'), 404); + + $this->assertIsArray($vars); + $this->assertCount(7, $vars); + + foreach (['title', 'type', 'code', 'message', 'file', 'line', 'trace'] as $key) { + $this->assertArrayHasKey($key, $vars); + } + } + + public function testHandleWebPageNotFoundExceptionDoNotAcceptHTML(): void + { + $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar'); + + $request = Services::incomingrequest(null, false); + $response = Services::response(null, false); + $response->pretend(); + + ob_start(); + $this->handler->handle($exception, $request, $response, 404, EXIT_ERROR); + $output = ob_get_clean(); + + $json = json_decode($output); + $this->assertSame(PageNotFoundException::class, $json->title); + $this->assertSame(PageNotFoundException::class, $json->type); + $this->assertSame(404, $json->code); + $this->assertSame('Controller or its method is not found: Foo::bar', $json->message); + } + + public function testHandleWebPageNotFoundExceptionAcceptHTML(): void + { + $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar'); + + $request = Services::incomingrequest(null, false); + $request->setHeader('accept', 'text/html'); + $response = Services::response(null, false); + $response->pretend(); + + ob_start(); + $this->handler->handle($exception, $request, $response, 404, EXIT_ERROR); + $output = ob_get_clean(); + + $this->assertStringContainsString('404 - Page Not Found', $output); + } + + public function testHandleCLIPageNotFoundException(): void + { + $exception = PageNotFoundException::forControllerNotFound('Foo', 'bar'); + + $request = Services::clirequest(null, false); + $request->setHeader('accept', 'text/html'); + $response = Services::response(null, false); + $response->pretend(); + + $this->handler->handle($exception, $request, $response, 404, EXIT_ERROR); + + $this->assertStringContainsString( + 'ERROR: 404', + $this->getStreamFilterBuffer() + ); + $this->assertStringContainsString( + 'Controller or its method is not found: Foo::bar', + $this->getStreamFilterBuffer() + ); + + $this->resetStreamFilterBuffer(); + } + + public function testMaskSensitiveData(): void + { + $maskSensitiveData = $this->getPrivateMethodInvoker($this->handler, 'maskSensitiveData'); + + $trace = [ + 0 => [ + 'file' => '/var/www/CodeIgniter4/app/Controllers/Home.php', + 'line' => 15, + 'function' => 'f', + 'class' => Home::class, + 'type' => '->', + 'args' => [ + 0 => (object) [ + 'password' => 'secret1', + ], + 1 => (object) [ + 'default' => [ + 'password' => 'secret2', + ], + ], + 2 => [ + 'password' => 'secret3', + ], + 3 => [ + 'default' => ['password' => 'secret4'], + ], + ], + ], + 1 => [ + 'file' => '/var/www/CodeIgniter4/system/CodeIgniter.php', + 'line' => 932, + 'function' => 'index', + 'class' => Home::class, + 'type' => '->', + 'args' => [ + ], + ], + ]; + $keysToMask = ['password']; + $path = ''; + + $newTrace = $maskSensitiveData($trace, $keysToMask, $path); + + $this->assertSame(['password' => '******************'], (array) $newTrace[0]['args'][0]); + $this->assertSame(['password' => '******************'], $newTrace[0]['args'][1]->default); + $this->assertSame(['password' => '******************'], $newTrace[0]['args'][2]); + $this->assertSame(['password' => '******************'], $newTrace[0]['args'][3]['default']); + } + + public function testMaskSensitiveDataTraceDataKey(): void + { + $maskSensitiveData = $this->getPrivateMethodInvoker($this->handler, 'maskSensitiveData'); + + $trace = [ + 0 => [ + 'file' => '/var/www/CodeIgniter4/app/Controllers/Home.php', + 'line' => 15, + 'function' => 'f', + 'class' => Home::class, + 'type' => '->', + 'args' => [ + ], + ], + 1 => [ + 'file' => '/var/www/CodeIgniter4/system/CodeIgniter.php', + 'line' => 932, + 'function' => 'index', + 'class' => Home::class, + 'type' => '->', + 'args' => [ + ], + ], + ]; + $keysToMask = ['file']; + $path = ''; + + $newTrace = $maskSensitiveData($trace, $keysToMask, $path); + + $this->assertSame('/var/www/CodeIgniter4/app/Controllers/Home.php', $newTrace[0]['file']); + } +} diff --git a/tests/system/Debug/ExceptionsTest.php b/tests/system/Debug/ExceptionsTest.php index a77745ba8a78..4a30a32f1133 100644 --- a/tests/system/Debug/ExceptionsTest.php +++ b/tests/system/Debug/ExceptionsTest.php @@ -19,7 +19,6 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\ReflectionHelper; use Config\Exceptions as ExceptionsConfig; -use Config\Services; use ErrorException; use RuntimeException; @@ -52,7 +51,7 @@ protected function setUp(): void { parent::setUp(); - $this->exception = new Exceptions(new ExceptionsConfig(), Services::request(), Services::response()); + $this->exception = new Exceptions(new ExceptionsConfig()); } /** @@ -65,7 +64,7 @@ public function testDeprecationsOnPhp81DoNotThrow(): void $config->logDeprecations = true; $config->deprecationLogLevel = 'error'; - $this->exception = new Exceptions($config, Services::request(), Services::response()); + $this->exception = new Exceptions($config); $this->exception->initialize(); // this is only needed for IDEs not to complain that strlen does not accept explicit null @@ -89,7 +88,7 @@ public function testSuppressedDeprecationsAreLogged(): void $config->logDeprecations = true; $config->deprecationLogLevel = 'error'; - $this->exception = new Exceptions($config, Services::request(), Services::response()); + $this->exception = new Exceptions($config); $this->exception->initialize(); @trigger_error('Hello! I am a deprecation!', E_USER_DEPRECATED); diff --git a/tests/system/Entity/EntityTest.php b/tests/system/Entity/EntityTest.php index be3fb0db8d2e..dadea32b5680 100644 --- a/tests/system/Entity/EntityTest.php +++ b/tests/system/Entity/EntityTest.php @@ -34,6 +34,36 @@ final class EntityTest extends CIUnitTestCase { use ReflectionHelper; + public function testSetStringToPropertyNamedAttributes() + { + $entity = $this->getEntity(); + + $entity->attributes = 'attributes'; + + $this->assertSame('attributes', $entity->attributes); + } + + /** + * @see https://github.com/codeigniter4/CodeIgniter4/issues + */ + public function testSetArrayToPropertyNamedAttributes() + { + $entity = new Entity(); + + $entity->a = 1; + $entity->attributes = [1, 2, 3]; + + $expected = [ + 'a' => 1, + 'attributes' => [ + 0 => 1, + 1 => 2, + 2 => 3, + ], + ]; + $this->assertSame($expected, $entity->toRawArray()); + } + public function testSimpleSetAndGet(): void { $entity = $this->getEntity(); @@ -52,6 +82,19 @@ public function testGetterSetters(): void $this->assertSame('bar:thanks:bar', $entity->bar); } + public function testNewGetterSetters() + { + $entity = $this->getNewSetterGetterEntity(); + + $entity->bar = 'thanks'; + + $this->assertSame('bar:thanks:bar', $entity->bar); + + $entity->setBar('BAR'); + + $this->assertSame('BAR', $entity->getBar()); + } + public function testUnsetUnsetsAttribute(): void { $entity = $this->getEntity(); @@ -1066,6 +1109,52 @@ public function getFakeBar() }; } + protected function getNewSetterGetterEntity() + { + return new class () extends Entity { + protected $attributes = [ + 'foo' => null, + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ]; + protected $original = [ + 'foo' => null, + 'bar' => null, + 'default' => 'sumfin', + 'created_at' => null, + ]; + protected $datamap = [ + 'createdAt' => 'created_at', + ]; + private string $bar; + + public function setBar($value) + { + $this->bar = $value; + + return $this; + } + + public function getBar() + { + return $this->bar; + } + + public function _setBar($value) + { + $this->attributes['bar'] = "bar:{$value}"; + + return $this; + } + + public function _getBar() + { + return "{$this->attributes['bar']}:bar"; + } + }; + } + protected function getMappedEntity() { return new class () extends Entity { diff --git a/tests/system/Filters/FiltersTest.php b/tests/system/Filters/FiltersTest.php index ae75b14af835..8e80e4d5de1d 100644 --- a/tests/system/Filters/FiltersTest.php +++ b/tests/system/Filters/FiltersTest.php @@ -12,6 +12,7 @@ namespace CodeIgniter\Filters; use CodeIgniter\Config\Services; +use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\Filters\Exceptions\FilterException; use CodeIgniter\Filters\fixtures\GoogleCurious; use CodeIgniter\Filters\fixtures\GoogleEmpty; @@ -839,6 +840,92 @@ public function testEnableFilter(): void $this->assertContains('google', $filters['before']); } + public function testFiltersWithArguments() + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $config = [ + 'aliases' => ['role' => Role::class], + 'globals' => [ + ], + 'filters' => [ + 'role:admin,super' => [ + 'before' => ['admin/*'], + 'after' => ['admin/*'], + ], + ], + ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + + $filters = $filters->initialize('admin/foo/bar'); + $found = $filters->getFilters(); + + $this->assertContains('role', $found['before']); + $this->assertSame(['admin', 'super'], $filters->getArguments('role')); + $this->assertSame(['role' => ['admin', 'super']], $filters->getArguments()); + + $response = $filters->run('admin/foo/bar', 'before'); + + $this->assertSame('admin;super', $response); + + $response = $filters->run('admin/foo/bar', 'after'); + + $this->assertSame('admin;super', $response->getBody()); + } + + public function testFilterWithArgumentsIsDefined() + { + $this->expectException(ConfigException::class); + $this->expectExceptionMessage('"role" already has arguments: admin,super'); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $config = [ + 'aliases' => ['role' => Role::class], + 'globals' => [], + 'filters' => [ + 'role:admin,super' => [ + 'before' => ['admin/*'], + ], + 'role:super' => [ + 'before' => ['admin/user/*'], + ], + ], + ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + + $filters->initialize('admin/user/bar'); + } + + public function testFilterWithoutArgumentsIsDefined() + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + $config = [ + 'aliases' => ['role' => Role::class], + 'globals' => [], + 'filters' => [ + 'role' => [ + 'before' => ['admin/*'], + ], + 'role:super' => [ + 'before' => ['admin/user/*'], + ], + ], + ]; + $filtersConfig = $this->createConfigFromArray(FiltersConfig::class, $config); + $filters = $this->createFilters($filtersConfig); + + $filters = $filters->initialize('admin/user/bar'); + $found = $filters->getFilters(); + + $this->assertContains('role', $found['before']); + $this->assertSame(['super'], $filters->getArguments('role')); + $this->assertSame(['role' => ['super']], $filters->getArguments()); + } + public function testEnableFilterWithArguments(): void { $_SERVER['REQUEST_METHOD'] = 'GET'; diff --git a/tests/system/HTTP/CURLRequestDoNotShareOptionsTest.php b/tests/system/HTTP/CURLRequestDoNotShareOptionsTest.php index 9399ed6a8f69..a0a9ffcda4c6 100644 --- a/tests/system/HTTP/CURLRequestDoNotShareOptionsTest.php +++ b/tests/system/HTTP/CURLRequestDoNotShareOptionsTest.php @@ -559,6 +559,20 @@ public function testSSLWithBadKey(): void ]); } + public function testProxyuOption() + { + $this->request->request('get', 'http://example.com', [ + 'proxy' => 'http://localhost:3128', + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_PROXY, $options); + $this->assertSame('http://localhost:3128', $options[CURLOPT_PROXY]); + $this->assertArrayHasKey(CURLOPT_HTTPPROXYTUNNEL, $options); + $this->assertTrue($options[CURLOPT_HTTPPROXYTUNNEL]); + } + public function testDebugOptionTrue(): void { $this->request->request('get', 'http://example.com', [ diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index 415032d19628..f7a1ce6fe9e7 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -542,6 +542,20 @@ public function testSSLWithBadKey(): void ]); } + public function testProxyuOption() + { + $this->request->request('get', 'http://example.com', [ + 'proxy' => 'http://localhost:3128', + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_PROXY, $options); + $this->assertSame('http://localhost:3128', $options[CURLOPT_PROXY]); + $this->assertArrayHasKey(CURLOPT_HTTPPROXYTUNNEL, $options); + $this->assertTrue($options[CURLOPT_HTTPPROXYTUNNEL]); + } + public function testDebugOptionTrue(): void { $this->request->request('get', 'http://example.com', [ diff --git a/tests/system/HTTP/DownloadResponseTest.php b/tests/system/HTTP/DownloadResponseTest.php index 6bd699375a63..975c063927e3 100644 --- a/tests/system/HTTP/DownloadResponseTest.php +++ b/tests/system/HTTP/DownloadResponseTest.php @@ -120,6 +120,14 @@ public function testSetFileName(): void $this->assertSame('attachment; filename="myFile.txt"; filename*=UTF-8\'\'myFile.txt', $response->getHeaderLine('Content-Disposition')); } + public function testDispositionInline(): void + { + $response = new DownloadResponse('unit-test.txt', true); + $response->inline(); + $response->buildHeaders(); + $this->assertSame('inline', $response->getHeaderLine('Content-Disposition')); + } + public function testNoCache(): void { $response = new DownloadResponse('unit-test.txt', true); diff --git a/tests/system/HTTP/Files/FileCollectionTest.php b/tests/system/HTTP/Files/FileCollectionTest.php index 6c9983effb84..cae7971a52e5 100644 --- a/tests/system/HTTP/Files/FileCollectionTest.php +++ b/tests/system/HTTP/Files/FileCollectionTest.php @@ -453,6 +453,41 @@ public function testErrorWithNoError(): void $this->assertSame(UPLOAD_ERR_OK, $file->getError()); } + public function testClientPathReturnsValidFullPath() + { + $_FILES = [ + 'userfile' => [ + 'name' => 'someFile.txt', + 'type' => 'text/plain', + 'size' => '124', + 'tmp_name' => '/tmp/myTempFile.txt', + 'full_path' => 'someDir/someFile.txt', + ], + ]; + + $collection = new FileCollection(); + $file = $collection->getFile('userfile'); + + $this->assertSame('someDir/someFile.txt', $file->getClientPath()); + } + + public function testClientPathReturnsNullWhenFullPathIsNull() + { + $_FILES = [ + 'userfile' => [ + 'name' => 'someFile.txt', + 'type' => 'text/plain', + 'size' => '124', + 'tmp_name' => '/tmp/myTempFile.txt', + ], + ]; + + $collection = new FileCollection(); + $file = $collection->getFile('userfile'); + + $this->assertNull($file->getClientPath()); + } + public function testFileReturnsValidSingleFile(): void { $_FILES = [ diff --git a/tests/system/HTTP/IncomingRequestTest.php b/tests/system/HTTP/IncomingRequestTest.php index 5224b5f5ea03..3855a4b85c94 100644 --- a/tests/system/HTTP/IncomingRequestTest.php +++ b/tests/system/HTTP/IncomingRequestTest.php @@ -11,6 +11,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Config\Factories; use CodeIgniter\Exceptions\ConfigException; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\Files\UploadedFile; @@ -34,11 +35,22 @@ protected function setUp(): void { parent::setUp(); - $this->request = new IncomingRequest(new App(), new URI(), null, new UserAgent()); + $config = new App(); + $this->request = $this->createRequest($config); $_POST = $_GET = $_SERVER = $_REQUEST = $_ENV = $_COOKIE = $_SESSION = []; } + private function createRequest(?App $config = null, $body = null, ?string $path = null): IncomingRequest + { + $config ??= new App(); + $path ??= ''; + + $uri = new SiteURI($config, $path); + + return new IncomingRequest($config, $uri, $body, new UserAgent()); + } + public function testCanGrabRequestVars(): void { $_REQUEST['TEST'] = 5; @@ -185,7 +197,7 @@ public function testSetLocaleSaves(): void $config->defaultLocale = 'es'; $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), null, new UserAgent()); + $request = $this->createRequest($config); $request->setLocale('en'); $this->assertSame('en', $request->getLocale()); @@ -198,12 +210,27 @@ public function testSetBadLocale(): void $config->defaultLocale = 'es'; $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), null, new UserAgent()); + $request = $this->createRequest($config); $request->setLocale('xx'); $this->assertSame('es', $request->getLocale()); } + public function testSetValidLocales() + { + $config = new App(); + $config->supportedLocales = ['en', 'es']; + $config->defaultLocale = 'es'; + $config->baseURL = 'http://example.com/'; + + $request = new IncomingRequest($config, new URI(), null, new UserAgent()); + + $request->setValidLocales(['ja']); + $request->setLocale('ja'); + + $this->assertSame('ja', $request->getLocale()); + } + /** * @see https://github.com/codeigniter4/CodeIgniter4/issues/2774 */ @@ -216,7 +243,7 @@ public function testNegotiatesLocale(): void $config->supportedLocales = ['fr', 'en']; $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), null, new UserAgent()); + $request = $this->createRequest($config); $this->assertSame($config->defaultLocale, $request->getDefaultLocale()); $this->assertSame('fr', $request->getLocale()); @@ -231,7 +258,7 @@ public function testNegotiatesLocaleOnlyBroad(): void $config->supportedLocales = ['fr', 'en']; $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), null, new UserAgent()); + $request = $this->createRequest($config); $this->assertSame($config->defaultLocale, $request->getDefaultLocale()); $this->assertSame('fr', $request->getLocale()); @@ -290,7 +317,7 @@ public function testCanGrabGetRawJSON(): void $config = new App(); $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); + $request = $this->createRequest($config, $json); $this->assertSame($expected, $request->getJSON(true)); } @@ -311,7 +338,7 @@ public function testCanGetAVariableFromJson(): void $config = new App(); $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); + $request = $this->createRequest($config, $json); $this->assertSame('bar', $request->getJsonVar('foo')); $this->assertNull($request->getJsonVar('notExists')); @@ -345,7 +372,7 @@ public function testGetJsonVarAsArray(): void $config = new App(); $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); + $request = $this->createRequest($config, $json); $jsonVar = $request->getJsonVar('baz', true); $this->assertIsArray($jsonVar); @@ -365,7 +392,7 @@ public function testGetJsonVarCanFilter(): void $config = new App(); $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); + $request = $this->createRequest($config, $json); $this->assertFalse($request->getJsonVar('foo', false, FILTER_VALIDATE_INT)); } @@ -386,7 +413,7 @@ public function testGetJsonVarCanFilterArray(): void $config = new App(); $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); + $request = $this->createRequest($config, $json); $request->setHeader('Content-Type', 'application/json'); $expected = [ @@ -430,7 +457,7 @@ public function testGetVarWorksWithJson(): void $config = new App(); $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); + $request = $this->createRequest($config, $json); $request->setHeader('Content-Type', 'application/json'); $this->assertSame('bar', $request->getVar('foo')); @@ -457,12 +484,13 @@ public function testGetVarWorksWithJsonAndGetParams(): void $_REQUEST['foo'] = 'bar'; $_REQUEST['fizz'] = 'buzz'; - $request = new IncomingRequest($config, new URI('http://example.com/path?foo=bar&fizz=buzz'), 'php://input', new UserAgent()); + $request = $this->createRequest($config, null); $request = $request->withMethod('GET'); // JSON type $request->setHeader('Content-Type', 'application/json'); + // The body is null, so this works. $this->assertSame('bar', $request->getVar('foo')); $this->assertSame('buzz', $request->getVar('fizz')); @@ -485,7 +513,7 @@ public function testGetJsonVarReturnsNullFromNullBody(): void $config = new App(); $config->baseURL = 'http://example.com/'; $json = null; - $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); + $request = $this->createRequest($config, $json); $this->assertNull($request->getJsonVar('myKey')); } @@ -495,7 +523,7 @@ public function testgetJSONReturnsNullFromNullBody(): void $config = new App(); $config->baseURL = 'http://example.com/'; $json = null; - $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); + $request = $this->createRequest($config, $json); $this->assertNull($request->getJSON()); } @@ -513,7 +541,7 @@ public function testCanGrabGetRawInput(): void $config = new App(); $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), $rawstring, new UserAgent()); + $request = $this->createRequest($config, $rawstring); $this->assertSame($expected, $request->getRawInput()); } @@ -611,7 +639,7 @@ public function testCanGrabGetRawInputVar($rawstring, $var, $expected, $filter, $config = new App(); $config->baseURL = 'http://example.com/'; - $request = new IncomingRequest($config, new URI(), $rawstring, new UserAgent()); + $request = $this->createRequest($config, $rawstring); $this->assertSame($expected, $request->getRawInputVar($var, $filter, $flag)); } @@ -707,7 +735,7 @@ public function testUserAgent(): void $_SERVER['HTTP_USER_AGENT'] = 'Mozilla'; $config = new App(); - $request = new IncomingRequest($config, new URI(), null, new UserAgent()); + $request = $this->createRequest($config); $this->assertSame('Mozilla', $request->getUserAgent()->__toString()); } @@ -822,7 +850,7 @@ public function testGetPostSecondStreams(): void public function testWithFalseBody(): void { // Use `false` here to simulate file_get_contents returning a false value - $request = new IncomingRequest(new App(), new URI(), false, new UserAgent()); + $request = $this->createRequest(null, false); $this->assertNotFalse($request->getBody()); $this->assertNull($request->getBody()); @@ -872,49 +900,11 @@ public function testExtensionPHP($path, $detectPath): void public function testGetPath(): void { - $_SERVER['REQUEST_URI'] = '/index.php/fruits/banana'; - $_SERVER['SCRIPT_NAME'] = '/index.php'; - - $request = new IncomingRequest(new App(), new URI(), null, new UserAgent()); - - $this->assertSame('fruits/banana', $request->getPath()); - } - - public function testGetPathIsRelative(): void - { - $_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->assertSame('fruits/banana', $request->getPath()); - } - - public function testGetPathStoresDetectedValue(): void - { - $_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 = $this->createRequest(null, null, 'fruits/banana'); $this->assertSame('fruits/banana', $request->getPath()); } - public function testGetPathIsRediscovered(): void - { - $_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->assertSame('candy/snickers', $request->getPath()); - } - public function testSetPath(): void { $request = new IncomingRequest(new App(), new URI(), null, new UserAgent()); @@ -924,15 +914,6 @@ public function testSetPath(): void $this->assertSame('foobar', $request->getPath()); } - public function testSetPathUpdatesURI(): void - { - $request = new IncomingRequest(new App(), new URI(), null, new UserAgent()); - - $request->setPath('apples'); - - $this->assertSame('apples', $request->getUri()->getPath()); - } - public function testGetIPAddressNormal(): void { $expected = '123.123.123.123'; @@ -957,7 +938,8 @@ public function testGetIPAddressThruProxy(): void '10.0.1.200' => 'X-Forwarded-For', '192.168.5.0/24' => 'X-Forwarded-For', ]; - $this->request = new Request($config); + Factories::injectMock('config', App::class, $config); + $this->request = new Request(); $this->request->populateHeaders(); // we should see the original forwarded address @@ -974,7 +956,8 @@ public function testGetIPAddressThruProxyIPv6(): void $config->proxyIPs = [ '2001:db8::2:1' => 'X-Forwarded-For', ]; - $this->request = new Request($config); + Factories::injectMock('config', App::class, $config); + $this->request = new Request(); $this->request->populateHeaders(); // we should see the original forwarded address @@ -1059,7 +1042,8 @@ public function testGetIPAddressThruProxySubnet(): void $config = new App(); $config->proxyIPs = ['192.168.5.0/24' => 'X-Forwarded-For']; - $this->request = new Request($config); + Factories::injectMock('config', App::class, $config); + $this->request = new Request(); $this->request->populateHeaders(); // we should see the original forwarded address @@ -1074,7 +1058,8 @@ public function testGetIPAddressThruProxySubnetIPv6(): void $config = new App(); $config->proxyIPs = ['2001:db8:1234::/48' => 'X-Forwarded-For']; - $this->request = new Request($config); + Factories::injectMock('config', App::class, $config); + $this->request = new Request(); $this->request->populateHeaders(); // we should see the original forwarded address @@ -1150,7 +1135,8 @@ public function testGetIPAddressThruProxyInvalidConfigArray(): void $config = new App(); $config->proxyIPs = ['192.168.5.0/28']; - $this->request = new Request($config); + Factories::injectMock('config', App::class, $config); + $this->request = new Request(); $this->request->populateHeaders(); $this->request->getIPAddress(); diff --git a/tests/system/HTTP/NegotiateTest.php b/tests/system/HTTP/NegotiateTest.php index 4323e80c2b42..0784bd562b54 100644 --- a/tests/system/HTTP/NegotiateTest.php +++ b/tests/system/HTTP/NegotiateTest.php @@ -29,7 +29,8 @@ protected function setUp(): void { parent::setUp(); - $this->request = new IncomingRequest(new App(), new URI(), null, new UserAgent()); + $config = new App(); + $this->request = new IncomingRequest($config, new SiteURI($config), null, new UserAgent()); $this->negotiate = new Negotiate($this->request); } diff --git a/tests/system/HTTP/RedirectExceptionTest.php b/tests/system/HTTP/RedirectExceptionTest.php new file mode 100644 index 000000000000..acf91a392582 --- /dev/null +++ b/tests/system/HTTP/RedirectExceptionTest.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\HTTP\Exceptions\RedirectException; +use CodeIgniter\Log\Logger; +use CodeIgniter\Test\Mock\MockLogger as LoggerConfig; +use Config\Services; +use LogicException; +use PHPUnit\Framework\TestCase; +use Tests\Support\Log\Handlers\TestHandler; + +/** + * @internal + * + * @group Others + */ +final class RedirectExceptionTest extends TestCase +{ + protected function setUp(): void + { + Services::reset(); + Services::injectMock('logger', new Logger(new LoggerConfig())); + } + + public function testResponse(): void + { + $response = (new RedirectException( + Services::response() + ->redirect('redirect') + ->setCookie('cookie', 'value') + ->setHeader('Redirect-Header', 'value') + ))->getResponse(); + + $this->assertSame('redirect', $response->getHeaderLine('location')); + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('value', $response->getHeaderLine('Redirect-Header')); + $this->assertSame('value', $response->getCookie('cookie')->getValue()); + } + + public function testResponseWithoutLocation(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'The Response object passed to RedirectException does not contain a redirect address.' + ); + + new RedirectException(Services::response()); + } + + public function testResponseWithoutStatusCode(): void + { + $response = (new RedirectException(Services::response()->setHeader('Location', 'location')))->getResponse(); + + $this->assertSame('location', $response->getHeaderLine('location')); + $this->assertSame(302, $response->getStatusCode()); + } + + public function testLoggingLocationHeader(): void + { + $uri = 'http://location'; + $expected = 'INFO - ' . date('Y-m-d') . ' --> REDIRECTED ROUTE at ' . $uri; + $response = (new RedirectException(Services::response()->redirect($uri)))->getResponse(); + + $logs = TestHandler::getLogs(); + + $this->assertSame($uri, $response->getHeaderLine('Location')); + $this->assertSame('', $response->getHeaderLine('Refresh')); + $this->assertSame($expected, $logs[0]); + } + + public function testLoggingRefreshHeader(): void + { + $uri = 'http://location'; + $expected = 'INFO - ' . date('Y-m-d') . ' --> REDIRECTED ROUTE at ' . $uri; + $response = (new RedirectException(Services::response()->redirect($uri, 'refresh')))->getResponse(); + + $logs = TestHandler::getLogs(); + + $this->assertSame($uri, substr($response->getHeaderLine('Refresh'), 6)); + $this->assertSame('', $response->getHeaderLine('Location')); + $this->assertSame($expected, $logs[0]); + } +} diff --git a/tests/system/HTTP/RedirectResponseTest.php b/tests/system/HTTP/RedirectResponseTest.php index c775da828865..a15117e9324a 100644 --- a/tests/system/HTTP/RedirectResponseTest.php +++ b/tests/system/HTTP/RedirectResponseTest.php @@ -19,6 +19,7 @@ use CodeIgniter\Validation\Validation; use Config\App; use Config\Modules; +use Config\Routing; use Config\Services; /** @@ -47,12 +48,12 @@ protected function setUp(): void $this->config = new App(); $this->config->baseURL = 'http://example.com/'; - $this->routes = new RouteCollection(Services::locator(), new Modules()); + $this->routes = new RouteCollection(Services::locator(), new Modules(), new Routing()); Services::injectMock('routes', $this->routes); $this->request = new MockIncomingRequest( $this->config, - new URI('http://example.com'), + new SiteURI($this->config), null, new UserAgent() ); @@ -185,7 +186,8 @@ public function testWith(): void public function testRedirectBack(): void { $_SERVER['HTTP_REFERER'] = 'http://somewhere.com'; - $this->request = new MockIncomingRequest($this->config, new URI('http://somewhere.com'), null, new UserAgent()); + + $this->request = new MockIncomingRequest($this->config, new SiteURI($this->config), null, new UserAgent()); Services::injectMock('request', $this->request); $response = new RedirectResponse(new App()); @@ -221,7 +223,7 @@ public function testRedirectRouteBaseUrl(): void $config->baseURL = 'http://example.com/test/'; Factories::injectMock('config', 'App', $config); - $request = new MockIncomingRequest($config, new URI('http://example.com/test/'), null, new UserAgent()); + $request = new MockIncomingRequest($config, new SiteURI($config), null, new UserAgent()); Services::injectMock('request', $request); $response = new RedirectResponse(new App()); diff --git a/tests/system/HTTP/RequestTest.php b/tests/system/HTTP/RequestTest.php index 0b0714c38a20..621227c4b888 100644 --- a/tests/system/HTTP/RequestTest.php +++ b/tests/system/HTTP/RequestTest.php @@ -11,6 +11,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Config\Factories; use CodeIgniter\Test\CIUnitTestCase; use Config\App; @@ -616,7 +617,8 @@ public function testGetIPAddressThruProxy(): void '10.0.1.200' => 'X-Forwarded-For', '192.168.5.0/24' => 'X-Forwarded-For', ]; - $this->request = new Request($config); + Factories::injectMock('config', App::class, $config); + $this->request = new Request(); $this->request->populateHeaders(); // we should see the original forwarded address @@ -667,7 +669,8 @@ public function testGetIPAddressThruProxySubnet(): void $config = new App(); $config->proxyIPs = ['192.168.5.0/24' => 'X-Forwarded-For']; - $this->request = new Request($config); + Factories::injectMock('config', App::class, $config); + $this->request = new Request(); $this->request->populateHeaders(); // we should see the original forwarded address diff --git a/tests/system/HTTP/ResponseTest.php b/tests/system/HTTP/ResponseTest.php index a66dc06f9bc4..e08dc1de62a3 100644 --- a/tests/system/HTTP/ResponseTest.php +++ b/tests/system/HTTP/ResponseTest.php @@ -12,7 +12,6 @@ namespace CodeIgniter\HTTP; use CodeIgniter\Config\Factories; -use CodeIgniter\Cookie\Exceptions\CookieException; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockResponse; @@ -173,7 +172,7 @@ public function testSetLink(): void $response->setLink($pager); $this->assertSame( - '; rel="first",; rel="prev",; rel="next",; rel="last"', + '; rel="first",; rel="prev",; rel="next",; rel="last"', $response->header('Link')->getValue() ); @@ -181,7 +180,7 @@ public function testSetLink(): void $response->setLink($pager); $this->assertSame( - '; rel="next",; rel="last"', + '; rel="next",; rel="last"', $response->header('Link')->getValue() ); @@ -189,7 +188,7 @@ public function testSetLink(): void $response->setLink($pager); $this->assertSame( - '; rel="first",; rel="prev"', + '; rel="first",; rel="prev"', $response->header('Link')->getValue() ); } @@ -573,14 +572,4 @@ public function testPretendOutput(): void $this->assertSame('Happy days', $actual); } - - public function testInvalidSameSiteCookie(): void - { - $config = new App(); - $config->cookieSameSite = 'Invalid'; - - $this->expectException(CookieException::class); - $this->expectExceptionMessage(lang('Cookie.invalidSameSite', ['Invalid'])); - new Response($config); - } } diff --git a/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php new file mode 100644 index 000000000000..34b242b57ffa --- /dev/null +++ b/tests/system/HTTP/SiteURIFactoryDetectRoutePathTest.php @@ -0,0 +1,312 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Superglobals; +use CodeIgniter\Test\CIUnitTestCase; +use Config\App; + +/** + * @backupGlobals enabled + * + * @internal + * + * @group Others + */ +final class SiteURIFactoryDetectRoutePathTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $_GET = $_SERVER = []; + } + + private function createSiteURIFactory(array $server, ?App $appConfig = null): SiteURIFactory + { + $appConfig ??= new App(); + + $_SERVER = $server; + $superglobals = new Superglobals(); + + return new SiteURIFactory($appConfig, $superglobals); + } + + public function testDefault() + { + // /index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath()); + } + + public function testDefaultEmpty() + { + // / + $_SERVER['REQUEST_URI'] = '/'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = '/'; + $this->assertSame($expected, $factory->detectRoutePath()); + } + + public function testRequestURI() + { + // /index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURINested() + { + // I'm not sure but this is a case of Apache config making such SERVER + // values? + // The current implementation doesn't use the value of the URI object. + // So I removed the code to set URI. Therefore, it's exactly the same as + // the method above as a test. + // But it may be changed in the future to use the value of the URI object. + // So I don't remove this test case. + + // /ci/index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURISubfolder() + { + // /ci/index.php/popcorn/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/ci/index.php/popcorn/woot'; + $_SERVER['SCRIPT_NAME'] = '/ci/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'popcorn/woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURINoIndex() + { + // /sub/example + $_SERVER['REQUEST_URI'] = '/sub/example'; + $_SERVER['SCRIPT_NAME'] = '/sub/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'example'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURINginx() + { + // /ci/index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot?code=good'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURINginxRedirecting() + { + // /?/ci/index.php/woot + $_SERVER['REQUEST_URI'] = '/?/ci/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'ci/woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURISuppressed() + { + // /woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/woot'; + $_SERVER['SCRIPT_NAME'] = '/'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURIGetPath() + { + // /index.php/fruits/banana + $_SERVER['REQUEST_URI'] = '/index.php/fruits/banana'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $this->assertSame('fruits/banana', $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURIPathIsRelative() + { + // /sub/folder/index.php/fruits/banana + $_SERVER['REQUEST_URI'] = '/sub/folder/index.php/fruits/banana'; + $_SERVER['SCRIPT_NAME'] = '/sub/folder/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $this->assertSame('fruits/banana', $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURIStoresDetectedPath() + { + // /fruits/banana + $_SERVER['REQUEST_URI'] = '/fruits/banana'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $_SERVER['REQUEST_URI'] = '/candy/snickers'; + + $this->assertSame('fruits/banana', $factory->detectRoutePath('REQUEST_URI')); + } + + public function testRequestURIPathIsNeverRediscovered() + { + $_SERVER['REQUEST_URI'] = '/fruits/banana'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $_SERVER['REQUEST_URI'] = '/candy/snickers'; + $factory->detectRoutePath('REQUEST_URI'); + + $this->assertSame('fruits/banana', $factory->detectRoutePath('REQUEST_URI')); + } + + public function testQueryString() + { + // /index.php?/ci/woot + $_SERVER['REQUEST_URI'] = '/index.php?/ci/woot'; + $_SERVER['QUERY_STRING'] = '/ci/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $_GET['/ci/woot'] = ''; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'ci/woot'; + $this->assertSame($expected, $factory->detectRoutePath('QUERY_STRING')); + } + + public function testQueryStringWithQueryString() + { + // /index.php?/ci/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php?/ci/woot?code=good'; + $_SERVER['QUERY_STRING'] = '/ci/woot?code=good'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $_GET['/ci/woot?code'] = 'good'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'ci/woot'; + $this->assertSame($expected, $factory->detectRoutePath('QUERY_STRING')); + $this->assertSame('code=good', $_SERVER['QUERY_STRING']); + $this->assertSame(['code' => 'good'], $_GET); + } + + public function testQueryStringEmpty() + { + // /index.php? + $_SERVER['REQUEST_URI'] = '/index.php?'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = '/'; + $this->assertSame($expected, $factory->detectRoutePath('QUERY_STRING')); + } + + public function testPathInfoUnset() + { + // /index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('PATH_INFO')); + } + + public function testPathInfoSubfolder() + { + $appConfig = new App(); + $appConfig->baseURL = 'http://localhost:8888/ci431/public/'; + + // http://localhost:8888/ci431/public/index.php/woot?code=good#pos + $_SERVER['PATH_INFO'] = '/woot'; + $_SERVER['REQUEST_URI'] = '/ci431/public/index.php/woot?code=good'; + $_SERVER['SCRIPT_NAME'] = '/ci431/public/index.php'; + + $factory = $this->createSiteURIFactory($_SERVER, $appConfig); + + $expected = 'woot'; + $this->assertSame($expected, $factory->detectRoutePath('PATH_INFO')); + } + + /** + * @dataProvider provideExtensionPHP + * + * @param string $path + * @param string $detectPath + */ + public function testExtensionPHP($path, $detectPath) + { + $config = new App(); + $config->baseURL = 'http://example.com/'; + + $_SERVER['REQUEST_URI'] = $path; + $_SERVER['SCRIPT_NAME'] = $path; + + $factory = $this->createSiteURIFactory($_SERVER, $config); + + $this->assertSame($detectPath, $factory->detectRoutePath()); + } + + public static function provideExtensionPHP(): iterable + { + return [ + 'not /index.php' => [ + '/test.php', + '/', + ], + '/index.php' => [ + '/index.php', + '/', + ], + ]; + } +} diff --git a/tests/system/HTTP/SiteURIFactoryTest.php b/tests/system/HTTP/SiteURIFactoryTest.php new file mode 100644 index 000000000000..c7522c8cf089 --- /dev/null +++ b/tests/system/HTTP/SiteURIFactoryTest.php @@ -0,0 +1,175 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use CodeIgniter\Superglobals; +use CodeIgniter\Test\CIUnitTestCase; +use Config\App; + +/** + * @backupGlobals enabled + * + * @internal + * + * @group Others + */ +final class SiteURIFactoryTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $_GET = $_SERVER = []; + } + + private function createSiteURIFactory(?App $config = null, ?Superglobals $superglobals = null): SiteURIFactory + { + $config ??= new App(); + $superglobals ??= new Superglobals(); + + return new SiteURIFactory($config, $superglobals); + } + + public function testCreateFromGlobals() + { + // http://localhost:8080/index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot?code=good'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['QUERY_STRING'] = 'code=good'; + $_SERVER['HTTP_HOST'] = 'localhost:8080'; + $_SERVER['PATH_INFO'] = '/woot'; + + $_GET['code'] = 'good'; + + $factory = $this->createSiteURIFactory(); + + $uri = $factory->createFromGlobals(); + + $this->assertInstanceOf(SiteURI::class, $uri); + $this->assertSame('http://localhost:8080/index.php/woot?code=good', (string) $uri); + $this->assertSame('/index.php/woot', $uri->getPath()); + $this->assertSame('woot', $uri->getRoutePath()); + } + + public function testCreateFromGlobalsAllowedHost() + { + // http://users.example.jp/index.php/woot?code=good#pos + $_SERVER['REQUEST_URI'] = '/index.php/woot?code=good'; + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $_SERVER['QUERY_STRING'] = 'code=good'; + $_SERVER['HTTP_HOST'] = 'users.example.jp'; + $_SERVER['PATH_INFO'] = '/woot'; + + $_GET['code'] = 'good'; + + $config = new App(); + $config->baseURL = 'http://example.jp/'; + $config->allowedHostnames = ['users.example.jp']; + + $factory = $this->createSiteURIFactory($config); + + $uri = $factory->createFromGlobals(); + + $this->assertInstanceOf(SiteURI::class, $uri); + $this->assertSame('http://users.example.jp/index.php/woot?code=good', (string) $uri); + $this->assertSame('/index.php/woot', $uri->getPath()); + $this->assertSame('woot', $uri->getRoutePath()); + } + + /** + * @dataProvider provideCreateFromStringWithIndexPage + */ + public function testCreateFromStringWithIndexPage( + string $uriString, + string $expectUriString, + string $expectedPath, + string $expectedRoutePath + ) { + $factory = $this->createSiteURIFactory(); + + $uri = $factory->createFromString($uriString); + + $this->assertInstanceOf(SiteURI::class, $uri); + $this->assertSame($expectUriString, (string) $uri); + $this->assertSame($expectedPath, $uri->getPath()); + $this->assertSame($expectedRoutePath, $uri->getRoutePath()); + } + + public static function provideCreateFromStringWithIndexPage(): iterable + { + return [ + 'indexPage path query' => [ + 'http://invalid.example.jp/foo/bar?page=3', // $uriString + 'http://localhost:8080/index.php/foo/bar?page=3', // $expectUriString + '/index.php/foo/bar', // $expectedPath + 'foo/bar', // $expectedRoutePath + ], + 'indexPage noPath' => [ + 'http://localhost:8080', // $uriString + 'http://localhost:8080/index.php', // $expectUriString + '/index.php', // $expectedPath + '', // $expectedRoutePath + ], + 'indexPage slash' => [ + 'http://localhost:8080/', // $uriString + 'http://localhost:8080/index.php/', // $expectUriString + '/index.php/', // $expectedPath + '', // $expectedRoutePath + ], + ]; + } + + /** + * @dataProvider provideCreateFromStringWithoutIndexPage + */ + public function testCreateFromStringWithoutIndexPage( + string $uriString, + string $expectUriString, + string $expectedPath, + string $expectedRoutePath + ) { + $config = new App(); + $config->indexPage = ''; + $factory = $this->createSiteURIFactory($config); + + $uri = $factory->createFromString($uriString); + + $this->assertInstanceOf(SiteURI::class, $uri); + $this->assertSame($expectUriString, (string) $uri); + $this->assertSame($expectedPath, $uri->getPath()); + $this->assertSame($expectedRoutePath, $uri->getRoutePath()); + } + + public static function provideCreateFromStringWithoutIndexPage(): iterable + { + return [ + 'path query' => [ + 'http://invalid.example.jp/foo/bar?page=3', // $uriString + 'http://localhost:8080/foo/bar?page=3', // $expectUriString + '/foo/bar', // $expectedPath + 'foo/bar', // $expectedRoutePath + ], + 'noPath' => [ + 'http://localhost:8080', // $uriString + 'http://localhost:8080/', // $expectUriString + '/', // $expectedPath + '', // $expectedRoutePath + ], + 'slash' => [ + 'http://localhost:8080/', // $uriString + 'http://localhost:8080/', // $expectUriString + '/', // $expectedPath + '', // $expectedRoutePath + ], + ]; + } +} diff --git a/tests/system/HTTP/SiteURITest.php b/tests/system/HTTP/SiteURITest.php new file mode 100644 index 000000000000..f839b51c0771 --- /dev/null +++ b/tests/system/HTTP/SiteURITest.php @@ -0,0 +1,503 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HTTP; + +use BadMethodCallException; +use CodeIgniter\Exceptions\ConfigException; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\Test\CIUnitTestCase; +use Config\App; + +/** + * @backupGlobals enabled + * + * @internal + * + * @group Others + */ +final class SiteURITest extends CIUnitTestCase +{ + /** + * @dataProvider provideConstructor + */ + public function testConstructor( + string $baseURL, + string $indexPage, + string $relativePath, + string $expectedURI, + string $expectedRoutePath, + string $expectedPath, + string $expectedQuery, + string $expectedFragment, + array $expectedSegments, + int $expectedTotalSegments + ) { + $config = new App(); + $config->indexPage = $indexPage; + $config->baseURL = $baseURL; + + $uri = new SiteURI($config, $relativePath); + + $this->assertInstanceOf(SiteURI::class, $uri); + + $this->assertSame($expectedURI, (string) $uri); + $this->assertSame($expectedRoutePath, $uri->getRoutePath()); + $this->assertSame($expectedPath, $uri->getPath()); + $this->assertSame($expectedQuery, $uri->getQuery()); + $this->assertSame($expectedFragment, $uri->getFragment()); + $this->assertSame($baseURL, $uri->getBaseURL()); + + $this->assertSame($expectedSegments, $uri->getSegments()); + $this->assertSame($expectedTotalSegments, $uri->getTotalSegments()); + } + + public function provideConstructor(): iterable + { + return array_merge($this->provideSetPath(), $this->provideRelativePathWithQueryOrFragment()); + } + + public static function provideSetPath(): iterable + { + return [ + '' => [ + 'http://example.com/', // $baseURL + 'index.php', // $indexPage + '', // $relativePath + 'http://example.com/index.php', // $expectedURI + '', // $expectedRoutePath + '/index.php', // $expectedPath + '', // $expectedQuery + '', // $expectedFragment + [], // $expectedSegments + 0, // $expectedTotalSegments + ], + '/' => [ + 'http://example.com/', + 'index.php', + '/', + 'http://example.com/index.php/', + '', + '/index.php/', + '', + '', + [], + 0, + ], + 'one/two' => [ + 'http://example.com/', + 'index.php', + 'one/two', + 'http://example.com/index.php/one/two', + 'one/two', + '/index.php/one/two', '', + '', + ['one', 'two'], + 2, + ], + '/one/two' => [ + 'http://example.com/', + 'index.php', + '/one/two', + 'http://example.com/index.php/one/two', + 'one/two', + '/index.php/one/two', + '', + '', + ['one', 'two'], + 2, + ], + '/one/two/' => [ + 'http://example.com/', + 'index.php', + '/one/two/', + 'http://example.com/index.php/one/two/', + 'one/two/', + '/index.php/one/two/', + '', + '', + ['one', 'two'], + 2, + ], + '//one/two' => [ + 'http://example.com/', + 'index.php', + '//one/two', + 'http://example.com/index.php/one/two', + 'one/two', + '/index.php/one/two', + '', + '', + ['one', 'two'], + 2, + ], + 'one/two//' => [ + 'http://example.com/', + 'index.php', + 'one/two//', + 'http://example.com/index.php/one/two/', + 'one/two/', + '/index.php/one/two/', + '', + '', + ['one', 'two'], + 2, + ], + '///one///two///' => [ + 'http://example.com/', + 'index.php', + '///one///two///', + 'http://example.com/index.php/one/two/', + 'one/two/', + '/index.php/one/two/', + '', + '', + ['one', 'two'], + 2, + ], + 'Subfolder: ' => [ + 'http://example.com/ci4/', + 'index.php', + '', + 'http://example.com/ci4/index.php', + '', + '/ci4/index.php', + '', + '', + [], + 0, + ], + 'Subfolder: one/two' => [ + 'http://example.com/ci4/', + 'index.php', + 'one/two', + 'http://example.com/ci4/index.php/one/two', + 'one/two', + '/ci4/index.php/one/two', + '', + '', + ['one', 'two'], + 2, + ], + 'EmptyIndexPage: ' => [ + 'http://example.com/', + '', + '', + 'http://example.com/', + '', + '/', + '', + '', + [], + 0, + ], + 'EmptyIndexPage: /' => [ + 'http://example.com/', + '', + '/', + 'http://example.com/', + '', + '/', + '', + '', + [], + 0, + ], + ]; + } + + public function provideRelativePathWithQueryOrFragment() + { + return [ + 'one/two?foo=1&bar=2' => [ + 'http://example.com/', // $baseURL + 'index.php', // $indexPage + 'one/two?foo=1&bar=2', // $relativePath + 'http://example.com/index.php/one/two?foo=1&bar=2', // $expectedURI + 'one/two', // $expectedRoutePath + '/index.php/one/two', // $expectedPath + 'foo=1&bar=2', // $expectedQuery + '', // $expectedFragment + ['one', 'two'], // $expectedSegments + 2, // $expectedTotalSegments + ], + 'one/two#sec1' => [ + 'http://example.com/', + 'index.php', + 'one/two#sec1', + 'http://example.com/index.php/one/two#sec1', + 'one/two', + '/index.php/one/two', + '', + 'sec1', + ['one', 'two'], + 2, + ], + 'one/two?foo=1&bar=2#sec1' => [ + 'http://example.com/', + 'index.php', + 'one/two?foo=1&bar=2#sec1', + 'http://example.com/index.php/one/two?foo=1&bar=2#sec1', + 'one/two', + '/index.php/one/two', + 'foo=1&bar=2', + 'sec1', + ['one', 'two'], + 2, + ], + 'Subfolder: one/two?foo=1&bar=2' => [ + 'http://example.com/ci4/', + 'index.php', + 'one/two?foo=1&bar=2', + 'http://example.com/ci4/index.php/one/two?foo=1&bar=2', + 'one/two', + '/ci4/index.php/one/two', + 'foo=1&bar=2', + '', + ['one', 'two'], + 2, + ], + ]; + } + + public function testConstructorHost() + { + $config = new App(); + $config->allowedHostnames = ['sub.example.com']; + + $uri = new SiteURI($config, '', 'sub.example.com'); + + $this->assertInstanceOf(SiteURI::class, $uri); + $this->assertSame('http://sub.example.com/index.php', (string) $uri); + $this->assertSame('', $uri->getRoutePath()); + $this->assertSame('/index.php', $uri->getPath()); + $this->assertSame('http://sub.example.com/', $uri->getBaseURL()); + } + + public function testConstructorScheme() + { + $config = new App(); + + $uri = new SiteURI($config, '', null, 'https'); + + $this->assertInstanceOf(SiteURI::class, $uri); + $this->assertSame('https://example.com/index.php', (string) $uri); + $this->assertSame('https://example.com/', $uri->getBaseURL()); + } + + public function testConstructorEmptyScheme() + { + $config = new App(); + + $uri = new SiteURI($config, '', null, ''); + + $this->assertInstanceOf(SiteURI::class, $uri); + $this->assertSame('http://example.com/index.php', (string) $uri); + $this->assertSame('http://example.com/', $uri->getBaseURL()); + } + + public function testConstructorForceGlobalSecureRequests() + { + $config = new App(); + $config->forceGlobalSecureRequests = true; + + $uri = new SiteURI($config); + + $this->assertSame('https://example.com/index.php', (string) $uri); + $this->assertSame('https://example.com/', $uri->getBaseURL()); + } + + public function testConstructorInvalidBaseURL() + { + $this->expectException(ConfigException::class); + + $config = new App(); + $config->baseURL = 'invalid'; + + new SiteURI($config); + } + + /** + * @dataProvider provideSetPath + */ + public function testSetPath( + string $baseURL, + string $indexPage, + string $relativePath, + string $expectedURI, + string $expectedRoutePath, + string $expectedPath, + string $expectedQuery, + string $expectedFragment, + array $expectedSegments, + int $expectedTotalSegments + ) { + $config = new App(); + $config->indexPage = $indexPage; + $config->baseURL = $baseURL; + + $uri = new SiteURI($config); + + $uri->setPath($relativePath); + + $this->assertSame($expectedURI, (string) $uri); + $this->assertSame($expectedRoutePath, $uri->getRoutePath()); + $this->assertSame($expectedPath, $uri->getPath()); + $this->assertSame($expectedQuery, $uri->getQuery()); + $this->assertSame($expectedFragment, $uri->getFragment()); + $this->assertSame($baseURL, $uri->getBaseURL()); + + $this->assertSame($expectedSegments, $uri->getSegments()); + $this->assertSame($expectedTotalSegments, $uri->getTotalSegments()); + } + + public function testSetSegment() + { + $config = new App(); + + $uri = new SiteURI($config); + $uri->setPath('test/method'); + + $uri->setSegment(1, 'one'); + + $this->assertSame('http://example.com/index.php/one/method', (string) $uri); + $this->assertSame('one/method', $uri->getRoutePath()); + $this->assertSame('/index.php/one/method', $uri->getPath()); + $this->assertSame(['one', 'method'], $uri->getSegments()); + $this->assertSame('one', $uri->getSegment(1)); + $this->assertSame(2, $uri->getTotalSegments()); + } + + public function testSetSegmentOutOfRange() + { + $this->expectException(HTTPException::class); + + $config = new App(); + $uri = new SiteURI($config); + $uri->setPath('test/method'); + + $uri->setSegment(4, 'four'); + } + + public function testSetSegmentSilentOutOfRange() + { + $config = new App(); + $uri = new SiteURI($config); + $uri->setPath('one/method'); + $uri->setSilent(); + + $uri->setSegment(4, 'four'); + $this->assertSame(['one', 'method'], $uri->getSegments()); + } + + public function testSetSegmentZero() + { + $this->expectException(HTTPException::class); + + $config = new App(); + $uri = new SiteURI($config); + $uri->setPath('test/method'); + + $uri->setSegment(0, 'four'); + } + + public function testSetSegmentSubfolder() + { + $config = new App(); + $config->baseURL = 'http://example.com/ci4/'; + + $uri = new SiteURI($config); + $uri->setPath('test/method'); + + $uri->setSegment(1, 'one'); + + $this->assertSame('http://example.com/ci4/index.php/one/method', (string) $uri); + $this->assertSame('one/method', $uri->getRoutePath()); + $this->assertSame('/ci4/index.php/one/method', $uri->getPath()); + $this->assertSame(['one', 'method'], $uri->getSegments()); + $this->assertSame('one', $uri->getSegment(1)); + $this->assertSame(2, $uri->getTotalSegments()); + } + + public function testGetRoutePath() + { + $config = new App(); + $uri = new SiteURI($config); + + $this->assertSame('', $uri->getRoutePath()); + } + + public function testGetSegments() + { + $config = new App(); + $uri = new SiteURI($config); + + $this->assertSame([], $uri->getSegments()); + } + + public function testGetSegmentZero() + { + $this->expectException(HTTPException::class); + + $config = new App(); + $uri = new SiteURI($config); + $uri->setPath('test/method'); + + $uri->getSegment(0); + } + + public function testGetSegmentOutOfRange() + { + $this->expectException(HTTPException::class); + + $config = new App(); + $uri = new SiteURI($config); + $uri->setPath('test/method'); + + $uri->getSegment(4); + } + + public function testGetTotalSegments() + { + $config = new App(); + $uri = new SiteURI($config); + + $this->assertSame(0, $uri->getTotalSegments()); + } + + public function testSetURI() + { + $this->expectException(BadMethodCallException::class); + + $config = new App(); + $uri = new SiteURI($config); + + $uri->setURI('http://another.site.example.jp/'); + } + + public function testSetBaseURI() + { + $this->expectException(BadMethodCallException::class); + + $config = new App(); + $uri = new SiteURI($config); + + $uri->setBaseURL('http://another.site.example.jp/'); + } + + public function testGetBaseURL() + { + $config = new App(); + $uri = new SiteURI($config); + + $this->assertSame('http://example.com/', $uri->getBaseURL()); + } +} diff --git a/tests/system/HTTP/URITest.php b/tests/system/HTTP/URITest.php index 82256ad92549..1e1fbc4d1a87 100644 --- a/tests/system/HTTP/URITest.php +++ b/tests/system/HTTP/URITest.php @@ -240,6 +240,44 @@ public function testSetSchemeSetsValue(): void $this->assertSame($expected, (string) $uri); } + public function testWithScheme() + { + $url = 'example.com'; + $uri = new URI('http://' . $url); + + $new = $uri->withScheme('x'); + + $this->assertSame('x://' . $url, (string) $new); + $this->assertSame('http://' . $url, (string) $uri); + } + + public function testWithSchemeSetsHttps() + { + $url = 'http://example.com/path'; + $uri = new URI($url); + + $new = $uri->withScheme('https'); + + $this->assertSame('https', $new->getScheme()); + $this->assertSame('http', $uri->getScheme()); + + $expected = 'https://example.com/path'; + $this->assertSame($expected, (string) $new); + $expected = 'http://example.com/path'; + $this->assertSame($expected, (string) $uri); + } + + public function testWithSchemeSetsEmpty() + { + $url = 'example.com'; + $uri = new URI('http://' . $url); + + $new = $uri->withScheme(''); + + $this->assertSame($url, (string) $new); + $this->assertSame('http://' . $url, (string) $uri); + } + public function testSetUserInfoSetsValue(): void { $url = 'http://example.com/path'; @@ -932,32 +970,37 @@ public function testSetSegment(): void $this->assertSame('foo/banana/baz', $uri->getPath()); } - public function testSetSegmentFallback(): void + public function testSetSegmentNewOne(): void { $base = 'http://example.com'; + $uri = new URI($base); - $uri = new URI($base); + // Can set the next segment. $uri->setSegment(1, 'first'); - $uri->setSegment(3, 'third'); + // Can set the next segment. + $uri->setSegment(2, 'third'); $this->assertSame('first/third', $uri->getPath()); + // Can replace the existing segment. $uri->setSegment(2, 'second'); $this->assertSame('first/second', $uri->getPath()); + // Can set the next segment. $uri->setSegment(3, 'third'); $this->assertSame('first/second/third', $uri->getPath()); - $uri->setSegment(5, 'fifth'); + // Can set the next segment. + $uri->setSegment(4, 'fourth'); - $this->assertSame('first/second/third/fifth', $uri->getPath()); + $this->assertSame('first/second/third/fourth', $uri->getPath()); - // sixth or seventh was not set + // Cannot set the next next segment. $this->expectException(HTTPException::class); - $uri->setSegment(8, 'eighth'); + $uri->setSegment(6, 'six'); } public function testSetBadSegment(): void @@ -985,8 +1028,11 @@ public function testSetBadSegmentSilent(): void public function testBasedNoIndex(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/ci/v4/controller/method'; + $_SERVER['REQUEST_URI'] = '/ci/v4/controller/method'; + $_SERVER['SCRIPT_NAME'] = '/ci/v4/index.php'; + $_SERVER['QUERY_STRING'] = ''; + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['PATH_INFO'] = '/controller/method'; $this->resetServices(); @@ -1003,20 +1049,24 @@ public function testBasedNoIndex(): void 'http://example.com/ci/v4/controller/method', (string) $request->getUri() ); - $this->assertSame('ci/v4/controller/method', $request->getUri()->getPath()); + $this->assertSame('/ci/v4/controller/method', $request->getUri()->getPath()); + $this->assertSame('controller/method', $request->getUri()->getRoutePath()); // standalone $uri = new URI('http://example.com/ci/v4/controller/method'); $this->assertSame('http://example.com/ci/v4/controller/method', (string) $uri); $this->assertSame('/ci/v4/controller/method', $uri->getPath()); - $this->assertSame($uri->getPath(), '/' . $request->getUri()->getPath()); + $this->assertSame($uri->getPath(), $request->getUri()->getPath()); } public function testBasedWithIndex(): void { - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/ci/v4/index.php/controller/method'; + $_SERVER['REQUEST_URI'] = '/ci/v4/index.php/controller/method'; + $_SERVER['SCRIPT_NAME'] = '/ci/v4/index.php'; + $_SERVER['QUERY_STRING'] = ''; + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['PATH_INFO'] = '/controller/method'; $this->resetServices(); @@ -1034,7 +1084,7 @@ public function testBasedWithIndex(): void (string) $request->getUri() ); $this->assertSame( - 'ci/v4/index.php/controller/method', + '/ci/v4/index.php/controller/method', $request->getUri()->getPath() ); @@ -1046,26 +1096,26 @@ public function testBasedWithIndex(): void ); $this->assertSame('/ci/v4/index.php/controller/method', $uri->getPath()); - $this->assertSame($uri->getPath(), '/' . $request->getUri()->getPath()); + $this->assertSame($uri->getPath(), $request->getUri()->getPath()); } public function testForceGlobalSecureRequests(): void { $this->resetServices(); - $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/ci/v4/controller/method'; + $_SERVER['REQUEST_URI'] = '/ci/v4/controller/method'; + $_SERVER['SCRIPT_NAME'] = '/ci/v4/index.php'; + $_SERVER['QUERY_STRING'] = ''; + $_SERVER['HTTP_HOST'] = 'example.com'; + $_SERVER['PATH_INFO'] = '/controller/method'; $config = new App(); $config->baseURL = 'http://example.com/ci/v4'; - $config->indexPage = 'index.php'; + $config->indexPage = ''; $config->forceGlobalSecureRequests = true; - Factories::injectMock('config', 'App', $config); - $uri = new URI('http://example.com/ci/v4/controller/method'); - $request = new IncomingRequest($config, $uri, 'php://input', new UserAgent()); - + $request = Services::request($config); Services::injectMock('request', $request); // Detected by request diff --git a/tests/system/Helpers/ArrayHelperTest.php b/tests/system/Helpers/ArrayHelperTest.php index 7b5c1e45a145..9a91c807c4b4 100644 --- a/tests/system/Helpers/ArrayHelperTest.php +++ b/tests/system/Helpers/ArrayHelperTest.php @@ -498,4 +498,798 @@ public static function provideArrayFlattening(): iterable ], ]; } + + /** + * @dataProvider provideArrayGroupByIncludeEmpty + */ + public function testArrayGroupByIncludeEmpty(array $indexes, array $data, array $expected): void + { + $actual = array_group_by($data, $indexes, true); + + $this->assertSame($expected, $actual, 'array including empty not the same'); + } + + /** + * @dataProvider provideArrayGroupByExcludeEmpty + */ + public function testArrayGroupByExcludeEmpty(array $indexes, array $data, array $expected): void + { + $actual = array_group_by($data, $indexes, false); + + $this->assertSame($expected, $actual, 'array excluding empty not the same'); + } + + public static function provideArrayGroupByIncludeEmpty(): iterable + { + yield 'simple group-by test' => [ + ['color'], + [ + [ + 'id' => 1, + 'item' => 'ball', + 'color' => 'blue', + ], + [ + 'id' => 2, + 'item' => 'book', + 'color' => 'red', + ], + [ + 'id' => 3, + 'item' => 'bird', + 'age' => 5, + ], + [ + 'id' => 4, + 'item' => 'jeans', + 'color' => 'blue', + ], + ], + [ + 'blue' => [ + [ + 'id' => 1, + 'item' => 'ball', + 'color' => 'blue', + ], + [ + 'id' => 4, + 'item' => 'jeans', + 'color' => 'blue', + ], + ], + 'red' => [ + [ + 'id' => 2, + 'item' => 'book', + 'color' => 'red', + ], + ], + '' => [ + [ + 'id' => 3, + 'item' => 'bird', + 'age' => 5, + ], + ], + ], + ]; + + yield '2 index data' => [ + ['gender', 'country'], + [ + [ + 'id' => 1, + 'first_name' => 'Scarface', + 'gender' => 'Male', + 'country' => 'Germany', + ], + [ + 'id' => 2, + 'first_name' => 'Fletch', + 'gender' => 'Male', + 'country' => 'France', + ], + [ + 'id' => 3, + 'first_name' => 'Wrennie', + 'gender' => 'Female', + 'country' => 'France', + ], + [ + 'id' => 4, + 'first_name' => 'Virgilio', + 'gender' => 'Male', + 'country' => 'France', + ], + [ + 'id' => 5, + 'first_name' => 'Cathlene', + 'gender' => 'Polygender', + 'country' => 'France', + ], + [ + 'id' => 6, + 'first_name' => 'Far', + 'gender' => 'Male', + 'country' => 'Canada', + ], + [ + 'id' => 7, + 'first_name' => 'Dolores', + 'gender' => 'Female', + 'country' => 'Canada', + ], + [ + 'id' => 8, + 'first_name' => 'Sissy', + 'gender' => 'Female', + 'country' => null, + ], + [ + 'id' => 9, + 'first_name' => 'Chlo', + 'gender' => 'Female', + 'country' => 'France', + ], + [ + 'id' => 10, + 'first_name' => 'Gabbie', + 'gender' => 'Male', + 'country' => 'Canada', + ], + ], + [ + 'Male' => [ + 'Germany' => [ + [ + 'id' => 1, + 'first_name' => 'Scarface', + 'gender' => 'Male', + 'country' => 'Germany', + ], + ], + 'France' => [ + [ + 'id' => 2, + 'first_name' => 'Fletch', + 'gender' => 'Male', + 'country' => 'France', + ], + [ + 'id' => 4, + 'first_name' => 'Virgilio', + 'gender' => 'Male', + 'country' => 'France', + ], + ], + 'Canada' => [ + [ + 'id' => 6, + 'first_name' => 'Far', + 'gender' => 'Male', + 'country' => 'Canada', + ], + [ + 'id' => 10, + 'first_name' => 'Gabbie', + 'gender' => 'Male', + 'country' => 'Canada', + ], + ], + ], + 'Female' => [ + 'France' => [ + [ + 'id' => 3, + 'first_name' => 'Wrennie', + 'gender' => 'Female', + 'country' => 'France', + ], + [ + 'id' => 9, + 'first_name' => 'Chlo', + 'gender' => 'Female', + 'country' => 'France', + ], + ], + 'Canada' => [ + [ + 'id' => 7, + 'first_name' => 'Dolores', + 'gender' => 'Female', + 'country' => 'Canada', + ], + ], + '' => [ + [ + 'id' => 8, + 'first_name' => 'Sissy', + 'gender' => 'Female', + 'country' => null, + ], + ], + ], + 'Polygender' => [ + 'France' => [ + [ + 'id' => 5, + 'first_name' => 'Cathlene', + 'gender' => 'Polygender', + 'country' => 'France', + ], + ], + ], + ], + ]; + + yield 'nested data with dot syntax' => [ + ['gender', 'hr.department'], + [ + [ + 'id' => 1, + 'first_name' => 'Urbano', + 'gender' => null, + 'hr' => [ + 'country' => 'Canada', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 2, + 'first_name' => 'Case', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Marketing', + ], + ], + [ + 'id' => 3, + 'first_name' => 'Emera', + 'gender' => 'Female', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 4, + 'first_name' => 'Richy', + 'gender' => null, + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + [ + 'id' => 5, + 'first_name' => 'Mandy', + 'gender' => null, + 'hr' => [ + 'country' => 'France', + 'department' => 'Sales', + ], + ], + [ + 'id' => 6, + 'first_name' => 'Risa', + 'gender' => 'Female', + 'hr' => [ + 'country' => null, + 'department' => 'Engineering', + ], + ], + [ + 'id' => 7, + 'first_name' => 'Alfred', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 8, + 'first_name' => 'Tabby', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Marketing', + ], + ], + [ + 'id' => 9, + 'first_name' => 'Ario', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + [ + 'id' => 10, + 'first_name' => 'Somerset', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'Germany', + 'department' => 'Marketing', + ], + ], + ], + [ + '' => [ + 'Engineering' => [ + [ + 'id' => 1, + 'first_name' => 'Urbano', + 'gender' => null, + 'hr' => [ + 'country' => 'Canada', + 'department' => 'Engineering', + ], + ], + ], + 'Sales' => [ + [ + 'id' => 4, + 'first_name' => 'Richy', + 'gender' => null, + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + [ + 'id' => 5, + 'first_name' => 'Mandy', + 'gender' => null, + 'hr' => [ + 'country' => 'France', + 'department' => 'Sales', + ], + ], + ], + ], + 'Male' => [ + 'Marketing' => [ + [ + 'id' => 2, + 'first_name' => 'Case', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Marketing', + ], + ], + [ + 'id' => 8, + 'first_name' => 'Tabby', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Marketing', + ], + ], + [ + 'id' => 10, + 'first_name' => 'Somerset', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'Germany', + 'department' => 'Marketing', + ], + ], + ], + 'Engineering' => [ + [ + 'id' => 7, + 'first_name' => 'Alfred', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + ], + 'Sales' => [ + [ + 'id' => 9, + 'first_name' => 'Ario', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + ], + ], + 'Female' => [ + 'Engineering' => [ + [ + 'id' => 3, + 'first_name' => 'Emera', + 'gender' => 'Female', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 6, + 'first_name' => 'Risa', + 'gender' => 'Female', + 'hr' => [ + 'country' => null, + 'department' => 'Engineering', + ], + ], + ], + ], + ], + ]; + } + + public static function provideArrayGroupByExcludeEmpty(): iterable + { + yield 'simple group-by test' => [ + ['color'], + [ + [ + 'id' => 1, + 'item' => 'ball', + 'color' => 'blue', + ], + [ + 'id' => 2, + 'item' => 'book', + 'color' => 'red', + ], + [ + 'id' => 3, + 'item' => 'bird', + 'age' => 5, + ], + [ + 'id' => 4, + 'item' => 'jeans', + 'color' => 'blue', + ], + ], + [ + 'blue' => [ + [ + 'id' => 1, + 'item' => 'ball', + 'color' => 'blue', + ], + [ + 'id' => 4, + 'item' => 'jeans', + 'color' => 'blue', + ], + ], + 'red' => [ + [ + 'id' => 2, + 'item' => 'book', + 'color' => 'red', + ], + ], + ], + ]; + + yield '2 index data' => [ + ['gender', 'country'], + [ + [ + 'id' => 1, + 'first_name' => 'Scarface', + 'gender' => 'Male', + 'country' => 'Germany', + ], + [ + 'id' => 2, + 'first_name' => 'Fletch', + 'gender' => 'Male', + 'country' => 'France', + ], + [ + 'id' => 3, + 'first_name' => 'Wrennie', + 'gender' => 'Female', + 'country' => 'France', + ], + [ + 'id' => 4, + 'first_name' => 'Virgilio', + 'gender' => 'Male', + 'country' => 'France', + ], + [ + 'id' => 5, + 'first_name' => 'Cathlene', + 'gender' => 'Polygender', + 'country' => 'France', + ], + [ + 'id' => 6, + 'first_name' => 'Far', + 'gender' => 'Male', + 'country' => 'Canada', + ], + [ + 'id' => 7, + 'first_name' => 'Dolores', + 'gender' => 'Female', + 'country' => 'Canada', + ], + [ + 'id' => 8, + 'first_name' => 'Sissy', + 'gender' => 'Female', + 'country' => null, + ], + [ + 'id' => 9, + 'first_name' => 'Chlo', + 'gender' => 'Female', + 'country' => 'France', + ], + [ + 'id' => 10, + 'first_name' => 'Gabbie', + 'gender' => 'Male', + 'country' => 'Canada', + ], + ], + [ + 'Male' => [ + 'Germany' => [ + [ + 'id' => 1, + 'first_name' => 'Scarface', + 'gender' => 'Male', + 'country' => 'Germany', + ], + ], + 'France' => [ + [ + 'id' => 2, + 'first_name' => 'Fletch', + 'gender' => 'Male', + 'country' => 'France', + ], + [ + 'id' => 4, + 'first_name' => 'Virgilio', + 'gender' => 'Male', + 'country' => 'France', + ], + ], + 'Canada' => [ + [ + 'id' => 6, + 'first_name' => 'Far', + 'gender' => 'Male', + 'country' => 'Canada', + ], + [ + 'id' => 10, + 'first_name' => 'Gabbie', + 'gender' => 'Male', + 'country' => 'Canada', + ], + ], + ], + 'Female' => [ + 'France' => [ + [ + 'id' => 3, + 'first_name' => 'Wrennie', + 'gender' => 'Female', + 'country' => 'France', + ], + [ + 'id' => 9, + 'first_name' => 'Chlo', + 'gender' => 'Female', + 'country' => 'France', + ], + ], + 'Canada' => [ + [ + 'id' => 7, + 'first_name' => 'Dolores', + 'gender' => 'Female', + 'country' => 'Canada', + ], + ], + ], + 'Polygender' => [ + 'France' => [ + [ + 'id' => 5, + 'first_name' => 'Cathlene', + 'gender' => 'Polygender', + 'country' => 'France', + ], + ], + ], + ], + ]; + + yield 'nested data with dot syntax' => [ + ['gender', 'hr.department'], + [ + [ + 'id' => 1, + 'first_name' => 'Urbano', + 'gender' => null, + 'hr' => [ + 'country' => 'Canada', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 2, + 'first_name' => 'Case', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Marketing', + ], + ], + [ + 'id' => 3, + 'first_name' => 'Emera', + 'gender' => 'Female', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 4, + 'first_name' => 'Richy', + 'gender' => null, + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + [ + 'id' => 5, + 'first_name' => 'Mandy', + 'gender' => null, + 'hr' => [ + 'country' => 'France', + 'department' => 'Sales', + ], + ], + [ + 'id' => 6, + 'first_name' => 'Risa', + 'gender' => 'Female', + 'hr' => [ + 'country' => null, + 'department' => 'Engineering', + ], + ], + [ + 'id' => 7, + 'first_name' => 'Alfred', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 8, + 'first_name' => 'Tabby', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Marketing', + ], + ], + [ + 'id' => 9, + 'first_name' => 'Ario', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + [ + 'id' => 10, + 'first_name' => 'Somerset', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'Germany', + 'department' => 'Marketing', + ], + ], + ], + [ + 'Male' => [ + 'Marketing' => [ + [ + 'id' => 2, + 'first_name' => 'Case', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Marketing', + ], + ], + [ + 'id' => 8, + 'first_name' => 'Tabby', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Marketing', + ], + ], + [ + 'id' => 10, + 'first_name' => 'Somerset', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'Germany', + 'department' => 'Marketing', + ], + ], + ], + 'Engineering' => [ + [ + 'id' => 7, + 'first_name' => 'Alfred', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + ], + 'Sales' => [ + [ + 'id' => 9, + 'first_name' => 'Ario', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + ], + ], + 'Female' => [ + 'Engineering' => [ + [ + 'id' => 3, + 'first_name' => 'Emera', + 'gender' => 'Female', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 6, + 'first_name' => 'Risa', + 'gender' => 'Female', + 'hr' => [ + 'country' => null, + 'department' => 'Engineering', + ], + ], + ], + ], + ], + ]; + } } diff --git a/tests/system/Helpers/FormHelperTest.php b/tests/system/Helpers/FormHelperTest.php index ca90ce53c12e..8ed1bdeb7201 100644 --- a/tests/system/Helpers/FormHelperTest.php +++ b/tests/system/Helpers/FormHelperTest.php @@ -11,7 +11,7 @@ namespace CodeIgniter\Helpers; -use CodeIgniter\HTTP\URI; +use CodeIgniter\HTTP\SiteURI; use CodeIgniter\Test\CIUnitTestCase; use Config\App; use Config\Filters; @@ -35,13 +35,12 @@ protected function setUp(): void private function setRequest(): void { - $uri = new URI('http://example.com/'); - Services::injectMock('uri', $uri); - $config = new App(); - $config->baseURL = ''; $config->indexPage = 'index.php'; + $uri = new SiteURI($config); + Services::injectMock('uri', $uri); + $request = Services::request($config); Services::injectMock('request', $request); } diff --git a/tests/system/Helpers/HTMLHelperTest.php b/tests/system/Helpers/HTMLHelperTest.php index 1f8485393b3f..0d9452640730 100755 --- a/tests/system/Helpers/HTMLHelperTest.php +++ b/tests/system/Helpers/HTMLHelperTest.php @@ -39,6 +39,8 @@ protected function setUp(): void { parent::setUp(); + $this->resetServices(); + helper('html'); $this->tracks = [ diff --git a/tests/system/Helpers/SecurityHelperTest.php b/tests/system/Helpers/SecurityHelperTest.php index cb719b1ab2d8..d7fb2f8ea6f9 100644 --- a/tests/system/Helpers/SecurityHelperTest.php +++ b/tests/system/Helpers/SecurityHelperTest.php @@ -13,7 +13,7 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockSecurity; -use Config\App; +use Config\Security as SecurityConfig; use Tests\Support\Config\Services; /** @@ -32,7 +32,7 @@ protected function setUp(): void public function testSanitizeFilenameSimpleSuccess(): void { - Services::injectMock('security', new MockSecurity(new App())); + Services::injectMock('security', new MockSecurity(new SecurityConfig())); $this->assertSame('hello.doc', sanitize_filename('hello.doc')); } diff --git a/tests/system/Helpers/URLHelper/CurrentUrlTest.php b/tests/system/Helpers/URLHelper/CurrentUrlTest.php index 6f908053b6a6..c735064fa287 100644 --- a/tests/system/Helpers/URLHelper/CurrentUrlTest.php +++ b/tests/system/Helpers/URLHelper/CurrentUrlTest.php @@ -13,7 +13,11 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\SiteURIFactory; use CodeIgniter\HTTP\URI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; @@ -42,7 +46,6 @@ protected function setUp(): void $this->config = new App(); $this->config->baseURL = 'http://example.com/'; $this->config->indexPage = 'index.php'; - Factories::injectMock('config', 'App', $this->config); $_SERVER['HTTP_HOST'] = 'example.com'; $_SERVER['REQUEST_URI'] = '/'; @@ -58,14 +61,12 @@ protected function tearDown(): void public function testCurrentURLReturnsBasicURL(): void { - $_SERVER['REQUEST_URI'] = '/public'; + $_SERVER['REQUEST_URI'] = '/public/'; $_SERVER['SCRIPT_NAME'] = '/public/index.php'; - $this->config->baseURL = 'http://example.com/public'; + $this->config->baseURL = 'http://example.com/public/'; - // URI object are updated in IncomingRequest constructor. - $request = Services::incomingrequest($this->config); - Services::injectMock('request', $request); + $this->createRequest($this->config); $this->assertSame('http://example.com/public/index.php/', current_url()); } @@ -73,31 +74,53 @@ public function testCurrentURLReturnsBasicURL(): void public function testCurrentURLReturnsAllowedHostname(): void { $_SERVER['HTTP_HOST'] = 'www.example.jp'; - $_SERVER['REQUEST_URI'] = '/public'; + $_SERVER['REQUEST_URI'] = '/public/'; $_SERVER['SCRIPT_NAME'] = '/public/index.php'; - $this->config->baseURL = 'http://example.com/public'; + $this->config->baseURL = 'http://example.com/public/'; $this->config->allowedHostnames = ['www.example.jp']; + $this->createRequest($this->config); + $this->assertSame('http://www.example.jp/public/index.php/', current_url()); } + private function createRequest(?App $config = null, $body = null, ?string $path = null): void + { + $config ??= new App(); + + $factory = new SiteURIFactory($config, new Superglobals()); + $uri = $factory->createFromGlobals(); + + if ($path !== null) { + $uri->setPath($path); + } + + $request = new IncomingRequest($config, $uri, $body, new UserAgent()); + Services::injectMock('request', $request); + + Factories::injectMock('config', 'App', $config); + } + public function testCurrentURLReturnsBaseURLIfNotAllowedHostname(): void { $_SERVER['HTTP_HOST'] = 'invalid.example.org'; - $_SERVER['REQUEST_URI'] = '/public'; + $_SERVER['REQUEST_URI'] = '/public/'; $_SERVER['SCRIPT_NAME'] = '/public/index.php'; - $this->config->baseURL = 'http://example.com/public'; + $this->config->baseURL = 'http://example.com/public/'; $this->config->allowedHostnames = ['www.example.jp']; + $this->createRequest($this->config); + $this->assertSame('http://example.com/public/index.php/', current_url()); } public function testCurrentURLReturnsObject(): void { - // Since we're on a CLI, we must provide our own URI - $this->config->baseURL = 'http://example.com/public'; + $this->config->baseURL = 'http://example.com/public/'; + + $this->createRequest($this->config); $url = current_url(true); @@ -108,14 +131,12 @@ public function testCurrentURLReturnsObject(): void public function testCurrentURLEquivalence(): void { $_SERVER['HTTP_HOST'] = 'example.com'; - $_SERVER['REQUEST_URI'] = '/public'; + $_SERVER['REQUEST_URI'] = '/public/'; $_SERVER['SCRIPT_NAME'] = '/index.php'; - // Since we're on a CLI, we must provide our own URI - Factories::injectMock('config', 'App', $this->config); + $this->config->indexPage = ''; - $request = Services::request($this->config); - Services::injectMock('request', $request); + $this->createRequest($this->config); $this->assertSame(site_url(uri_string()), current_url()); } @@ -126,18 +147,15 @@ public function testCurrentURLInSubfolder(): void $_SERVER['REQUEST_URI'] = '/foo/public/bar?baz=quip'; $_SERVER['SCRIPT_NAME'] = '/foo/public/index.php'; - // Since we're on a CLI, we must provide our own URI - $this->config->baseURL = 'http://example.com/foo/public'; - Factories::injectMock('config', 'App', $this->config); + $this->config->baseURL = 'http://example.com/foo/public/'; - $request = Services::request($this->config); - Services::injectMock('request', $request); + $this->createRequest($this->config); $this->assertSame('http://example.com/foo/public/index.php/bar', current_url()); $this->assertSame('http://example.com/foo/public/index.php/bar?baz=quip', (string) current_url(true)); $uri = current_url(true); - $this->assertSame('foo', $uri->getSegment(1)); + $this->assertSame('bar', $uri->getSegment(1)); $this->assertSame('example.com', $uri->getHost()); $this->assertSame('http', $uri->getScheme()); } @@ -149,19 +167,16 @@ public function testCurrentURLWithPortInSubfolder(): void $_SERVER['REQUEST_URI'] = '/foo/public/bar?baz=quip'; $_SERVER['SCRIPT_NAME'] = '/foo/public/index.php'; - // Since we're on a CLI, we must provide our own URI - $this->config->baseURL = 'http://example.com:8080/foo/public'; - Factories::injectMock('config', 'App', $this->config); + $this->config->baseURL = 'http://example.com:8080/foo/public/'; - $request = Services::request($this->config); - Services::injectMock('request', $request); + $this->createRequest($this->config); $this->assertSame('http://example.com:8080/foo/public/index.php/bar', current_url()); $this->assertSame('http://example.com:8080/foo/public/index.php/bar?baz=quip', (string) current_url(true)); $uri = current_url(true); - $this->assertSame(['foo', 'public', 'index.php', 'bar'], $uri->getSegments()); - $this->assertSame('foo', $uri->getSegment(1)); + $this->assertSame(['bar'], $uri->getSegments()); + $this->assertSame('bar', $uri->getSegment(1)); $this->assertSame('example.com', $uri->getHost()); $this->assertSame('http', $uri->getScheme()); $this->assertSame(8080, $uri->getPort()); @@ -172,19 +187,11 @@ public function testUriString(): void $_SERVER['HTTP_HOST'] = 'example.com'; $_SERVER['REQUEST_URI'] = '/assets/image.jpg'; - $uri = 'http://example.com/assets/image.jpg'; - $this->setService($uri); - - $this->assertSame('assets/image.jpg', uri_string()); - } + $this->config->indexPage = ''; - private function setService(string $uri): void - { - $uri = new URI($uri); - Services::injectMock('uri', $uri); + $this->createRequest($this->config); - $request = Services::request($this->config); - Services::injectMock('request', $request); + $this->assertSame('assets/image.jpg', uri_string()); } public function testUriStringNoTrailingSlash(): void @@ -192,18 +199,17 @@ public function testUriStringNoTrailingSlash(): void $_SERVER['HTTP_HOST'] = 'example.com'; $_SERVER['REQUEST_URI'] = '/assets/image.jpg'; - $this->config->baseURL = 'http://example.com'; + $this->config->baseURL = 'http://example.com/'; + $this->config->indexPage = ''; - $uri = 'http://example.com/assets/image.jpg'; - $this->setService($uri); + $this->createRequest($this->config); $this->assertSame('assets/image.jpg', uri_string()); } public function testUriStringEmpty(): void { - $uri = 'http://example.com/'; - $this->setService($uri); + $this->createRequest($this->config); $this->assertSame('', uri_string()); } @@ -215,8 +221,7 @@ public function testUriStringSubfolderAbsolute(): void $this->config->baseURL = 'http://example.com/subfolder/'; - $uri = 'http://example.com/subfolder/assets/image.jpg'; - $this->setService($uri); + $this->createRequest($this->config); $this->assertSame('subfolder/assets/image.jpg', uri_string()); } @@ -229,8 +234,7 @@ public function testUriStringSubfolderRelative(): void $this->config->baseURL = 'http://example.com/subfolder/'; - $uri = 'http://example.com/subfolder/assets/image.jpg'; - $this->setService($uri); + $this->createRequest($this->config); $this->assertSame('assets/image.jpg', uri_string()); } @@ -284,8 +288,7 @@ public function testUrlIs(string $currentPath, string $testPath, bool $expected) $_SERVER['HTTP_HOST'] = 'example.com'; $_SERVER['REQUEST_URI'] = '/' . $currentPath; - $uri = 'http://example.com/' . $currentPath; - $this->setService($uri); + $this->createRequest($this->config); $this->assertSame($expected, url_is($testPath)); } @@ -300,8 +303,7 @@ public function testUrlIsNoIndex(string $currentPath, string $testPath, bool $ex $this->config->indexPage = ''; - $uri = 'http://example.com/' . $currentPath; - $this->setService($uri); + $this->createRequest($this->config); $this->assertSame($expected, url_is($testPath)); } @@ -317,8 +319,7 @@ public function testUrlIsWithSubfolder(string $currentPath, string $testPath, bo $this->config->baseURL = 'http://example.com/subfolder/'; - $uri = 'http://example.com/subfolder/' . $currentPath; - $this->setService($uri); + $this->createRequest($this->config); $this->assertSame($expected, url_is($testPath)); } diff --git a/tests/system/Helpers/URLHelper/MiscUrlTest.php b/tests/system/Helpers/URLHelper/MiscUrlTest.php index 7af14d169f16..ecb59e1ef2b5 100644 --- a/tests/system/Helpers/URLHelper/MiscUrlTest.php +++ b/tests/system/Helpers/URLHelper/MiscUrlTest.php @@ -13,8 +13,11 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; -use CodeIgniter\HTTP\URI; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\SiteURIFactory; +use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Router\Exceptions\RouterException; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; use InvalidArgumentException; @@ -40,7 +43,6 @@ protected function setUp(): void $this->config = new App(); $this->config->baseURL = 'http://example.com/'; $this->config->indexPage = 'index.php'; - Factories::injectMock('config', 'App', $this->config); } protected function tearDown(): void @@ -61,19 +63,21 @@ public function testPreviousURLUsesSessionFirst(): void $this->config->baseURL = 'http://example.com/public'; $uri = 'http://example.com/public'; - $this->setRequest($uri); + $this->createRequest($uri); $this->assertSame($uri2, previous_url()); } - private function setRequest(string $uri): void + private function createRequest(string $uri): void { - $uri = new URI($uri); - Services::injectMock('uri', $uri); + $factory = new SiteURIFactory($this->config, new Superglobals()); + + $uri = $factory->createFromString($uri); - // Since we're on a CLI, we must provide our own URI - $request = Services::request($this->config); + $request = new IncomingRequest($this->config, $uri, null, new UserAgent()); Services::injectMock('request', $request); + + Factories::injectMock('config', 'App', $this->config); } public function testPreviousURLUsesRefererIfNeeded(): void @@ -85,7 +89,7 @@ public function testPreviousURLUsesRefererIfNeeded(): void $this->config->baseURL = 'http://example.com/public'; $uri = 'http://example.com/public'; - $this->setRequest($uri); + $this->createRequest($uri); $this->assertSame($uri1, previous_url()); } @@ -95,7 +99,7 @@ public function testPreviousURLUsesRefererIfNeeded(): void public function testIndexPage(): void { $uri = 'http://example.com/'; - $this->setRequest($uri); + $this->createRequest($uri); $this->assertSame('index.php', index_page()); } @@ -105,7 +109,7 @@ public function testIndexPageAlt(): void $this->config->indexPage = 'banana.php'; $uri = 'http://example.com/'; - $this->setRequest($uri); + $this->createRequest($uri); $this->assertSame('banana.php', index_page($this->config)); } @@ -166,7 +170,7 @@ public static function provideAnchor(): iterable public function testAnchor($expected = '', $uri = '', $title = '', $attributes = ''): void { $uriString = 'http://example.com/'; - $this->setRequest($uriString); + $this->createRequest($uriString); $this->assertSame($expected, anchor($uri, $title, $attributes, $this->config)); } @@ -233,7 +237,7 @@ public function testAnchorNoindex($expected = '', $uri = '', $title = '', $attri $this->config->indexPage = ''; $uriString = 'http://example.com/'; - $this->setRequest($uriString); + $this->createRequest($uriString); $this->assertSame($expected, anchor($uri, $title, $attributes, $this->config)); } @@ -290,7 +294,7 @@ public function testAnchorTargetted($expected = '', $uri = '', $title = '', $att $this->config->indexPage = ''; $uriString = 'http://example.com/'; - $this->setRequest($uriString); + $this->createRequest($uriString); $this->assertSame($expected, anchor($uri, $title, $attributes, $this->config)); } @@ -334,7 +338,7 @@ public static function provideAnchorExamples(): iterable public function testAnchorExamples($expected = '', $uri = '', $title = '', $attributes = ''): void { $uriString = 'http://example.com/'; - $this->setRequest($uriString); + $this->createRequest($uriString); $this->assertSame($expected, anchor($uri, $title, $attributes, $this->config)); } @@ -392,7 +396,7 @@ public static function provideAnchorPopup(): iterable public function testAnchorPopup($expected = '', $uri = '', $title = '', $attributes = false): void { $uriString = 'http://example.com/'; - $this->setRequest($uriString); + $this->createRequest($uriString); $this->assertSame($expected, anchor_popup($uri, $title, $attributes, $this->config)); } @@ -431,7 +435,7 @@ public static function provideMailto(): iterable public function testMailto($expected = '', $email = '', $title = '', $attributes = ''): void { $uriString = 'http://example.com/'; - $this->setRequest($uriString); + $this->createRequest($uriString); $this->assertSame($expected, mailto($email, $title, $attributes)); } @@ -470,7 +474,7 @@ public static function provideSafeMailto(): iterable public function testSafeMailto($expected = '', $email = '', $title = '', $attributes = ''): void { $uriString = 'http://example.com/'; - $this->setRequest($uriString); + $this->createRequest($uriString); $this->assertSame($expected, safe_mailto($email, $title, $attributes)); } diff --git a/tests/system/Helpers/URLHelper/SiteUrlTest.php b/tests/system/Helpers/URLHelper/SiteUrlTest.php index cafcb2f3ee6a..1a03d80f3521 100644 --- a/tests/system/Helpers/URLHelper/SiteUrlTest.php +++ b/tests/system/Helpers/URLHelper/SiteUrlTest.php @@ -13,7 +13,11 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\SiteURIFactory; use CodeIgniter\HTTP\URI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; use Config\App; @@ -39,7 +43,6 @@ protected function setUp(): void Services::reset(true); $this->config = new App(); - Factories::injectMock('config', 'App', $this->config); } protected function tearDown(): void @@ -49,6 +52,23 @@ protected function tearDown(): void $_SERVER = []; } + private function createRequest(?App $config = null, $body = null, ?string $path = null): void + { + $config ??= new App(); + + $factory = new SiteURIFactory($config, new Superglobals()); + $uri = $factory->createFromGlobals(); + + if ($path !== null) { + $uri->setPath($path); + } + + $request = new IncomingRequest($config, $uri, $body, new UserAgent()); + Services::injectMock('request', $request); + + Factories::injectMock('config', 'App', $config); + } + /** * Takes a multitude of various config input and verifies * that base_url() and site_url() return the expected result. @@ -77,6 +97,8 @@ public function testUrls( $this->config->indexPage = $indexPage; $this->config->forceGlobalSecureRequests = $secure; + $this->createRequest($this->config); + $this->assertSame($expectedSiteUrl, site_url($path, $scheme, $this->config)); $this->assertSame($expectedBaseUrl, base_url($path, $scheme)); } @@ -333,11 +355,15 @@ public function testBaseURLDiscovery(): void $_SERVER['HTTP_HOST'] = 'example.com'; $_SERVER['REQUEST_URI'] = '/test'; + $this->createRequest($this->config); + $this->assertSame('http://example.com/', base_url()); $_SERVER['HTTP_HOST'] = 'example.com'; $_SERVER['REQUEST_URI'] = '/test/page'; + $this->createRequest($this->config); + $this->assertSame('http://example.com/', base_url()); $this->assertSame('http://example.com/profile', base_url('profile')); } @@ -348,11 +374,17 @@ public function testBaseURLService(): void $_SERVER['REQUEST_URI'] = '/ci/v4/x/y'; $this->config->baseURL = 'http://example.com/ci/v4/'; - $request = Services::request($this->config); - Services::injectMock('request', $request); - $this->assertSame('http://example.com/ci/v4/index.php/controller/method', site_url('controller/method', null, $this->config)); - $this->assertSame('http://example.com/ci/v4/controller/method', base_url('controller/method', null)); + $this->createRequest($this->config); + + $this->assertSame( + 'http://example.com/ci/v4/index.php/controller/method', + site_url('controller/method', null, $this->config) + ); + $this->assertSame( + 'http://example.com/ci/v4/controller/method', + base_url('controller/method', null) + ); } public function testBaseURLWithCLIRequest(): void @@ -360,8 +392,8 @@ public function testBaseURLWithCLIRequest(): void unset($_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI']); $this->config->baseURL = 'http://example.com/'; - $request = Services::clirequest($this->config); - Services::injectMock('request', $request); + + $this->createRequest($this->config); $this->assertSame( 'http://example.com/index.php/controller/method', @@ -381,11 +413,8 @@ public function testSiteURLWithAllowedHostname(): void $this->config->baseURL = 'http://example.com/public/'; $this->config->allowedHostnames = ['www.example.jp']; - Services::injectMock('config', $this->config); - // URI object are updated in IncomingRequest constructor. - $request = Services::incomingrequest($this->config); - Services::injectMock('request', $request); + $this->createRequest($this->config); $this->assertSame( 'http://www.example.jp/public/index.php/controller/method', @@ -402,9 +431,7 @@ public function testSiteURLWithAltConfig(): void $this->config->baseURL = 'http://example.com/public/'; $this->config->allowedHostnames = ['www.example.jp']; - // URI object are updated in IncomingRequest constructor. - $request = Services::incomingrequest($this->config); - Services::injectMock('request', $request); + $this->createRequest($this->config); $altConfig = clone $this->config; $altConfig->baseURL = 'http://alt.example.com/public/'; @@ -424,9 +451,7 @@ public function testBaseURLWithAllowedHostname(): void $this->config->baseURL = 'http://example.com/public/'; $this->config->allowedHostnames = ['www.example.jp']; - // URI object are updated in IncomingRequest constructor. - $request = Services::incomingrequest($this->config); - Services::injectMock('request', $request); + $this->createRequest($this->config); $this->assertSame( 'http://www.example.jp/public/controller/method', diff --git a/tests/system/HotReloader/DirectoryHasherTest.php b/tests/system/HotReloader/DirectoryHasherTest.php new file mode 100644 index 000000000000..f5de5da523d5 --- /dev/null +++ b/tests/system/HotReloader/DirectoryHasherTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\HotReloader; + +use CodeIgniter\Exceptions\FrameworkException; +use CodeIgniter\Test\CIUnitTestCase; + +/** + * @internal + * + * @group Others + */ +final class DirectoryHasherTest extends CIUnitTestCase +{ + private DirectoryHasher $hasher; + + protected function setUp(): void + { + parent::setUp(); + + $this->hasher = new DirectoryHasher(); + } + + public function testHashApp() + { + $results = $this->hasher->hashApp(); + + $this->assertIsArray($results); + $this->assertArrayHasKey('app', $results); + } + + public function testHashDirectoryInvalid() + { + $this->expectException(FrameworkException::class); + $this->expectExceptionMessage('Directory does not exist: "' . APPPATH . 'Foo"'); + + $this->hasher->hashDirectory(APPPATH . 'Foo'); + } + + public function testUniqueHashes() + { + $hash1 = $this->hasher->hashDirectory(APPPATH); + $hash2 = $this->hasher->hashDirectory(SYSTEMPATH); + + $this->assertNotSame($hash1, $hash2); + } + + public function testRepeatableHashes() + { + $hash1 = $this->hasher->hashDirectory(APPPATH); + $hash2 = $this->hasher->hashDirectory(APPPATH); + + $this->assertSame($hash1, $hash2); + } + + public function testHash() + { + $expected = md5(implode('', $this->hasher->hashApp())); + + $this->assertSame($expected, $this->hasher->hash()); + } +} diff --git a/tests/system/RESTful/ResourceControllerTest.php b/tests/system/RESTful/ResourceControllerTest.php index e45cc7c4fca4..bd52bd0d31a5 100644 --- a/tests/system/RESTful/ResourceControllerTest.php +++ b/tests/system/RESTful/ResourceControllerTest.php @@ -66,7 +66,7 @@ private function createCodeigniter(): void // Inject mock router. $this->routes = Services::routes(); - $this->routes->resource('work', ['controller' => Worker::class]); + $this->routes->resource('work', ['controller' => '\\' . Worker::class]); Services::injectMock('routes', $this->routes); $config = new App(); diff --git a/tests/system/RESTful/ResourcePresenterTest.php b/tests/system/RESTful/ResourcePresenterTest.php index a3e82b69c365..affaab379255 100644 --- a/tests/system/RESTful/ResourcePresenterTest.php +++ b/tests/system/RESTful/ResourcePresenterTest.php @@ -60,7 +60,7 @@ private function createCodeigniter(): void // Inject mock router. $this->routes = Services::routes(); - $this->routes->presenter('work', ['controller' => Worker2::class]); + $this->routes->presenter('work', ['controller' => '\\' . Worker2::class]); Services::injectMock('routes', $this->routes); $config = new App(); diff --git a/tests/system/Router/AutoRouterImprovedTest.php b/tests/system/Router/AutoRouterImprovedTest.php index 293e49441f4d..0efecd2e50ac 100644 --- a/tests/system/Router/AutoRouterImprovedTest.php +++ b/tests/system/Router/AutoRouterImprovedTest.php @@ -11,6 +11,7 @@ namespace CodeIgniter\Router; +use CodeIgniter\Config\Factories; use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Router\Controllers\Dash_folder\Dash_controller; @@ -19,6 +20,7 @@ use CodeIgniter\Router\Controllers\Mycontroller; use CodeIgniter\Test\CIUnitTestCase; use Config\Modules; +use Config\Routing; /** * @internal @@ -35,14 +37,14 @@ protected function setUp(): void $moduleConfig = new Modules(); $moduleConfig->enabled = false; - $this->collection = new RouteCollection(Services::locator(), $moduleConfig); + $this->collection = new RouteCollection(Services::locator(), $moduleConfig, new Routing()); } - private function createNewAutoRouter(string $httpVerb = 'get'): AutoRouterImproved + private function createNewAutoRouter(string $httpVerb = 'get', $namespace = 'CodeIgniter\Router\Controllers'): AutoRouterImproved { return new AutoRouterImproved( [], - 'CodeIgniter\Router\Controllers', + $namespace, $this->collection->getDefaultController(), $this->collection->getDefaultMethod(), true, @@ -63,6 +65,32 @@ public function testAutoRouteFindsDefaultControllerAndMethodGet(): void $this->assertSame('\\' . Index::class, $controller); $this->assertSame('getIndex', $method); $this->assertSame([], $params); + $this->assertSame([ + 'controller' => null, + 'method' => null, + 'params' => null, + ], $router->getPos()); + } + + public function testAutoRouteFindsModuleDefaultControllerAndMethodGet() + { + $config = config(Routing::class); + $config->moduleRoutes = [ + 'test' => 'CodeIgniter\Router\Controllers', + ]; + Factories::injectMock('config', Routing::class, $config); + + $this->collection->setDefaultController('Index'); + + $router = $this->createNewAutoRouter('get', 'App/Controllers'); + + [$directory, $controller, $method, $params] + = $router->getRoute('test', 'get'); + + $this->assertNull($directory); + $this->assertSame('\\' . Index::class, $controller); + $this->assertSame('getIndex', $method); + $this->assertSame([], $params); } public function testAutoRouteFindsDefaultControllerAndMethodPost(): void @@ -91,6 +119,11 @@ public function testAutoRouteFindsControllerWithFileAndMethod(): void $this->assertSame('\\' . Mycontroller::class, $controller); $this->assertSame('getSomemethod', $method); $this->assertSame([], $params); + $this->assertSame([ + 'controller' => 0, + 'method' => 1, + 'params' => null, + ], $router->getPos()); } public function testFindsControllerAndMethodAndParam(): void @@ -104,6 +137,11 @@ public function testFindsControllerAndMethodAndParam(): void $this->assertSame('\\' . Mycontroller::class, $controller); $this->assertSame('getSomemethod', $method); $this->assertSame(['a'], $params); + $this->assertSame([ + 'controller' => 0, + 'method' => 1, + 'params' => 2, + ], $router->getPos()); } public function testUriParamCountIsGreaterThanMethodParams(): void @@ -142,6 +180,24 @@ public function testAutoRouteFindsControllerWithSubfolder(): void $this->assertSame('\\' . \CodeIgniter\Router\Controllers\Subfolder\Mycontroller::class, $controller); $this->assertSame('getSomemethod', $method); $this->assertSame([], $params); + $this->assertSame([ + 'controller' => 1, + 'method' => 2, + 'params' => null, + ], $router->getPos()); + } + + public function testAutoRouteFindsControllerWithSubSubfolder() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('subfolder/sub/mycontroller/somemethod', 'get'); + + $this->assertSame('Subfolder/Sub/', $directory); + $this->assertSame('\\' . \CodeIgniter\Router\Controllers\Subfolder\Sub\Mycontroller::class, $controller); + $this->assertSame('getSomemethod', $method); + $this->assertSame([], $params); } public function testAutoRouteFindsDashedSubfolder(): void @@ -199,6 +255,78 @@ public function testAutoRouteFindsDefaultDashFolder(): void $this->assertSame([], $params); } + public function testAutoRouteFallbackToDefaultMethod() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('index/15', 'get'); + + $this->assertNull($directory); + $this->assertSame('\\' . Index::class, $controller); + $this->assertSame('getIndex', $method); + $this->assertSame(['15'], $params); + $this->assertSame([ + 'controller' => 0, + 'method' => null, + 'params' => 1, + ], $router->getPos()); + } + + public function testAutoRouteFallbackToDefaultControllerOneParam() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('subfolder/15', 'get'); + + $this->assertSame('Subfolder/', $directory); + $this->assertSame('\\' . \CodeIgniter\Router\Controllers\Subfolder\Home::class, $controller); + $this->assertSame('getIndex', $method); + $this->assertSame(['15'], $params); + $this->assertSame([ + 'controller' => null, + 'method' => null, + 'params' => 1, + ], $router->getPos()); + } + + public function testAutoRouteFallbackToDefaultControllerTwoParams() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('subfolder/15/20', 'get'); + + $this->assertSame('Subfolder/', $directory); + $this->assertSame('\\' . \CodeIgniter\Router\Controllers\Subfolder\Home::class, $controller); + $this->assertSame('getIndex', $method); + $this->assertSame(['15', '20'], $params); + $this->assertSame([ + 'controller' => null, + 'method' => null, + 'params' => 1, + ], $router->getPos()); + } + + public function testAutoRouteFallbackToDefaultControllerNoParams() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('subfolder', 'get'); + + $this->assertSame('Subfolder/', $directory); + $this->assertSame('\\' . \CodeIgniter\Router\Controllers\Subfolder\Home::class, $controller); + $this->assertSame('getIndex', $method); + $this->assertSame([], $params); + $this->assertSame([ + 'controller' => null, + 'method' => null, + 'params' => null, + ], $router->getPos()); + } + public function testAutoRouteRejectsSingleDot(): void { $this->expectException(PageNotFoundException::class); @@ -264,4 +392,66 @@ public function testRejectsControllerWithRemapMethod(): void $router->getRoute('remap/test', 'get'); } + + public function testRejectsURIWithUnderscoreFolder() + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage( + 'AutoRouterImproved prohibits access to the URI containing underscores ("dash_folder")' + ); + + $router = $this->createNewAutoRouter(); + + $router->getRoute('dash_folder', 'get'); + } + + public function testRejectsURIWithUnderscoreController() + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage( + 'AutoRouterImproved prohibits access to the URI containing underscores ("dash_controller")' + ); + + $router = $this->createNewAutoRouter(); + + $router->getRoute('dash-folder/dash_controller/dash-method', 'get'); + } + + public function testRejectsURIWithUnderscoreMethod() + { + $this->expectException(PageNotFoundException::class); + $this->expectExceptionMessage( + 'AutoRouterImproved prohibits access to the URI containing underscores ("dash_method")' + ); + + $router = $this->createNewAutoRouter(); + + $router->getRoute('dash-folder/dash-controller/dash_method', 'get'); + } + + public function testPermitsURIWithUnderscoreParam() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('mycontroller/somemethod/a_b', 'get'); + + $this->assertNull($directory); + $this->assertSame('\\' . Mycontroller::class, $controller); + $this->assertSame('getSomemethod', $method); + $this->assertSame(['a_b'], $params); + } + + public function testDoesNotTranslateDashInParam() + { + $router = $this->createNewAutoRouter(); + + [$directory, $controller, $method, $params] + = $router->getRoute('mycontroller/somemethod/a-b', 'get'); + + $this->assertNull($directory); + $this->assertSame('\\' . Mycontroller::class, $controller); + $this->assertSame('getSomemethod', $method); + $this->assertSame(['a-b'], $params); + } } diff --git a/tests/system/Router/Controllers/Index.php b/tests/system/Router/Controllers/Index.php index b2be979fe8a4..34143c11a0e3 100644 --- a/tests/system/Router/Controllers/Index.php +++ b/tests/system/Router/Controllers/Index.php @@ -15,7 +15,7 @@ class Index extends Controller { - public function getIndex(): void + public function getIndex($p1 = ''): void { } diff --git a/tests/system/Router/Controllers/Subfolder/Home.php b/tests/system/Router/Controllers/Subfolder/Home.php new file mode 100644 index 000000000000..b249971f2516 --- /dev/null +++ b/tests/system/Router/Controllers/Subfolder/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\Subfolder; + +use CodeIgniter\Controller; + +class Home extends Controller +{ + public function getIndex($p1 = null, $p2 = null) + { + } +} diff --git a/tests/system/Router/Controllers/Subfolder/Sub/Mycontroller.php b/tests/system/Router/Controllers/Subfolder/Sub/Mycontroller.php new file mode 100644 index 000000000000..7bd80203b914 --- /dev/null +++ b/tests/system/Router/Controllers/Subfolder/Sub/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\Sub; + +use CodeIgniter\Controller; + +class Mycontroller extends Controller +{ + public function getSomemethod() + { + } +} diff --git a/tests/system/Router/DefinedRouteCollectorTest.php b/tests/system/Router/DefinedRouteCollectorTest.php new file mode 100644 index 000000000000..ba74cec1a86a --- /dev/null +++ b/tests/system/Router/DefinedRouteCollectorTest.php @@ -0,0 +1,90 @@ + + * + * 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\Test\CIUnitTestCase; +use Config\Modules; +use Config\Routing; + +/** + * @internal + * + * @group Others + */ +final class DefinedRouteCollectorTest extends CIUnitTestCase +{ + private function createRouteCollection(array $config = [], $moduleConfig = null): RouteCollection + { + $defaults = [ + 'Config' => APPPATH . 'Config', + 'App' => APPPATH, + ]; + $config = array_merge($config, $defaults); + + Services::autoloader()->addNamespace($config); + + $loader = Services::locator(); + + if ($moduleConfig === null) { + $moduleConfig = new Modules(); + $moduleConfig->enabled = false; + } + + return (new RouteCollection($loader, $moduleConfig, new Routing()))->setHTTPVerb('get'); + } + + public function testCollect() + { + $routes = $this->createRouteCollection(); + $routes->get('journals', 'Blogs'); + $routes->get('product/(:num)', 'Catalog::productLookupByID/$1'); + $routes->get('feed', static fn () => 'A Closure route.'); + $routes->view('about', 'pages/about'); + + $collector = new DefinedRouteCollector($routes); + + $definedRoutes = []; + + foreach ($collector->collect() as $route) { + $definedRoutes[] = $route; + } + + $expected = [ + [ + 'method' => 'get', + 'route' => 'journals', + 'name' => 'journals', + 'handler' => '\App\Controllers\Blogs', + ], + [ + 'method' => 'get', + 'route' => 'product/([0-9]+)', + 'name' => 'product/([0-9]+)', + 'handler' => '\App\Controllers\Catalog::productLookupByID/$1', + ], + [ + 'method' => 'get', + 'route' => 'feed', + 'name' => 'feed', + 'handler' => '(Closure)', + ], + [ + 'method' => 'get', + 'route' => 'about', + 'name' => 'about', + 'handler' => '(View) pages/about', + ], + ]; + $this->assertSame($expected, $definedRoutes); + } +} diff --git a/tests/system/Router/RouteCollectionReverseRouteTest.php b/tests/system/Router/RouteCollectionReverseRouteTest.php index 8335d516f9e8..d00e48e35b0d 100644 --- a/tests/system/Router/RouteCollectionReverseRouteTest.php +++ b/tests/system/Router/RouteCollectionReverseRouteTest.php @@ -15,6 +15,7 @@ use CodeIgniter\Router\Exceptions\RouterException; use CodeIgniter\Test\CIUnitTestCase; use Config\Modules; +use Config\Routing; /** * @internal @@ -48,7 +49,7 @@ protected function getCollector(array $config = [], array $files = [], $moduleCo $moduleConfig->enabled = false; } - return (new RouteCollection($loader, $moduleConfig))->setHTTPVerb('get'); + return (new RouteCollection($loader, $moduleConfig, new Routing()))->setHTTPVerb('get'); } public function testReverseRoutingFindsSimpleMatch(): void diff --git a/tests/system/Router/RouteCollectionTest.php b/tests/system/Router/RouteCollectionTest.php index a4e49c9ad4f4..37844fed6cdb 100644 --- a/tests/system/Router/RouteCollectionTest.php +++ b/tests/system/Router/RouteCollectionTest.php @@ -16,6 +16,7 @@ use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Test\CIUnitTestCase; use Config\Modules; +use Config\Routing; use Tests\Support\Controllers\Hello; /** @@ -50,7 +51,10 @@ protected function getCollector(array $config = [], array $files = [], $moduleCo $moduleConfig->enabled = false; } - return (new RouteCollection($loader, $moduleConfig))->setHTTPVerb('get'); + $routerConfig = new Routing(); + $routerConfig->defaultNamespace = '\\'; + + return (new RouteCollection($loader, $moduleConfig, $routerConfig))->setHTTPVerb('get'); } public function testBasicAdd(): void diff --git a/tests/system/Router/RouterTest.php b/tests/system/Router/RouterTest.php index e863bb5e4baf..c5a05fb36ace 100644 --- a/tests/system/Router/RouterTest.php +++ b/tests/system/Router/RouterTest.php @@ -13,11 +13,12 @@ use CodeIgniter\Config\Services; use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\Exceptions\RedirectException; use CodeIgniter\HTTP\IncomingRequest; -use CodeIgniter\Router\Exceptions\RedirectException; use CodeIgniter\Router\Exceptions\RouterException; use CodeIgniter\Test\CIUnitTestCase; use Config\Modules; +use Config\Routing; use Tests\Support\Filters\Customfilter; /** @@ -36,7 +37,11 @@ protected function setUp(): void $moduleConfig = new Modules(); $moduleConfig->enabled = false; - $this->collection = new RouteCollection(Services::locator(), $moduleConfig); + + $routerConfig = new Routing(); + $routerConfig->defaultNamespace = '\\'; + + $this->collection = new RouteCollection(Services::locator(), $moduleConfig, $routerConfig); $routes = [ '/' => 'Home::index', diff --git a/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php b/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php index bf49eb1ab1fe..b0c1ef8ec707 100644 --- a/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php +++ b/tests/system/Security/SecurityCSRFCookieRandomizeTokenTest.php @@ -38,19 +38,21 @@ final class SecurityCSRFCookieRandomizeTokenTest extends CIUnitTestCase */ private string $randomizedToken = '8bc70b67c91494e815c7d2219c1ae0ab005513c290126d34d41bf41c5265e0f1'; + private SecurityConfig $config; + protected function setUp(): void { parent::setUp(); $_COOKIE = []; - $config = new SecurityConfig(); - $config->csrfProtection = Security::CSRF_PROTECTION_COOKIE; - $config->tokenRandomize = true; - Factories::injectMock('config', 'Security', $config); + $this->config = new SecurityConfig(); + $this->config->csrfProtection = Security::CSRF_PROTECTION_COOKIE; + $this->config->tokenRandomize = true; + Factories::injectMock('config', 'Security', $this->config); // Set Cookie value - $security = new MockSecurity(new MockAppConfig()); + $security = new MockSecurity($this->config); $_COOKIE[$security->getCookieName()] = $this->hash; $this->resetServices(); @@ -58,7 +60,7 @@ protected function setUp(): void public function testTokenIsReadFromCookie(): void { - $security = new MockSecurity(new MockAppConfig()); + $security = new MockSecurity($this->config); $this->assertSame( $this->randomizedToken, @@ -74,7 +76,7 @@ public function testCSRFVerifySetNewCookie(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $security = new Security(new MockAppConfig()); + $security = new Security($this->config); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); diff --git a/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php b/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php index 8e593903595c..2a148c902e0f 100644 --- a/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php +++ b/tests/system/Security/SecurityCSRFSessionRandomizeTokenTest.php @@ -25,9 +25,10 @@ use CodeIgniter\Test\Mock\MockSecurity; use CodeIgniter\Test\Mock\MockSession; use CodeIgniter\Test\TestLogger; -use Config\App as AppConfig; +use Config\Cookie; use Config\Logger as LoggerConfig; use Config\Security as SecurityConfig; +use Config\Session as SessionConfig; /** * @runTestsInSeparateProcesses @@ -50,6 +51,8 @@ final class SecurityCSRFSessionRandomizeTokenTest extends CIUnitTestCase */ private string $randomizedToken = '8bc70b67c91494e815c7d2219c1ae0ab005513c290126d34d41bf41c5265e0f1'; + private SecurityConfig $config; + protected function setUp(): void { parent::setUp(); @@ -57,10 +60,10 @@ protected function setUp(): void $_SESSION = []; Factories::reset(); - $config = new SecurityConfig(); - $config->csrfProtection = Security::CSRF_PROTECTION_SESSION; - $config->tokenRandomize = true; - Factories::injectMock('config', 'Security', $config); + $this->config = new SecurityConfig(); + $this->config->csrfProtection = Security::CSRF_PROTECTION_SESSION; + $this->config->tokenRandomize = true; + Factories::injectMock('config', 'Security', $this->config); $this->injectSession($this->hash); } @@ -68,28 +71,36 @@ protected function setUp(): void private function createSession($options = []): Session { $defaults = [ - 'sessionDriver' => FileHandler::class, - 'sessionCookieName' => 'ci_session', - 'sessionExpiration' => 7200, - 'sessionSavePath' => '', - 'sessionMatchIP' => false, - 'sessionTimeToUpdate' => 300, - 'sessionRegenerateDestroy' => false, - 'cookieDomain' => '', - 'cookiePrefix' => '', - 'cookiePath' => '/', - 'cookieSecure' => false, - 'cookieSameSite' => 'Lax', + 'driver' => FileHandler::class, + 'cookieName' => 'ci_session', + 'expiration' => 7200, + 'savePath' => '', + 'matchIP' => false, + 'timeToUpdate' => 300, + 'regenerateDestroy' => false, ]; + $config = array_merge($defaults, $options); - $config = array_merge($defaults, $options); - $appConfig = new AppConfig(); + $sessionConfig = new SessionConfig(); foreach ($config as $key => $c) { - $appConfig->{$key} = $c; + $sessionConfig->{$key} = $c; } - $session = new MockSession(new ArrayHandler($appConfig, '127.0.0.1'), $appConfig); + $cookie = new Cookie(); + + foreach ([ + 'prefix' => '', + 'domain' => '', + 'path' => '/', + 'secure' => false, + 'samesite' => 'Lax', + ] as $key => $value) { + $cookie->{$key} = $value; + } + Factories::injectMock('config', 'Cookie', $cookie); + + $session = new MockSession(new ArrayHandler($sessionConfig, '127.0.0.1'), $sessionConfig); $session->setLogger(new TestLogger(new LoggerConfig())); return $session; @@ -102,9 +113,14 @@ private function injectSession(string $hash): void Services::injectMock('session', $session); } + private function createSecurity(): Security + { + return new Security($this->config); + } + public function testHashIsReadFromSession(): void { - $security = new MockSecurity(new MockAppConfig()); + $security = new MockSecurity($this->config); $this->assertSame( $this->randomizedToken, @@ -122,7 +138,7 @@ public function testCSRFVerifyPostNoToken(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $security->verify($request); } @@ -137,7 +153,7 @@ public function testCSRFVerifyPostThrowsExceptionOnNoMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $security->verify($request); } @@ -152,7 +168,7 @@ public function testCSRFVerifyPostInvalidToken(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $security->verify($request); } @@ -165,7 +181,7 @@ public function testCSRFVerifyPostReturnsSelfOnMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); @@ -179,7 +195,7 @@ public function testCSRFVerifyPOSTHeaderThrowsExceptionOnNoMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005b'); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->expectException(SecurityException::class); $this->expectExceptionMessage('The action you requested is not allowed.'); @@ -195,7 +211,7 @@ public function testCSRFVerifyPOSTHeaderReturnsSelfOnMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setHeader('X-CSRF-TOKEN', $this->randomizedToken); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); @@ -209,7 +225,7 @@ public function testCSRFVerifyPUTHeaderThrowsExceptionOnNoMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005b'); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->expectException(SecurityException::class); $this->expectExceptionMessage('The action you requested is not allowed.'); @@ -224,7 +240,7 @@ public function testCSRFVerifyPUTHeaderReturnsSelfOnMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setHeader('X-CSRF-TOKEN', $this->randomizedToken); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); @@ -240,7 +256,7 @@ public function testCSRFVerifyJsonThrowsExceptionOnNoMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005b"}'); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $security->verify($request); } @@ -252,7 +268,7 @@ public function testCSRFVerifyJsonReturnsSelfOnMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setBody('{"csrf_test_name":"' . $this->randomizedToken . '","foo":"bar"}'); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); @@ -271,7 +287,7 @@ public function testRegenerateWithFalseSecurityRegenerateProperty(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $security = new MockSecurity(new MockAppConfig()); + $security = new MockSecurity($this->config); $oldHash = $security->getHash(); $security->verify($request); @@ -292,7 +308,7 @@ public function testRegenerateWithTrueSecurityRegenerateProperty(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $oldHash = $security->getHash(); $security->verify($request); diff --git a/tests/system/Security/SecurityCSRFSessionTest.php b/tests/system/Security/SecurityCSRFSessionTest.php index 12bee2761732..a8f784a5464b 100644 --- a/tests/system/Security/SecurityCSRFSessionTest.php +++ b/tests/system/Security/SecurityCSRFSessionTest.php @@ -24,9 +24,10 @@ use CodeIgniter\Test\Mock\MockAppConfig; use CodeIgniter\Test\Mock\MockSession; use CodeIgniter\Test\TestLogger; -use Config\App as AppConfig; +use Config\Cookie; use Config\Logger as LoggerConfig; use Config\Security as SecurityConfig; +use Config\Session as SessionConfig; /** * @runTestsInSeparateProcesses @@ -44,6 +45,8 @@ final class SecurityCSRFSessionTest extends CIUnitTestCase */ private string $hash = '8b9218a55906f9dcc1dc263dce7f005a'; + private SecurityConfig $config; + protected function setUp(): void { parent::setUp(); @@ -51,9 +54,9 @@ protected function setUp(): void $_SESSION = []; Factories::reset(); - $config = new SecurityConfig(); - $config->csrfProtection = Security::CSRF_PROTECTION_SESSION; - Factories::injectMock('config', 'Security', $config); + $this->config = new SecurityConfig(); + $this->config->csrfProtection = Security::CSRF_PROTECTION_SESSION; + Factories::injectMock('config', 'Security', $this->config); $this->injectSession($this->hash); } @@ -61,28 +64,36 @@ protected function setUp(): void private function createSession($options = []): Session { $defaults = [ - 'sessionDriver' => FileHandler::class, - 'sessionCookieName' => 'ci_session', - 'sessionExpiration' => 7200, - 'sessionSavePath' => '', - 'sessionMatchIP' => false, - 'sessionTimeToUpdate' => 300, - 'sessionRegenerateDestroy' => false, - 'cookieDomain' => '', - 'cookiePrefix' => '', - 'cookiePath' => '/', - 'cookieSecure' => false, - 'cookieSameSite' => 'Lax', + 'driver' => FileHandler::class, + 'cookieName' => 'ci_session', + 'expiration' => 7200, + 'savePath' => '', + 'matchIP' => false, + 'timeToUpdate' => 300, + 'regenerateDestroy' => false, ]; + $config = array_merge($defaults, $options); - $config = array_merge($defaults, $options); - $appConfig = new AppConfig(); + $sessionConfig = new SessionConfig(); foreach ($config as $key => $c) { - $appConfig->{$key} = $c; + $sessionConfig->{$key} = $c; + } + + $cookie = new Cookie(); + + foreach ([ + 'prefix' => '', + 'domain' => '', + 'path' => '/', + 'secure' => false, + 'samesite' => 'Lax', + ] as $key => $value) { + $cookie->{$key} = $value; } + Factories::injectMock('config', 'Cookie', $cookie); - $session = new MockSession(new ArrayHandler($appConfig, '127.0.0.1'), $appConfig); + $session = new MockSession(new ArrayHandler($sessionConfig, '127.0.0.1'), $sessionConfig); $session->setLogger(new TestLogger(new LoggerConfig())); return $session; @@ -95,9 +106,14 @@ private function injectSession(string $hash): void Services::injectMock('session', $session); } + private function createSecurity(): Security + { + return new Security($this->config); + } + public function testHashIsReadFromSession(): void { - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->assertSame($this->hash, $security->getHash()); } @@ -111,7 +127,7 @@ public function testCSRFVerifyPostThrowsExceptionOnNoMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $security->verify($request); } @@ -124,7 +140,7 @@ public function testCSRFVerifyPostReturnsSelfOnMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); @@ -138,7 +154,7 @@ public function testCSRFVerifyPOSTHeaderThrowsExceptionOnNoMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005b'); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->expectException(SecurityException::class); $security->verify($request); @@ -152,7 +168,7 @@ public function testCSRFVerifyPOSTHeaderReturnsSelfOnMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); @@ -166,7 +182,7 @@ public function testCSRFVerifyPUTHeaderThrowsExceptionOnNoMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005b'); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->expectException(SecurityException::class); $security->verify($request); @@ -179,7 +195,7 @@ public function testCSRFVerifyPUTHeaderReturnsSelfOnMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setHeader('X-CSRF-TOKEN', '8b9218a55906f9dcc1dc263dce7f005a'); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); @@ -194,7 +210,7 @@ public function testCSRFVerifyJsonThrowsExceptionOnNoMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005b"}'); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $security->verify($request); } @@ -206,7 +222,7 @@ public function testCSRFVerifyJsonReturnsSelfOnMatch(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); $request->setBody('{"csrf_test_name":"8b9218a55906f9dcc1dc263dce7f005a","foo":"bar"}'); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $this->assertInstanceOf(Security::class, $security->verify($request)); $this->assertLogged('info', 'CSRF token verified.'); @@ -224,7 +240,7 @@ public function testRegenerateWithFalseSecurityRegenerateProperty(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $oldHash = $security->getHash(); $security->verify($request); @@ -244,7 +260,7 @@ public function testRegenerateWithTrueSecurityRegenerateProperty(): void $request = new IncomingRequest(new MockAppConfig(), new URI('http://badurl.com'), null, new UserAgent()); - $security = new Security(new MockAppConfig()); + $security = $this->createSecurity(); $oldHash = $security->getHash(); $security->verify($request); diff --git a/tests/system/Security/SecurityTest.php b/tests/system/Security/SecurityTest.php index 9dd2aa26e77b..972115de6a7b 100644 --- a/tests/system/Security/SecurityTest.php +++ b/tests/system/Security/SecurityTest.php @@ -41,9 +41,16 @@ protected function setUp(): void $this->resetServices(); } + private function createMockSecurity(?SecurityConfig $config = null): MockSecurity + { + $config ??= new SecurityConfig(); + + return new MockSecurity($config); + } + public function testBasicConfigIsSaved(): void { - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $hash = $security->getHash(); @@ -55,7 +62,7 @@ public function testHashIsReadFromCookie(): void { $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $this->assertSame( '8b9218a55906f9dcc1dc263dce7f005a', @@ -65,7 +72,7 @@ public function testHashIsReadFromCookie(): void public function testGetHashSetsCookieWhenGETWithoutCSRFCookie(): void { - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -80,7 +87,7 @@ public function testGetHashReturnsCSRFCookieWhenGETWithCSRFCookie(): void $_SERVER['REQUEST_METHOD'] = 'GET'; $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $security->verify(new Request(new MockAppConfig())); @@ -93,7 +100,7 @@ public function testCSRFVerifyPostThrowsExceptionOnNoMatch(): void $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $request = new IncomingRequest( new MockAppConfig(), new URI('http://badurl.com'), @@ -112,7 +119,7 @@ public function testCSRFVerifyPostReturnsSelfOnMatch(): void $_POST['csrf_test_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $request = new IncomingRequest( new MockAppConfig(), new URI('http://badurl.com'), @@ -131,7 +138,7 @@ public function testCSRFVerifyHeaderThrowsExceptionOnNoMatch(): void $_SERVER['REQUEST_METHOD'] = 'POST'; $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $request = new IncomingRequest( new MockAppConfig(), new URI('http://badurl.com'), @@ -151,7 +158,7 @@ public function testCSRFVerifyHeaderReturnsSelfOnMatch(): void $_POST['foo'] = 'bar'; $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $request = new IncomingRequest( new MockAppConfig(), new URI('http://badurl.com'), @@ -172,7 +179,7 @@ public function testCSRFVerifyJsonThrowsExceptionOnNoMatch(): void $_SERVER['REQUEST_METHOD'] = 'POST'; $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005b'; - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $request = new IncomingRequest( new MockAppConfig(), new URI('http://badurl.com'), @@ -193,7 +200,7 @@ public function testCSRFVerifyJsonReturnsSelfOnMatch(): void $_SERVER['REQUEST_METHOD'] = 'POST'; $_COOKIE['csrf_cookie_name'] = '8b9218a55906f9dcc1dc263dce7f005a'; - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $request = new IncomingRequest( new MockAppConfig(), new URI('http://badurl.com'), @@ -213,7 +220,7 @@ public function testCSRFVerifyJsonReturnsSelfOnMatch(): void public function testSanitizeFilename(): void { - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $filename = './'; @@ -230,7 +237,7 @@ public function testRegenerateWithFalseSecurityRegenerateProperty(): void $config->regenerate = false; Factories::injectMock('config', 'Security', $config); - $security = new MockSecurity(new MockAppConfig()); + $security = new MockSecurity($config); $request = new IncomingRequest( new MockAppConfig(), new URI('http://badurl.com'), @@ -255,7 +262,7 @@ public function testRegenerateWithFalseSecurityRegeneratePropertyManually(): voi $config->regenerate = false; Factories::injectMock('config', 'Security', $config); - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity($config); $request = new IncomingRequest( new MockAppConfig(), new URI('http://badurl.com'), @@ -281,7 +288,7 @@ public function testRegenerateWithTrueSecurityRegenerateProperty(): void $config->regenerate = true; Factories::injectMock('config', 'Security', $config); - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity($config); $request = new IncomingRequest( new MockAppConfig(), new URI('http://badurl.com'), @@ -298,7 +305,7 @@ public function testRegenerateWithTrueSecurityRegenerateProperty(): void public function testGetters(): void { - $security = new MockSecurity(new MockAppConfig()); + $security = $this->createMockSecurity(); $this->assertIsString($security->getHash()); $this->assertIsString($security->getTokenName()); diff --git a/tests/system/Session/Handlers/Database/MySQLiHandlerTest.php b/tests/system/Session/Handlers/Database/MySQLiHandlerTest.php index b18f1b047eef..19266f4462b5 100644 --- a/tests/system/Session/Handlers/Database/MySQLiHandlerTest.php +++ b/tests/system/Session/Handlers/Database/MySQLiHandlerTest.php @@ -11,8 +11,6 @@ namespace CodeIgniter\Session\Handlers\Database; -use CodeIgniter\Config\Factories; -use Config\App as AppConfig; use Config\Database as DatabaseConfig; use Config\Session as SessionConfig; @@ -49,8 +47,7 @@ protected function getInstance($options = []) foreach ($config as $key => $value) { $sessionConfig->{$key} = $value; } - Factories::injectMock('config', 'Session', $sessionConfig); - return new MySQLiHandler(new AppConfig(), $this->userIpAddress); + return new MySQLiHandler($sessionConfig, $this->userIpAddress); } } diff --git a/tests/system/Session/Handlers/Database/PostgreHandlerTest.php b/tests/system/Session/Handlers/Database/PostgreHandlerTest.php index f9db847faab0..3e5fe5a3b10b 100644 --- a/tests/system/Session/Handlers/Database/PostgreHandlerTest.php +++ b/tests/system/Session/Handlers/Database/PostgreHandlerTest.php @@ -11,8 +11,6 @@ namespace CodeIgniter\Session\Handlers\Database; -use CodeIgniter\Config\Factories; -use Config\App as AppConfig; use Config\Database as DatabaseConfig; use Config\Session as SessionConfig; @@ -49,8 +47,7 @@ protected function getInstance($options = []) foreach ($config as $key => $value) { $sessionConfig->{$key} = $value; } - Factories::injectMock('config', 'Session', $sessionConfig); - return new PostgreHandler(new AppConfig(), $this->userIpAddress); + return new PostgreHandler($sessionConfig, $this->userIpAddress); } } diff --git a/tests/system/Session/Handlers/Database/RedisHandlerTest.php b/tests/system/Session/Handlers/Database/RedisHandlerTest.php index 032b4e6c77cd..d7acb0dbf56d 100644 --- a/tests/system/Session/Handlers/Database/RedisHandlerTest.php +++ b/tests/system/Session/Handlers/Database/RedisHandlerTest.php @@ -11,10 +11,8 @@ namespace CodeIgniter\Session\Handlers\Database; -use CodeIgniter\Config\Factories; use CodeIgniter\Session\Handlers\RedisHandler; use CodeIgniter\Test\CIUnitTestCase; -use Config\App as AppConfig; use Config\Session as SessionConfig; /** @@ -48,9 +46,8 @@ protected function getInstance($options = []) foreach ($config as $key => $value) { $sessionConfig->{$key} = $value; } - Factories::injectMock('config', 'Session', $sessionConfig); - return new RedisHandler(new AppConfig(), $this->userIpAddress); + return new RedisHandler($sessionConfig, $this->userIpAddress); } public function testSavePathWithoutProtocol(): void diff --git a/tests/system/Session/SessionTest.php b/tests/system/Session/SessionTest.php index 178102cdb36c..57ccff5b16d8 100644 --- a/tests/system/Session/SessionTest.php +++ b/tests/system/Session/SessionTest.php @@ -17,7 +17,6 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockSession; use CodeIgniter\Test\TestLogger; -use Config\App as AppConfig; use Config\Cookie as CookieConfig; use Config\Logger as LoggerConfig; use Config\Session as SessionConfig; @@ -43,8 +42,6 @@ protected function setUp(): void protected function getInstance($options = []) { - $appConfig = new AppConfig(); - $defaults = [ 'driver' => FileHandler::class, 'cookieName' => 'ci_session', @@ -62,7 +59,7 @@ protected function getInstance($options = []) } Factories::injectMock('config', 'Session', $sessionConfig); - $session = new MockSession(new FileHandler($appConfig, '127.0.0.1'), $appConfig); + $session = new MockSession(new FileHandler($sessionConfig, '127.0.0.1'), $sessionConfig); $session->setLogger(new TestLogger(new LoggerConfig())); return $session; diff --git a/tests/system/SuperglobalsTest.php b/tests/system/SuperglobalsTest.php new file mode 100644 index 000000000000..f530145da766 --- /dev/null +++ b/tests/system/SuperglobalsTest.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter; + +use CodeIgniter\Test\CIUnitTestCase; + +/** + * @internal + * + * @group Others + */ +final class SuperglobalsTest extends CIUnitTestCase +{ + public function testSetGet() + { + $globals = new Superglobals([], []); + + $globals->setGet('test', 'value1'); + + $this->assertSame('value1', $globals->get('test')); + } +} diff --git a/tests/system/Test/ControllerTestTraitTest.php b/tests/system/Test/ControllerTestTraitTest.php index b5d08e72ed8f..ab45c6372d41 100644 --- a/tests/system/Test/ControllerTestTraitTest.php +++ b/tests/system/Test/ControllerTestTraitTest.php @@ -19,6 +19,7 @@ use Config\App; use Config\Services; use Exception; +use Tests\Support\Controllers\Newautorouting; use Tests\Support\Controllers\Popcorn; /** @@ -259,4 +260,15 @@ public function throwsBody(): void $this->withBody('banana')->execute('throwsBody'); } + + public function testWithUriUpdatesUriStringAndCurrentUrlValues() + { + $result = $this->withURI('http://example.com/foo/bar/1/2/3') + ->controller(Newautorouting::class) + ->execute('postSave', '1', '2', '3'); + + $this->assertSame('Saved', $result->response()->getBody()); + $this->assertSame('foo/bar/1/2/3', uri_string()); + $this->assertSame('http://example.com/index.php/foo/bar/1/2/3', current_url()); + } } diff --git a/tests/system/Test/FabricatorTest.php b/tests/system/Test/FabricatorTest.php index 8242f629e0ab..4fb9326d6a56 100644 --- a/tests/system/Test/FabricatorTest.php +++ b/tests/system/Test/FabricatorTest.php @@ -11,7 +11,7 @@ namespace CodeIgniter\Test; -use CodeIgniter\Database\ModelFactory; +use CodeIgniter\Config\Factories; use Tests\Support\Models\EntityModel; use Tests\Support\Models\EventModel; use Tests\Support\Models\FabricatorModel; @@ -98,12 +98,16 @@ public function testConstructorUsesProvidedLocale(): void public function testModelUsesNewInstance(): void { - // Inject the wrong model for UserModel to show it is ignored by Fabricator + // Inject the wrong model for UserModel $mock = new FabricatorModel(); - ModelFactory::injectMock(UserModel::class, $mock); + Factories::injectMock('models', UserModel::class, $mock); $fabricator = new Fabricator(UserModel::class); - $this->assertInstanceOf(UserModel::class, $fabricator->getModel()); + + // Fabricator gets the instance from Factories, so it is FabricatorModel. + $this->assertInstanceOf(FabricatorModel::class, $fabricator->getModel()); + // But Fabricator creates a new instance. + $this->assertNotSame($mock, $fabricator->getModel()); } public function testGetModelReturnsModel(): void diff --git a/tests/system/Test/FeatureTestTraitTest.php b/tests/system/Test/FeatureTestTraitTest.php index f40817767b6d..0f5bb7ce33d5 100644 --- a/tests/system/Test/FeatureTestTraitTest.php +++ b/tests/system/Test/FeatureTestTraitTest.php @@ -52,25 +52,46 @@ public function testCallGet(): void ]); $response = $this->get('home'); + $this->assertInstanceOf(TestResponse::class, $response); + $this->assertInstanceOf(Response::class, $response->response()); + $this->assertTrue($response->isOK()); + $this->assertSame('Hello World', $response->response()->getBody()); + $this->assertSame(200, $response->response()->getStatusCode()); $response->assertSee('Hello World'); $response->assertDontSee('Again'); } - public function testCallSimpleGet(): void + public function testCallGetAndUriString(): void + { + $this->withRoutes([ + [ + 'get', + 'foo/bar/1/2/3', + static fn () => 'Hello World', + ], + ]); + $response = $this->get('foo/bar/1/2/3'); + + $this->assertSame('Hello World', $response->response()->getBody()); + $this->assertSame('foo/bar/1/2/3', uri_string()); + $this->assertSame('http://example.com/index.php/foo/bar/1/2/3', current_url()); + } + + public function testClosureWithEcho() { $this->withRoutes([ [ - 'add', + 'get', 'home', - static fn () => 'Hello Earth', + static function () { echo 'test echo'; }, ], ]); - $response = $this->call('get', 'home'); + $response = $this->get('home'); $this->assertInstanceOf(TestResponse::class, $response); $this->assertInstanceOf(Response::class, $response->response()); $this->assertTrue($response->isOK()); - $this->assertSame('Hello Earth', $response->response()->getBody()); + $this->assertSame('test echo', $response->response()->getBody()); $this->assertSame(200, $response->response()->getStatusCode()); } diff --git a/tests/system/Validation/DotArrayFilterTest.php b/tests/system/Validation/DotArrayFilterTest.php new file mode 100644 index 000000000000..6d3b380f517a --- /dev/null +++ b/tests/system/Validation/DotArrayFilterTest.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Validation; + +use CodeIgniter\Test\CIUnitTestCase; + +/** + * @internal + * + * @group Others + */ +final class DotArrayFilterTest extends CIUnitTestCase +{ + public function testRunReturnEmptyArray() + { + $data = []; + + $result = DotArrayFilter::run(['foo.bar'], $data); + + $this->assertSame([], $result); + } + + public function testRunReturnEmptyArrayMissingValue() + { + $data = [ + 'foo' => [ + 'bar' => 23, + ], + ]; + + $result = DotArrayFilter::run(['foo.baz'], $data); + + $this->assertSame([], $result); + } + + public function testRunReturnEmptyArrayEmptyIndex() + { + $data = [ + 'foo' => [ + 'bar' => 23, + ], + ]; + + $result = DotArrayFilter::run([''], $data); + + $this->assertSame([], $result); + } + + public function testRunEarlyIndex() + { + $data = [ + 'foo' => [ + 'bar' => 23, + ], + ]; + + $result = DotArrayFilter::run(['foo'], $data); + + $this->assertSame($data, $result); + } + + public function testRunWildcard() + { + $data = [ + 'foo' => [ + 'bar' => [ + 'baz' => 23, + ], + ], + ]; + + $result = DotArrayFilter::run(['foo.*.baz'], $data); + + $this->assertSame($data, $result); + } + + public function testRunWildcardWithMultipleChoices() + { + $data = [ + 'foo' => [ + 'buzz' => [ + 'fizz' => 11, + ], + 'bar' => [ + 'baz' => 23, + ], + ], + ]; + + $result = DotArrayFilter::run(['foo.*.fizz', 'foo.*.baz'], $data); + + $this->assertSame($data, $result); + } + + public function testRunNestedNotFound() + { + $data = [ + 'foo' => [ + 'buzz' => [ + 'fizz' => 11, + ], + 'bar' => [ + 'baz' => 23, + ], + ], + ]; + + $result = DotArrayFilter::run(['foo.*.notthere'], $data); + + $this->assertSame([], $result); + } + + public function testRunIgnoresLastWildcard() + { + $data = [ + 'foo' => [ + 'bar' => [ + 'baz' => 23, + ], + ], + ]; + + $result = DotArrayFilter::run(['foo.bar.*'], $data); + + $this->assertSame($data, $result); + } + + public function testRunNestedArray() + { + $array = [ + 'user' => [ + 'name' => 'John', + 'age' => 30, + 'email' => 'john@example.com', + 'preferences' => [ + 'theme' => 'dark', + 'language' => 'en', + 'notifications' => [ + 'email' => true, + 'push' => false, + ], + ], + ], + 'product' => [ + 'name' => 'Acme Product', + 'description' => 'This is a great product!', + 'price' => 19.99, + ], + ]; + + $result = DotArrayFilter::run([ + 'user.name', + 'user.preferences.language', + 'user.preferences.notifications.email', + 'product.name', + ], $array); + + $expected = [ + 'user' => [ + 'name' => 'John', + 'preferences' => [ + 'language' => 'en', + 'notifications' => [ + 'email' => true, + ], + ], + ], + 'product' => [ + 'name' => 'Acme Product', + ], + ]; + $this->assertSame($expected, $result); + } +} diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index 8f05442c74bb..e015c8b21de9 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -238,6 +238,7 @@ public function testRunReturnsFalseWithNothingToDo(): void { $this->validation->setRules([]); $this->assertFalse($this->validation->run([])); + $this->assertSame([], $this->validation->getValidated()); } public function testRuleClassesInstantiatedOnce(): void @@ -260,7 +261,9 @@ public function testRunDoesTheBasics(): void { $data = ['foo' => 'notanumber']; $this->validation->setRules(['foo' => 'is_numeric']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame([], $this->validation->getValidated()); } public function testClosureRule(): void @@ -279,13 +282,14 @@ public function testClosureRule(): void ); $data = ['foo' => 'xyz']; - $return = $this->validation->run($data); + $result = $this->validation->run($data); - $this->assertFalse($return); + $this->assertFalse($result); $this->assertSame( ['foo' => 'The value is not "abc"'], $this->validation->getErrors() ); + $this->assertSame([], $this->validation->getValidated()); } public function testClosureRuleWithParamError(): void @@ -306,13 +310,14 @@ static function ($value, $data, &$error, $field) { ]); $data = ['foo' => 'xyz']; - $return = $this->validation->run($data); + $result = $this->validation->run($data); - $this->assertFalse($return); + $this->assertFalse($result); $this->assertSame( ['foo' => 'The foo value is not "abc"'], $this->validation->getErrors() ); + $this->assertSame([], $this->validation->getValidated()); } public function testClosureRuleWithLabel(): void @@ -329,9 +334,9 @@ public function testClosureRuleWithLabel(): void ]); $data = ['secret' => 'xyz']; - $return = $this->validation->run($data); + $result = $this->validation->run($data); - $this->assertFalse($return); + $this->assertFalse($result); $this->assertSame( ['secret' => 'The シークレット is invalid'], $this->validation->getErrors() @@ -457,8 +462,9 @@ public function testRunWithCustomErrors(): void ], ]; $this->validation->setRules(['foo' => 'is_numeric', 'bar' => 'is_numeric'], $messages); - $this->validation->run($data); + $result = $this->validation->run($data); + $this->assertFalse($result); $this->assertSame('Nope. Not a number.', $this->validation->getError('foo')); $this->assertSame('No. Not a number.', $this->validation->getError('bar')); } @@ -484,8 +490,9 @@ public function testSetRuleWithCustomErrors(): void ['bar' => 'is_numeric'], ['is_numeric' => 'Nope. Not a number.'] ); - $this->validation->run($data); + $result = $this->validation->run($data); + $this->assertFalse($result); $this->assertSame('Nope. Not a number.', $this->validation->getError('foo')); $this->assertSame('Nope. Not a number.', $this->validation->getError('bar')); } @@ -513,7 +520,9 @@ public function testGetErrors(): void { $data = ['foo' => 'notanumber']; $this->validation->setRules(['foo' => 'is_numeric']); - $this->validation->run($data); + $result = $this->validation->run($data); + + $this->assertFalse($result); $this->assertSame(['foo' => 'Validation.is_numeric'], $this->validation->getErrors()); } @@ -521,7 +530,9 @@ public function testGetErrorsWhenNone(): void { $data = ['foo' => 123]; $this->validation->setRules(['foo' => 'is_numeric']); - $this->validation->run($data); + $result = $this->validation->run($data); + + $this->assertTrue($result); $this->assertSame([], $this->validation->getErrors()); } @@ -535,7 +546,7 @@ public function testSetErrors(): void public function testRulesReturnErrors(): void { $this->validation->setRules(['foo' => 'customError']); - $this->validation->run(['foo' => 'bar']); + $this->assertFalse($this->validation->run(['foo' => 'bar'])); $this->assertSame(['foo' => 'My lovely error'], $this->validation->getErrors()); } @@ -584,6 +595,7 @@ public function testSetRuleGroupWithCustomErrorMessage(): void $this->validation->reset(); $this->validation->setRuleGroup('login'); $this->validation->run(['username' => 'codeigniter']); + $this->assertSame([ 'password' => 'custom password required error msg.', ], $this->validation->getErrors()); @@ -733,48 +745,46 @@ public function testInvalidRule(): void public function testRawInput(): void { - $rawstring = 'username=admin001&role=administrator&usepass=0'; - - $data = [ - 'username' => 'admin001', - 'role' => 'administrator', - 'usepass' => 0, - ]; - + $rawstring = 'username=admin001&role=administrator&usepass=0'; $config = new App(); $config->baseURL = 'http://example.com/'; + $request = new IncomingRequest($config, new URI(), $rawstring, new UserAgent()); - $request = new IncomingRequest($config, new URI(), $rawstring, new UserAgent()); - $this->validation->withRequest($request->withMethod('patch'))->run($data); + $rules = [ + 'role' => 'required|min_length[5]', + ]; + $result = $this->validation->withRequest($request->withMethod('patch'))->setRules($rules)->run(); + + $this->assertTrue($result); $this->assertSame([], $this->validation->getErrors()); + $this->assertSame(['role' => 'administrator'], $this->validation->getValidated()); } public function testJsonInput(): void { + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $data = [ 'username' => 'admin001', 'role' => 'administrator', 'usepass' => 0, ]; - $json = json_encode($data); - - $_SERVER['CONTENT_TYPE'] = 'application/json'; - + $json = json_encode($data); $config = new App(); $config->baseURL = 'http://example.com/'; - - $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); + $request = new IncomingRequest($config, new URI(), $json, new UserAgent()); $rules = [ 'role' => 'required|min_length[5]', ]; - $validated = $this->validation + $result = $this->validation ->withRequest($request->withMethod('patch')) ->setRules($rules) ->run(); - $this->assertTrue($validated); + $this->assertTrue($result); $this->assertSame([], $this->validation->getErrors()); + $this->assertSame(['role' => 'administrator'], $this->validation->getValidated()); unset($_SERVER['CONTENT_TYPE']); } @@ -804,12 +814,12 @@ public function testJsonInputObjectArray(): void $rules = [ 'p' => 'required|array_count[2]', ]; - $validated = $this->validation + $result = $this->validation ->withRequest($request->withMethod('patch')) ->setRules($rules) ->run(); - $this->assertFalse($validated); + $this->assertFalse($result); $this->assertSame(['p' => 'Validation.array_count'], $this->validation->getErrors()); unset($_SERVER['CONTENT_TYPE']); @@ -937,7 +947,10 @@ public function testTagReplacement(): void 'min_length' => 'Supplied value ({value}) for {field} must have at least {param} characters.', ]] ); - $this->validation->run($data); + $result = $this->validation->run($data); + + $this->assertFalse($result); + $errors = $this->validation->getErrors(); if (! isset($errors['Username'])) { @@ -954,8 +967,10 @@ public function testRulesForObjectField(): void 'configuration' => 'required|check_object_rule', ]); - $data = (object) ['configuration' => (object) ['first' => 1, 'second' => 2]]; - $this->validation->run((array) $data); + $data = (object) ['configuration' => (object) ['first' => 1, 'second' => 2]]; + $result = $this->validation->run((array) $data); + + $this->assertTrue($result); $this->assertSame([], $this->validation->getErrors()); $this->validation->reset(); @@ -963,9 +978,10 @@ public function testRulesForObjectField(): void 'configuration' => 'required|check_object_rule', ]); - $data = (object) ['configuration' => (object) ['first1' => 1, 'second' => 2]]; - $this->validation->run((array) $data); + $data = (object) ['configuration' => (object) ['first1' => 1, 'second' => 2]]; + $result = $this->validation->run((array) $data); + $this->assertFalse($result); $this->assertSame([ 'configuration' => 'Validation.check_object_rule', ], $this->validation->getErrors()); @@ -1188,7 +1204,6 @@ public function testTranslatedLabelWithCustomErrorMessage(): void public function testTranslatedLabelTagReplacement(): void { $data = ['Username' => 'Pizza']; - $this->validation->setRules( ['Username' => [ 'label' => 'Foo.bar', @@ -1198,8 +1213,10 @@ public function testTranslatedLabelTagReplacement(): void 'min_length' => 'Foo.bar.min_length2', ]] ); + $result = $this->validation->run($data); + + $this->assertFalse($result); - $this->validation->run($data); $errors = $this->validation->getErrors(); if (! isset($errors['Username'])) { @@ -1537,8 +1554,9 @@ public function testNestedArrayThrowsException(): void 'debit_amount' => '1500', 'beneficiaries_accounts' => [], ]; - $this->validation->run($data); + $result = $this->validation->run($data); + $this->assertFalse($result); $this->assertSame([ 'beneficiaries_accounts.*.account_number' => 'The BENEFICIARY ACCOUNT NUMBER field must be exactly 5 characters in length.', 'beneficiaries_accounts.*.credit_amount' => 'The CREDIT AMOUNT field is required.', @@ -1568,8 +1586,9 @@ public function testNestedArrayThrowsException(): void ], ], ]; - $this->validation->run($data); + $result = $this->validation->run($data); + $this->assertFalse($result); $this->assertSame([ 'beneficiaries_accounts.account_3.account_number' => 'The BENEFICIARY ACCOUNT NUMBER field must be exactly 5 characters in length.', 'beneficiaries_accounts.account_2.credit_amount' => 'The CREDIT AMOUNT field is required.', diff --git a/tests/system/View/TableTest.php b/tests/system/View/TableTest.php index 2c9bff35c4f0..3c607930616a 100644 --- a/tests/system/View/TableTest.php +++ b/tests/system/View/TableTest.php @@ -763,6 +763,59 @@ public function testInvalidCallback(): void $this->assertStringContainsString('FredBlueSmall', $generated); } + + /** + * @dataProvider orderedColumnUsecases + */ + public function testAddRowAndGenerateOrderedColumns(array $heading, array $row, string $expectContainsString): void + { + $this->table->setHeading($heading); + $this->table->setSyncRowsWithHeading(true); + $this->table->addRow($row); + + $generated = $this->table->generate(); + + $this->assertStringContainsString($expectContainsString, $generated); + } + + /** + * @dataProvider orderedColumnUsecases + */ + public function testGenerateOrderedColumns(array $heading, array $row, string $expectContainsString): void + { + $this->table->setHeading($heading); + $this->table->setSyncRowsWithHeading(true); + + $generated = $this->table->generate([$row]); + + $this->assertStringContainsString($expectContainsString, $generated); + } + + public static function orderedColumnUsecases(): iterable + { + yield from [ + 'reorder example #1' => [ + 'heading' => ['id' => 'ID', 'name' => 'Name', 'age' => 'Age'], + 'row' => ['name' => 'Max', 'age' => 30, 'id' => 5], + 'expectContainsString' => '5Max30', + ], + 'reorder example #2' => [ + 'heading' => ['id' => 'ID', 'age' => 'Age', 'name' => 'Name'], + 'row' => ['name' => 'Fred', 'age' => 30, 'id' => 5], + 'expectContainsString' => '530Fred', + ], + '2 col heading, 3 col data row' => [ + 'heading' => ['id' => 'ID', 'name' => 'Name'], + 'row' => ['name' => 'Fred', 'age' => 30, 'id' => 5], + 'expectContainsString' => '5Fred', + ], + '3 col heading, 2 col data row' => [ + 'heading' => ['id' => 'ID', 'age' => 'Age', 'name' => 'Name'], + 'row' => ['name' => 'Fred', 'id' => 5], + 'expectContainsString' => '5Fred', + ], + ]; + } } // We need this for the _set_from_db_result() test diff --git a/tests/system/View/ViewTest.php b/tests/system/View/ViewTest.php index 2a9e161b56d3..3f3b28827a00 100644 --- a/tests/system/View/ViewTest.php +++ b/tests/system/View/ViewTest.php @@ -387,4 +387,14 @@ public function testRenderNestedSections(): void $this->assertStringContainsString('

Second

', $content); $this->assertStringContainsString('

Third

', $content); } + + public function testRenderSectionSavingData() + { + $view = new View($this->config, $this->viewsDir, $this->loader); + $expected = "Welcome to CodeIgniter 4!\n

Welcome to CodeIgniter 4!

\n

Hello World

"; + + $view->setVar('pageTitle', 'Welcome to CodeIgniter 4!'); + $view->setVar('testString', 'Hello World'); + $this->assertStringContainsString($expected, $view->render('extend_reuse_section')); + } } diff --git a/tests/system/View/Views/extend_reuse_section.php b/tests/system/View/Views/extend_reuse_section.php new file mode 100644 index 000000000000..f7a5c0a263e2 --- /dev/null +++ b/tests/system/View/Views/extend_reuse_section.php @@ -0,0 +1,9 @@ +extend('layout_welcome') ?> + +section('page_title') ?> + +endSection() ?> + +section('content') ?> + +endSection() ?> diff --git a/tests/system/View/Views/layout_welcome.php b/tests/system/View/Views/layout_welcome.php new file mode 100644 index 000000000000..a3623a4aafad --- /dev/null +++ b/tests/system/View/Views/layout_welcome.php @@ -0,0 +1,3 @@ +<?= $this->renderSection('page_title', true) ?> +

renderSection('page_title') ?>

+

renderSection('content') ?>

diff --git a/user_guide_src/source/changelogs/index.rst b/user_guide_src/source/changelogs/index.rst index 8c50b9144694..2a04069e2406 100644 --- a/user_guide_src/source/changelogs/index.rst +++ b/user_guide_src/source/changelogs/index.rst @@ -12,6 +12,7 @@ See all the changes. .. toctree:: :titlesonly: + v4.4.0 v4.3.8 v4.3.7 v4.3.6 diff --git a/user_guide_src/source/changelogs/v4.4.0.rst b/user_guide_src/source/changelogs/v4.4.0.rst new file mode 100644 index 000000000000..13d121c07a02 --- /dev/null +++ b/user_guide_src/source/changelogs/v4.4.0.rst @@ -0,0 +1,368 @@ +Version 4.4.0 +############# + +Release Date: Unreleased + +**4.4.0 release of CodeIgniter4** + +.. contents:: + :local: + :depth: 3 + +Highlights +********** + +- TBD + +BREAKING +******** + +Behavior Changes +================ + +URI::setSegment() and Non-Existent Segment +------------------------------------------ + +An exception is now thrown when you set the last ``+2`` segment. +In previous versions, an exception was thrown only if the last segment ``+3`` +or more was specified. See :ref:`upgrade-440-uri-setsegment`. + +The next segment (``+1``) of the current last segment can be set as before. + +.. _v440-factories: + +Factories +--------- + +Passing Classname with Namespace +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Now ``preferApp`` works only when you request +:ref:`a classname without a namespace `. + +For example, when you call ``model(\Myth\Auth\Models\UserModel::class)`` or +``model('Myth\Auth\Models\UserModel')``: + + - before: + + - returns ``App\Models\UserModel`` if exists and ``preferApp`` is true (default) + - returns ``Myth\Auth\Models\UserModel`` if exists and ``preferApp`` is false + + - after: + + - returns ``Myth\Auth\Models\UserModel`` even if ``preferApp`` is true (default) + - returns ``App\Models\UserModel`` if you define ``Factories::define('models', 'Myth\Auth\Models\UserModel', 'App\Models\UserModel')`` before calling the ``model()`` + +If you had passed a non-existent classname by mistake, the previous version +would have returned a class instance in the ``App`` or ``Config`` namespace +because of the ``preferApp`` feature. + +For example, in a controller (``namespace App\Controllers``), if you called +``config(Config\App::class)`` by mistake (note the class is missing the leading ``\``), +meaning you actually passed ``App\Controllers\Config\App``. +But that class does not exist, so now Factories will return ``null``. + +Property Name +^^^^^^^^^^^^^ + +The property ``Factories::$basenames`` has been renamed to ``$aliases``. + +Autoloader +---------- + +Previously, CodeIgniter's autoloader allowed loading class names ending with the `.php` extension. This means instantiating objects like `new Foo.php()` was possible +and would instantiate as `new Foo()`. Since `Foo.php` is an invalid class name, this behavior of the autoloader is changed. Now, instantiating such classes would fail. + +.. _v440-codeigniter-and-exit: + +CodeIgniter and exit() +---------------------- + +The ``CodeIgniter::run()`` method no longer calls ``exit(EXIT_SUCCESS)``. The +exit call is moved to **public/index.php**. + +.. _v440-site-uri-changes: + +Site URI Changes +---------------- + +A new ``SiteURI`` class that extends the ``URI`` class and represents the site +URI has been added, and now it is used in many places that need the current URI. + +``$this->request->getUri()`` in controllers returns the ``SiteURI`` instance. +Also, :php:func:`site_url()`, :php:func:`base_url()`, and :php:func:`current_url()` +use the SiteURI internally. + +getPath() +^^^^^^^^^ + +The ``getPath()`` method now always returns the full URI path with leading ``/``. +Therefore, when your baseURL has sub-directories and you want to get the relative +path to baseURL, you must use the new ``getRoutePath()`` method instead. + +For example:: + + baseURL: http://localhost:8888/CodeIgniter4/ + The current URI: http://localhost:8888/CodeIgniter4/foo/bar + getPath(): /CodeIgniter4/foo/bar + getRoutePath(): foo/bar + +Site URI Values +^^^^^^^^^^^^^^^ + +The SiteURI class normalizes site URIs more strictly than before, and some bugs +have been fixed. + +As a result, the framework may return site URIs or the URI paths slightly differently +than in previous versions. +For example, ``/`` will be added after ``index.php``:: + + http://example.com/test/index.php?page=1 + ↓ + http://example.com/test/index.php/?page=1 + +.. _v440-interface-changes: + +Interface Changes +================= + +.. note:: As long as you have not extended the relevant CodeIgniter core classes + or implemented these interfaces, all these changes are backward compatible + and require no intervention. + +- **Validation:** Added the ``getValidated()`` method in ``ValidationInterface``. + +.. _v440-method-signature-changes: + +Method Signature Changes +======================== + +.. _v440-parameter-type-changes: + +Parameter Type Changes +---------------------- + +- **Services:** + - The first parameter of ``Services::security()`` has been changed from + ``Config\App`` to ``Config\Security``. + - The first parameter of ``Services::session()`` has been changed from + ``Config\App`` to ``Config\Session``. +- **Session:** + - The second parameter of ``Session::__construct()`` has been changed from + ``Config\App`` to ``Config\Session``. + - The first parameter of ``__construct()`` in the database's ``BaseHandler``, + ``DatabaseHandler``, ``FileHandler``, ``MemcachedHandler``, and ``RedisHandler`` + has been changed from ``Config\App`` to ``Config\Session``. +- **Security:** The first parameter of ``Security::__construct()`` has been + changed from ``Config\App`` to ``Config\Security``. +- **Validation:** The method signature of ``Validation::check()`` has been changed. + The ``string`` typehint on the ``$rule`` parameter was removed. +- **CodeIgniter:** The method signature of ``CodeIgniter::setRequest()`` has been + changed. The ``Request`` typehint on the ``$request`` parameter was removed. +- **FeatureTestCase:** + - The method signature of ``FeatureTestCase::populateGlobals()`` has been + changed. The ``Request`` typehint on the ``$request`` parameter was removed. + - The method signature of ``FeatureTestCase::setRequestBody()`` has been + changed. The ``Request`` typehint on the ``$request`` parameter and the + return type ``Request`` were removed. + +Added Parameters +---------------- + +- **Routing:** The third parameter ``Routing $routing`` has been added to + ``RouteCollection::__construct()``. + +Removed Parameters +------------------ + +- **Services:** The second parameter ``$request`` and the third parameter + ``$response`` in ``Services::exceptions()`` have been removed. +- **Error Handling:** The second parameter ``$request`` and the third parameter + ``$response`` in ``CodeIgniter\Debug\Exceptions::__construct()`` have been removed. + +Return Type Changes +------------------- + +- **Autoloader:** The return signatures of the `loadClass` and `loadClassmap` methods are made `void` + to be compatible as callbacks in `spl_autoload_register` and `spl_autoload_unregister` functions. + +Enhancements +************ + +Commands +======== + +- **spark routes:** + - Now you can specify the host in the request URL. + See :ref:`routing-spark-routes-specify-host`. + - It shows view files of :ref:`view-routes` in *Handler* like the following: + + +---------+-------------+------+------------------------------+----------------+---------------+ + | Method | Route | Name | Handler | Before Filters | After Filters | + +---------+-------------+------+------------------------------+----------------+---------------+ + | GET | about | » | (View) pages/about | | toolbar | + +---------+-------------+------+------------------------------+----------------+---------------+ + + +Testing +======= + +- The Debug Toolbar now has a new "Hot Reload" feature that can be used to automatically reload the page when a file is changed. See :ref:`debug-toolbar-hot-reload`. + +Database +======== + +Query Builder +------------- + +Forge +----- + +Others +------ + +- **MySQLi:** Added the ``numberNative`` attribute to the Database Config to keep the variable type obtained after SQL Query consistent with the type set in the database. + See :ref:`Database Configuration `. +- **SQLSRV:** Field Metadata now includes ``nullable``. See :ref:`db-metadata-getfielddata`. + +Model +===== + +- Added special getter/setter to Entity to avoid method name conflicts. + See :ref:`entities-special-getter-setter`. + +Libraries +========= + +- **Validation:** Added ``Validation::getValidated()`` method that gets + the actual validated data. See :ref:`validation-getting-validated-data` for details. +- **Images:** The option ``$quality`` can now be used to compress WebP images. +- **Uploaded Files:** Added ``UploadedFiles::getClientPath()`` method that returns + the value of the `full_path` index of the file if it was uploaded via directory upload. +- **CURLRequest:** Added a request option ``proxy``. See + :ref:`CURLRequest Class `. +- **URI:** A new ``SiteURI`` class that extends ``URI`` and represents the site + URI has been added. + +Helpers and Functions +===================== + +- **Array:** Added :php:func:`array_group_by()` helper function to group data + values together. Supports dot-notation syntax. +- **Common:** :php:func:`force_https()` no longer terminates the application, but throws a ``RedirectException``. + +Others +====== + +- **DownloadResponse:** Added ``DownloadResponse::inline()`` method that sets + the ``Content-Disposition: inline`` header to display the file in the browser. + See :ref:`open-file-in-browser` for details. +- **View:** Added optional 2nd parameter ``$saveData`` on ``renderSection()`` to prevent from auto cleans the data after displaying. See :ref:`View Layouts ` for details. +- **Auto Routing (Improved)**: + - Now you can route to Modules. See :ref:`auto-routing-improved-module-routing` + for details. + - If a controller is found that corresponds to a URI segment and that controller + does not have a method defined for the URI segment, the default method will + now be executed. This addition allows for more flexible handling of URIs in + auto routing. See :ref:`controller-default-method-fallback` for details. +- **Filters:** Now you can use Filter Arguments with :ref:`$filters property `. +- **Request:** Added ``IncomingRequest::setValidLocales()`` method to set valid locales. +- **Table:** Added ``Table::setSyncRowsWithHeading()`` method to synchronize row columns with headings. See :ref:`table-sync-rows-with-headings` for details. +- **Error Handling:** Now you can use :ref:`custom-exception-handlers`. +- **RedirectException:** + - It can also take an object that implements ``ResponseInterface`` as its first argument. + - It implements ``ResponsableInterface``. +- **DebugBar:** Now :ref:`view-routes` are displayed in *DEFINED ROUTES* on the *Routes* tab. +- **Factories:** + - You can now define the classname that will actually be loaded. + See :ref:`factories-defining-classname-to-be-loaded`. + - Config Caching implemented. See :ref:`factories-config-caching` for details. + +Message Changes +*************** + +- Improved ``HTTP.invalidHTTPProtocol`` error message. + +Changes +******* + +- **Images:** The default quality for WebP in ``GDHandler`` has been changed from 80 to 90. +- **Config:** + - The deprecated Cookie items in **app/Config/App.php** has been removed. + - The deprecated Session items in **app/Config/App.php** has been removed. + - The deprecated CSRF items in **app/Config/App.php** has been removed. + - Routing settings have been moved to **app/Config/Routing.php** config file. + See :ref:`Upgrading Guide `. +- **DownloadResponse:** When generating response headers, does not replace the ``Content-Disposition`` header if it was previously specified. +- **Autoloader:** + - Before v4.4.0, CodeIgniter autoloader did not allow special + characters that are illegal in filenames on certain operating systems. + The symbols that can be used are ``/``, ``_``, ``.``, ``:``, ``\`` and space. + So if you installed CodeIgniter under the folder that contains the special + characters like ``(``, ``)``, etc., CodeIgniter didn't work. Since v4.4.0, + this restriction has been removed. + - The methods ``Autoloader::loadClass()`` and ``Autoloader::loadClassmap()`` are now both + marked ``@internal``. +- **RouteCollection:** The array structure of the protected property ``$routes`` + has been modified for performance. +- **HSTS:** Now :php:func:`force_https()` or + ``Config\App::$forceGlobalSecureRequests = true`` sets the HTTP status code 307, + which allows the HTTP request method to be preserved after the redirect. + In previous versions, it was 302. + +Deprecations +************ + +- **Entity:** ``Entity::setAttributes()`` is deprecated. Use ``Entity::injectRawData()`` instead. +- **Error Handling:** Many methods and properties in ``CodeIgniter\Debug\Exceptions`` + are deprecated. Because these methods have been moved to ``BaseExceptionHandler`` or + ``ExceptionHandler``. +- **Autoloader:** ``Autoloader::sanitizeFilename()`` is deprecated. +- **CodeIgniter:** + - ``CodeIgniter::$returnResponse`` property is deprecated. No longer used. + - ``CodeIgniter::$cacheTTL`` property is deprecated. No longer used. Use ``ResponseCache`` instead. + - ``CodeIgniter::cache()`` method is deprecated. No longer used. Use ``ResponseCache`` instead. + - ``CodeIgniter::cachePage()`` method is deprecated. No longer used. Use ``ResponseCache`` instead. + - ``CodeIgniter::generateCacheName()`` method is deprecated. No longer used. Use ``ResponseCache`` instead. + - ``CodeIgniter::callExit()`` method is deprecated. No longer used. +- **RedirectException:** ``\CodeIgniter\Router\Exceptions\RedirectException`` is deprecated. Use ``\CodeIgniter\HTTP\Exceptions\RedirectException`` instead. +- **Session:** The property ``$sessionDriverName``, ``$sessionCookieName``, + ``$sessionExpiration``, ``$sessionSavePath``, ``$sessionMatchIP``, + ``$sessionTimeToUpdate``, and ``$sessionRegenerateDestroy`` in ``Session`` are + deprecated, and no longer used. Use ``$config`` instead. +- **Security:** The property ``$csrfProtection``, ``$tokenRandomize``, + ``$tokenName``, ``$headerName``, ``$expires``, ``$regenerate``, and + ``$redirect`` in ``Security`` are deprecated, and no longer used. Use + ``$config`` instead. +- **URI:** + - ``URI::$uriString`` is deprecated. + - ``URI::$baseURL`` is deprecated. Use ``SiteURI`` instead. + - ``URI::setSilent()`` is deprecated. + - ``URI::setScheme()`` is deprecated. Use ``withScheme()`` instead. + - ``URI::setURI()`` is deprecated. +- **IncomingRequest:** + - ``IncomingRequest::detectURI()`` is deprecated and no longer used. + - ``IncomingRequest::detectPath()`` is deprecated, and no longer used. It + moved to ``SiteURIFactory``. + - ``IncomingRequest::parseRequestURI()`` is deprecated, and no longer used. It + moved to ``SiteURIFactory``. + - ``IncomingRequest::parseQueryString()`` is deprecated, and no longer used. It + moved to ``SiteURIFactory``. + - ``IncomingRequest::setPath()`` is deprecated. + +Bugs Fixed +********** + +- **Auto Routing (Improved)**: In previous versions, when ``$translateURIDashes`` + is true, two URIs correspond to a single controller method, one URI for dashes + (e.g., **foo-bar**) and one URI for underscores (e.g., **foo_bar**). This bug + has been fixed. Now the URI for underscores (**foo_bar**) is not accessible. +- **Output Buffering:** Bug fix with output buffering. +- **ControllerTestTrait:** ``ControllerTestTrait::withUri()`` creates a new Request + instance with the URI. Because the Request instance should have the URI instance. + Also if the hostname in the URI string is invalid with ``Config\App``, the valid + hostname will be set. + +See the repo's +`CHANGELOG.md `_ +for a complete list of bugs fixed. diff --git a/user_guide_src/source/concepts/factories.rst b/user_guide_src/source/concepts/factories.rst index b3ec0a064176..f4872327f082 100644 --- a/user_guide_src/source/concepts/factories.rst +++ b/user_guide_src/source/concepts/factories.rst @@ -54,9 +54,18 @@ by using the magic static method of the Factories class, ``Factories::models()`` The static method name is called *component*. -By default, Factories first searches in the ``App`` namespace for the path corresponding to the magic static method name. +.. _factories-passing-classname-without-namespace: + +Passing Classname without Namespace +----------------------------------- + +If you pass a classname without a namespace, Factories first searches in the +``App`` namespace for the path corresponding to the magic static method name. ``Factories::models()`` searches the **app/Models** directory. +Passing Short Classname +^^^^^^^^^^^^^^^^^^^^^^^ + In the following code, if you have ``App\Models\UserModel``, the instance will be returned: .. literalinclude:: factories/001.php @@ -68,31 +77,35 @@ you get back the instance as before: .. literalinclude:: factories/003.php -preferApp option ----------------- +Passing Short Classname with Sub-directories +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -You could also request a specific class: +If you want to load a class in sub directories, you use the ``/`` as a separator. +The following code loads **app/Libraries/Sub/SubLib.php** if it exists: -.. literalinclude:: factories/002.php +.. literalinclude:: factories/013.php :lines: 2- -If you have only ``Blog\Models\UserModel``, the instance will be returned. -But if you have both ``App\Models\UserModel`` and ``Blog\Models\UserModel``, -the instance of ``App\Models\UserModel`` will be returned. +Passing Full Qualified Classname +-------------------------------- -If you want to get ``Blog\Models\UserModel``, you need to disable the option ``preferApp``: +You could also request a full qualified classname: -.. literalinclude:: factories/010.php +.. literalinclude:: factories/002.php :lines: 2- -Loading a Class in Sub-directories -================================== +It returns the instance of ``Blog\Models\UserModel`` if it exists. -If you want to load a class in sub directories, you use the ``/`` as a separator. -The following code loads **app/Libraries/Sub/SubLib.php**: +.. note:: Prior to v4.4.0, when you requested a full qualified classname, + if you had only ``Blog\Models\UserModel``, the instance would be returned. + But if you had both ``App\Models\UserModel`` and ``Blog\Models\UserModel``, + the instance of ``App\Models\UserModel`` would be returned. -.. literalinclude:: factories/013.php - :lines: 2- + If you wanted to get ``Blog\Models\UserModel``, you needed to disable the + option ``preferApp``: + + .. literalinclude:: factories/010.php + :lines: 2- Convenience Functions ********************* @@ -115,6 +128,29 @@ The second function, :php:func:`model()` returns a new instance of a Model class .. literalinclude:: factories/009.php +.. _factories-defining-classname-to-be-loaded: + +Defining Classname to be Loaded +******************************* + +.. versionadded:: 4.4.0 + +You could define a classname to be loaded before loading the class with +the ``Factories::define()`` method: + +.. literalinclude:: factories/014.php + :lines: 2- + +The first parameter is a component. The second parameter is a class alias +(the first parameter to Factories magic static method), and the third parameter +is the true full qualified classname to be loaded. + +After that, if you load ``Myth\Auth\Models\UserModel`` with Factories, the +``App\Models\UserModel`` instance will be returned: + +.. literalinclude:: factories/015.php + :lines: 2- + Factory Parameters ****************** @@ -154,6 +190,9 @@ preferApp boolean Whether a class with the same basename in the App name overrides other explicit class requests. ========== ============== ============================================================ =================================================== +.. note:: Since v4.4.0, ``preferApp`` works only when you request + :ref:`a classname without a namespace `. + Factories Behavior ****************** @@ -223,3 +262,81 @@ that single call will return a new or shared instance: .. literalinclude:: factories/007.php :lines: 2- + +.. _factories-config-caching: + +Config Caching +************** + +.. versionadded:: 4.4.0 + +To improve performance, Config Caching has been implemented. + +Prerequisite +============ + +.. important:: Using this feature when the prerequisites are not met will prevent + CodeIgniter from operating properly. Do not use this feature in such cases. + +- To use this feature, the properties of all Config objects instantiated in + Factories must not be modified after instantiation. Put another way, the Config + classes must be an immutable or readonly classes. +- By default, every Config class that is cached must implement ``__set_state()`` + method. + +How It Works +============ + +- Save the all Config instances in Factories into a cache file before shutdown, + if the state of the Config instances in Factories changes. +- Restore cached Config instances before CodeIgniter initialization if a cache + is available. + +Simply put, all Config instances held by Factories are cached immediately prior +to shutdown, and the cached instances are used permanently. + +How to Update Config Values +=========================== + +Once stored, the cached versions never expire. Changing a existing Config file +(or changing Environment Variables for it) will not update the cache nor the Config +values. + +So if you want to update Config values, update Config files or Environment Variables +for them, and you must manually delete the cache file. + +You can use the ``spark cache:clear`` command: + +.. code-block:: console + + php spark cache:clear + +Or simply delete the **writable/cache/FactoriesCache_config** file. + +How to Enable Config Caching +============================ + +Uncomment the following code in **public/index.php**:: + + --- a/public/index.php + +++ b/public/index.php + @@ -49,8 +49,8 @@ if (! defined('ENVIRONMENT')) { + } + + // Load Config Cache + -// $factoriesCache = new \CodeIgniter\Cache\FactoriesCache(); + -// $factoriesCache->load('config'); + +$factoriesCache = new \CodeIgniter\Cache\FactoriesCache(); + +$factoriesCache->load('config'); + // ^^^ Uncomment these lines if you want to use Config Caching. + + /* + @@ -79,7 +79,7 @@ $app->setContext($context); + $app->run(); + + // Save Config Cache + -// $factoriesCache->save('config'); + +$factoriesCache->save('config'); + // ^^^ Uncomment this line if you want to use Config Caching. + + // Exits the application, setting the exit code for CLI-based applications diff --git a/user_guide_src/source/concepts/factories/014.php b/user_guide_src/source/concepts/factories/014.php new file mode 100644 index 000000000000..992b0f7b2236 --- /dev/null +++ b/user_guide_src/source/concepts/factories/014.php @@ -0,0 +1,3 @@ +` queries. This permits multiple CodeIgniter - installations to share one database. -**pConnect** true/false (boolean) - Whether to use a persistent connection. -**DBDebug** true/false (boolean) - Whether to throw exceptions or not when database errors occur. -**charset** The character set used in communicating with the database. -**DBCollat** The character collation used in communicating with the database (``MySQLi`` only). -**swapPre** A default table prefix that should be swapped with ``DBPrefix``. This is useful for distributed - applications where you might run manually written queries, and need the prefix to still be - customizable by the end user. -**schema** The database schema, default value varies by driver. (Used by ``Postgre`` and ``SQLSRV``.) -**encrypt** Whether or not to use an encrypted connection. - ``SQLSRV`` driver accepts true/false - ``MySQLi`` driver accepts an array with the following options: - * ``ssl_key`` - Path to the private key file - * ``ssl_cert`` - Path to the public key certificate file - * ``ssl_ca`` - Path to the certificate authority file - * ``ssl_capath`` - Path to a directory containing trusted CA certificates in PEM format - * ``ssl_cipher`` - List of *allowed* ciphers to be used for the encryption, separated by colons (``:``) - * ``ssl_verify`` - true/false; Whether to verify the server certificate or not (``MySQLi`` only) -**compress** Whether or not to use client compression (``MySQLi`` only). -**strictOn** true/false (boolean) - Whether to force "Strict Mode" connections, good for ensuring strict SQL - while developing an application (``MySQLi`` only). -**port** The database port number - Empty string ``''`` for default port (or dynamic port with ``SQLSRV``). -**foreignKeys** true/false (boolean) - Whether or not to enable Foreign Key constraint (``SQLite3`` only). - - .. important:: SQLite3 Foreign Key constraint is disabled by default. - See `SQLite documentation `_. - To enforce Foreign Key constraint, set this config item to true. -**busyTimeout** milliseconds (int) - Sleeps for a specified amount of time when a table is locked (``SQLite3`` only). -=============== =========================================================================================================== +================ =========================================================================================================== + Name Config Description +================ =========================================================================================================== +**DSN** The DSN connect string (an all-in-one configuration sequence). +**hostname** The hostname of your database server. Often this is 'localhost'. +**username** The username used to connect to the database. (``SQLite3`` does not use this.) +**password** The password used to connect to the database. (``SQLite3`` does not use this.) +**database** The name of the database you want to connect to. + + .. note:: CodeIgniter doesn't support dots (``.``) in the database, table, and column names. +**DBDriver** The database driver name. The case must match the driver name. + You can set a fully qualified classname to use your custom driver. + Supported drivers: ``MySQLi``, ``Postgre``, ``SQLite3``, ``SQLSRV``, and ``OCI8``. +**DBPrefix** An optional table prefix which will added to the table name when running + :doc:`Query Builder ` queries. This permits multiple CodeIgniter + installations to share one database. +**pConnect** true/false (boolean) - Whether to use a persistent connection. +**DBDebug** true/false (boolean) - Whether to throw exceptions or not when database errors occur. +**charset** The character set used in communicating with the database. +**DBCollat** The character collation used in communicating with the database (``MySQLi`` only). +**swapPre** A default table prefix that should be swapped with ``DBPrefix``. This is useful for distributed + applications where you might run manually written queries, and need the prefix to still be + customizable by the end user. +**schema** The database schema, default value varies by driver. (Used by ``Postgre`` and ``SQLSRV``.) +**encrypt** Whether or not to use an encrypted connection. + ``SQLSRV`` driver accepts true/false + ``MySQLi`` driver accepts an array with the following options: + * ``ssl_key`` - Path to the private key file + * ``ssl_cert`` - Path to the public key certificate file + * ``ssl_ca`` - Path to the certificate authority file + * ``ssl_capath`` - Path to a directory containing trusted CA certificates in PEM format + * ``ssl_cipher`` - List of *allowed* ciphers to be used for the encryption, separated by colons (``:``) + * ``ssl_verify`` - true/false; Whether to verify the server certificate or not (``MySQLi`` only) +**compress** Whether or not to use client compression (``MySQLi`` only). +**strictOn** true/false (boolean) - Whether to force "Strict Mode" connections, good for ensuring strict SQL + while developing an application (``MySQLi`` only). +**port** The database port number - Empty string ``''`` for default port (or dynamic port with ``SQLSRV``). +**foreignKeys** true/false (boolean) - Whether or not to enable Foreign Key constraint (``SQLite3`` only). + + .. important:: SQLite3 Foreign Key constraint is disabled by default. + See `SQLite documentation `_. + To enforce Foreign Key constraint, set this config item to true. +**busyTimeout** milliseconds (int) - Sleeps for a specified amount of time when a table is locked (``SQLite3`` only). +**numberNative** true/false (boolean) - Whether or not to enable MYSQLI_OPT_INT_AND_FLOAT_NATIVE (``MySQLi`` only). +================ =========================================================================================================== .. note:: Depending on what database driver you are using (``MySQLi``, ``Postgre``, etc.) not all values will be needed. For example, when using ``SQLite3`` you diff --git a/user_guide_src/source/database/metadata.rst b/user_guide_src/source/database/metadata.rst index 101d0a605683..607a1025be8b 100644 --- a/user_guide_src/source/database/metadata.rst +++ b/user_guide_src/source/database/metadata.rst @@ -78,6 +78,8 @@ performing an action. Returns a boolean true/false. Usage example: Retrieve Field Metadata ======================= +.. _db-metadata-getfielddata: + $db->getFieldData() ------------------- @@ -104,9 +106,11 @@ database: - type - the type of the column - max_length - maximum length of the column - primary_key - integer ``1`` if the column is a primary key (all integer ``1``, even if there are multiple primary keys), otherwise integer ``0`` (This field is currently only available for MySQL and SQLite3) -- nullable - boolean ``true`` if the column is nullable, otherwise boolean ``false`` (This field is currently not available in SQL Server) +- nullable - boolean ``true`` if the column is nullable, otherwise boolean ``false`` - default - the default value +.. note:: Since v4.4.0, SQLSRV supported ``nullable``. + List the Indexes in a Table =========================== diff --git a/user_guide_src/source/general/configuration.rst b/user_guide_src/source/general/configuration.rst index 2e666c8245c8..e51ab1e8fa40 100644 --- a/user_guide_src/source/general/configuration.rst +++ b/user_guide_src/source/general/configuration.rst @@ -47,9 +47,10 @@ All of the configuration files that ship with CodeIgniter are namespaced with ``Config``. Using this namespace in your application will provide the best performance since it knows exactly where to find the files. -.. note:: ``config()`` finds the file in **app/Config/** when there is a class with the same shortname, +.. note:: Prior to v4.4.0, ``config()`` finds the file in **app/Config/** when there + is a class with the same shortname, even if you specify a fully qualified class name like ``config(\Acme\Blog\Config\Blog::class)``. - This is because ``config()`` is a wrapper for the ``Factories`` class which uses ``preferApp`` by default. See :ref:`factories-loading-class` for more information. + This behavior has been fixed in v4.4.0, and returns the specified instance. Getting a Config Property ========================= diff --git a/user_guide_src/source/general/errors.rst b/user_guide_src/source/general/errors.rst index 73f737671243..943793340466 100644 --- a/user_guide_src/source/general/errors.rst +++ b/user_guide_src/source/general/errors.rst @@ -113,6 +113,10 @@ This provides an exit code of 8. RedirectException ----------------- +.. note:: Since v4.4.0, the namespace of ``RedirectException`` has been changed. + Previously it was ``CodeIgniter\Router\Exceptions\RedirectException``. The + previous class is deprecated. + This exception is a special case allowing for overriding of all other response routing and forcing a redirect to a specific URI: @@ -123,6 +127,11 @@ redirect code to use instead of the default (``302``, "temporary redirect"): .. literalinclude:: errors/011.php +Also, since v4.4.0 an object of a class that implements ResponseInterface can be used as the first argument. +This solution is suitable for cases where you need to add additional headers or cookies in the response. + +.. literalinclude:: errors/018.php + .. _error-specify-http-status-code: Specify HTTP Status Code in Your Exception @@ -177,3 +186,40 @@ This feature also works with user deprecations: For testing your application you may want to always throw on deprecations. You may configure this by setting the environment variable ``CODEIGNITER_SCREAM_DEPRECATIONS`` to a truthy value. + +.. _custom-exception-handlers: + +Custom Exception Handlers +========================= + +.. versionadded:: 4.4.0 + +If you need more control over how exceptions are displayed you can now define your own handlers and +specify when they apply. + +Defining the New Handler +------------------------ + +The first step is to create a new class which implements ``CodeIgniter\Debug\ExceptionHandlerInterface``. +You can also extend ``CodeIgniter\Debug\BaseExceptionHandler``. +This class includes a number of utility methods that are used by the default exception handler. +The new handler must implement a single method: ``handle()``: + +.. literalinclude:: errors/015.php + +This example defines the minimum amount of code typically needed - display a view and exit with the proper +exit code. However, the ``BaseExceptionHandler`` provides a number of other helper functions and objects. + +Configuring the New Handler +--------------------------- + +Telling CodeIgniter to use your new exception handler class is done in the **app/Config/Exceptions.php** +configuration file's ``handler()`` method: + +.. literalinclude:: errors/016.php + +You can use any logic your application needs to determine whether it should handle the exception, but the +two most common are checking on the HTTP status code or the type of exception. If your class should handle +it then return a new instance of that class: + +.. literalinclude:: errors/017.php diff --git a/user_guide_src/source/general/errors/010.php b/user_guide_src/source/general/errors/010.php index 47ecb3c7853f..dbe3736a49e9 100644 --- a/user_guide_src/source/general/errors/010.php +++ b/user_guide_src/source/general/errors/010.php @@ -1,3 +1,3 @@ render($exception, $statusCode, $this->viewPath . "error_{$statusCode}.php"); + + exit($exitCode); + } +} diff --git a/user_guide_src/source/general/errors/016.php b/user_guide_src/source/general/errors/016.php new file mode 100644 index 000000000000..0fb61fa16cac --- /dev/null +++ b/user_guide_src/source/general/errors/016.php @@ -0,0 +1,18 @@ +redirect('https://example.com/path') + ->setHeader('Some', 'header') + ->setCookie('and', 'cookie'); + +throw new \CodeIgniter\HTTP\Exceptions\RedirectException($response); diff --git a/user_guide_src/source/general/modules.rst b/user_guide_src/source/general/modules.rst index 311a6ba2b421..b15e3c47bdf6 100644 --- a/user_guide_src/source/general/modules.rst +++ b/user_guide_src/source/general/modules.rst @@ -193,14 +193,15 @@ with the ``new`` command: .. literalinclude:: modules/008.php -Config files are automatically discovered whenever using the :php:func:`config()` function that is always available. +Config files are automatically discovered whenever using the :php:func:`config()` function that is always available, and you pass a short classname to it. .. note:: We don't recommend you use the same short classname in modules. - Modules that need to override or add to known configurations in **app/Config/** should use :ref:`registrars`. + Modules that need to override or add to known configurations in **app/Config/** should use :ref:`Implicit Registrars `. -.. note:: ``config()`` finds the file in **app/Config/** when there is a class with the same shortname, +.. note:: Prior to v4.4.0, ``config()`` finds the file in **app/Config/** when there + is a class with the same shortname, even if you specify a fully qualified class name like ``config(\Acme\Blog\Config\Blog::class)``. - This is because ``config()`` is a wrapper for the ``Factories`` class which uses ``preferApp`` by default. See :ref:`factories-loading-class` for more information. + This behavior has been fixed in v4.4.0, and returns the specified instance. Migrations ========== diff --git a/user_guide_src/source/helpers/array_helper.rst b/user_guide_src/source/helpers/array_helper.rst index cf9517925234..730dae957222 100644 --- a/user_guide_src/source/helpers/array_helper.rst +++ b/user_guide_src/source/helpers/array_helper.rst @@ -111,3 +111,27 @@ The following functions are available: will be supplying an initial ``$id``, it will be prepended to all keys. .. literalinclude:: array_helper/011.php + +.. php:function:: array_group_by(array $array, array $indexes[, bool $includeEmpty = false]): array + + :param array $array: Data rows (most likely from query results) + :param array $indexes: Indexes to group values. Follows dot syntax + :param bool $includeEmpty: If true, ``null`` and ``''`` values are not filtered out + :rtype: array + :returns: An array grouped by indexes values + + This function allows you to group data rows together by index values. + The depth of returned array equals the number of indexes passed as parameter. + + The example shows some data (i.e. loaded from an API) with nested arrays. + + .. literalinclude:: array_helper/012.php + + We want to group them first by "gender", then by "hr.department" (max depth = 2). + First the result when excluding empty values: + + .. literalinclude:: array_helper/013.php + + And here the same code, but this time we want to include empty values: + + .. literalinclude:: array_helper/014.php diff --git a/user_guide_src/source/helpers/array_helper/012.php b/user_guide_src/source/helpers/array_helper/012.php new file mode 100644 index 000000000000..8aeb6d705c7d --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/012.php @@ -0,0 +1,94 @@ + 1, + 'first_name' => 'Urbano', + 'gender' => null, + 'hr' => [ + 'country' => 'Canada', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 2, + 'first_name' => 'Case', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Marketing', + ], + ], + [ + 'id' => 3, + 'first_name' => 'Emera', + 'gender' => 'Female', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 4, + 'first_name' => 'Richy', + 'gender' => null, + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + [ + 'id' => 5, + 'first_name' => 'Mandy', + 'gender' => null, + 'hr' => [ + 'country' => 'France', + 'department' => 'Sales', + ], + ], + [ + 'id' => 6, + 'first_name' => 'Risa', + 'gender' => 'Female', + 'hr' => [ + 'country' => null, + 'department' => 'Engineering', + ], + ], + [ + 'id' => 7, + 'first_name' => 'Alfred', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 8, + 'first_name' => 'Tabby', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Marketing', + ], + ], + [ + 'id' => 9, + 'first_name' => 'Ario', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + [ + 'id' => 10, + 'first_name' => 'Somerset', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'Germany', + 'department' => 'Marketing', + ], + ], +]; diff --git a/user_guide_src/source/helpers/array_helper/013.php b/user_guide_src/source/helpers/array_helper/013.php new file mode 100644 index 000000000000..5b7e722fce75 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/013.php @@ -0,0 +1,81 @@ + [ + 'Marketing' => [ + [ + 'id' => 2, + 'first_name' => 'Case', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Marketing', + ], + ], + [ + 'id' => 8, + 'first_name' => 'Tabby', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Marketing', + ], + ], + [ + 'id' => 10, + 'first_name' => 'Somerset', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'Germany', + 'department' => 'Marketing', + ], + ], + ], + 'Engineering' => [ + [ + 'id' => 7, + 'first_name' => 'Alfred', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + ], + 'Sales' => [ + [ + 'id' => 9, + 'first_name' => 'Ario', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + ], + ], + 'Female' => [ + 'Engineering' => [ + [ + 'id' => 3, + 'first_name' => 'Emera', + 'gender' => 'Female', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 6, + 'first_name' => 'Risa', + 'gender' => 'Female', + 'hr' => [ + 'country' => null, + 'department' => 'Engineering', + ], + ], + ], + ], +]; diff --git a/user_guide_src/source/helpers/array_helper/014.php b/user_guide_src/source/helpers/array_helper/014.php new file mode 100644 index 000000000000..99089d236598 --- /dev/null +++ b/user_guide_src/source/helpers/array_helper/014.php @@ -0,0 +1,114 @@ + [ + 'Engineering' => [ + [ + 'id' => 1, + 'first_name' => 'Urbano', + 'gender' => null, + 'hr' => [ + 'country' => 'Canada', + 'department' => 'Engineering', + ], + ], + ], + 'Sales' => [ + [ + 'id' => 4, + 'first_name' => 'Richy', + 'gender' => null, + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + [ + 'id' => 5, + 'first_name' => 'Mandy', + 'gender' => null, + 'hr' => [ + 'country' => 'France', + 'department' => 'Sales', + ], + ], + ], + ], + 'Male' => [ + 'Marketing' => [ + [ + 'id' => 2, + 'first_name' => 'Case', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Marketing', + ], + ], + [ + 'id' => 8, + 'first_name' => 'Tabby', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Marketing', + ], + ], + [ + 'id' => 10, + 'first_name' => 'Somerset', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'Germany', + 'department' => 'Marketing', + ], + ], + ], + 'Engineering' => [ + [ + 'id' => 7, + 'first_name' => 'Alfred', + 'gender' => 'Male', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + ], + 'Sales' => [ + [ + 'id' => 9, + 'first_name' => 'Ario', + 'gender' => 'Male', + 'hr' => [ + 'country' => null, + 'department' => 'Sales', + ], + ], + ], + ], + 'Female' => [ + 'Engineering' => [ + [ + 'id' => 3, + 'first_name' => 'Emera', + 'gender' => 'Female', + 'hr' => [ + 'country' => 'France', + 'department' => 'Engineering', + ], + ], + [ + 'id' => 6, + 'first_name' => 'Risa', + 'gender' => 'Female', + 'hr' => [ + 'country' => null, + 'department' => 'Engineering', + ], + ], + ], + ], +]; diff --git a/user_guide_src/source/images/tutorial9.png b/user_guide_src/source/images/tutorial9.png deleted file mode 100644 index 39986162c65b..000000000000 Binary files a/user_guide_src/source/images/tutorial9.png and /dev/null differ diff --git a/user_guide_src/source/incoming/controllers.rst b/user_guide_src/source/incoming/controllers.rst index 6612669b56d1..8bc9e90d581f 100644 --- a/user_guide_src/source/incoming/controllers.rst +++ b/user_guide_src/source/incoming/controllers.rst @@ -98,19 +98,24 @@ and in the optional second parameter, an array of custom error messages to displ if the items are not valid. Internally, this uses the controller's ``$this->request`` instance to get the data to be validated. -.. warning:: - The ``validate()`` method uses :ref:`Validation::withRequest() ` method. - It validates data from :ref:`$request->getJSON() ` - or :ref:`$request->getRawInput() ` - or :ref:`$request->getVar() `. - Which data is used depends on the request. Remember that an attacker is free to send any request to - the server. - The :doc:`Validation Library docs ` have details on rule and message array formats, as well as available rules: .. literalinclude:: controllers/004.php +.. warning:: When you use the ``validate()`` method, you should use the + :ref:`getValidated() ` method to get the + validated data. Because the ``validate()`` method uses the + :ref:`Validation::withRequest() ` method internally, + and it validates data from + :ref:`$request->getJSON() ` + or :ref:`$request->getRawInput() ` + or :ref:`$request->getVar() `, and an attacker + could change what data is validated. + +.. note:: The :ref:`$this->validator->getValidated() ` + method can be used since v4.4.0. + If you find it simpler to keep the rules in the configuration file, you can replace the ``$rules`` array with the name of the group as defined in **app/Config/Validation.php**: @@ -274,10 +279,6 @@ Your method will be passed URI segments 3 and 4 (``'sandals'`` and ``'123'``): .. literalinclude:: controllers/022.php -.. important:: If there are more parameters in the URI than the method parameters, - Auto Routing (Improved) does not execute the method, and it results in 404 - Not Found. - Default Controller ================== @@ -312,6 +313,57 @@ see the "Hello World" message. For more information, please refer to the :ref:`routes-configuration-options` section of the :ref:`URI Routing ` documentation. +.. _controller-default-method-fallback: + +Default Method Fallback +======================= + +.. versionadded:: 4.4.0 + +If the controller method corresponding to the URI segment of the method name +does not exist, and if the default method is defined, the remaining URI segments +are passed to the default method for execution. + +.. literalinclude:: controllers/024.php + +Load the following URL:: + + example.com/index.php/product/15/edit + +The method will be passed URI segments 2 and 3 (``'15'`` and ``'edit'``): + +.. important:: If there are more parameters in the URI than the method parameters, + Auto Routing (Improved) does not execute the method, and it results in 404 + Not Found. + +Fallback to Default Controller +------------------------------ + +If the controller corresponding to the URI segment of the controller name +does not exist, and if the default controller (``Home`` by default) exists in +the directory, the remaining URI segments are passed to the default controller's +default method. + +For example, when you have the following default controller ``Home`` in the +**app/Controllers/News** directory: + +.. literalinclude:: controllers/025.php + +Load the following URL:: + + example.com/index.php/news/101 + +The ``News\Home`` controller and the default ``getIndex()`` method will be found. +So the default method will be passed URI segments 2 (``'101'``): + +.. note:: If there is ``App\Controllers\News`` controller, it takes precedence. + The URI segments are searched sequentially and the first controller found + is used. + +.. note:: If there are more parameters in the URI than the method parameters, + Auto Routing (Improved) does not execute the method, and it results in 404 + Not Found. + Organizing Your Controllers into Sub-directories ================================================ diff --git a/user_guide_src/source/incoming/controllers/004.php b/user_guide_src/source/incoming/controllers/004.php index 9216bab50032..afdf0a145852 100644 --- a/user_guide_src/source/incoming/controllers/004.php +++ b/user_guide_src/source/incoming/controllers/004.php @@ -10,11 +10,17 @@ public function updateUser(int $userID) 'email' => "required|is_unique[users.email,id,{$userID}]", 'name' => 'required|alpha_numeric_spaces', ])) { + // The validation failed. return view('users/update', [ 'errors' => $this->validator->getErrors(), ]); } - // do something here if successful... + // The validation was successful. + + // Get the validated data. + $validData = $this->validator->getValidated(); + + // ... } } diff --git a/user_guide_src/source/incoming/controllers/005.php b/user_guide_src/source/incoming/controllers/005.php index 48e936b60595..34170437355d 100644 --- a/user_guide_src/source/incoming/controllers/005.php +++ b/user_guide_src/source/incoming/controllers/005.php @@ -7,11 +7,17 @@ class UserController extends BaseController public function updateUser(int $userID) { if (! $this->validate('userRules')) { + // The validation failed. return view('users/update', [ 'errors' => $this->validator->getErrors(), ]); } - // do something here if successful... + // The validation was successful. + + // Get the validated data. + $validData = $this->validator->getValidated(); + + // ... } } diff --git a/user_guide_src/source/incoming/controllers/024.php b/user_guide_src/source/incoming/controllers/024.php new file mode 100644 index 000000000000..c3ae1a7c6a43 --- /dev/null +++ b/user_guide_src/source/incoming/controllers/024.php @@ -0,0 +1,11 @@ +`, -you can choose the specific URIs in which the filters will be applied to. Incoming filters may +you can choose the specific URIs or routes in which the filters will be applied to. Before filters may modify the Request while after filters can act on and even modify the Response, allowing for a lot of flexibility -and power. Some common examples of tasks that might be performed with filters are: +and power. + +Some common examples of tasks that might be performed with filters are: * Performing CSRF protection on the incoming requests * Restricting areas of your site based upon their Role @@ -77,11 +79,13 @@ the final output, or even to filter the final output with a bad words filter. Configuring Filters ******************* -Once you've created your filters, you need to configure when they get run. This is done in **app/Config/Filters.php**. -This file contains four properties that allow you to configure exactly when the filters run. +Once you've created your filters, you need to configure when they get run. This is done in **app/Config/Filters.php** or **app/Config/Routes.php**. .. Note:: The safest way to apply filters is to :ref:`disable auto-routing `, and :ref:`set filters to routes `. +The **app/Config/Filters.php** file contains four properties that allow you to +configure exactly when the filters run. + .. Warning:: It is recommended that you should always add ``*`` at the end of a URI in the filter settings. Because a controller method might be accessible by different URLs than you think. For example, when :ref:`auto-routing-legacy` is enabled, if you have ``Blog::index``, @@ -157,14 +161,24 @@ a list of URI path (relative to BaseURL) patterns that filter should apply to: .. literalinclude:: filters/009.php +.. _filters-filters-filter-arguments: + Filter Arguments ================ -When configuring filters, additional arguments may be passed to a filter when setting up the route: +Filter Arguments +---------------- + +.. versionadded:: 4.4.0 + +When configuring ``$filters``, additional arguments may be passed to a filter: -.. literalinclude:: filters/010.php +.. literalinclude:: filters/012.php -In this example, the array ``['dual', 'noreturn']`` will be passed in ``$arguments`` to the filter's ``before()`` and ``after()`` implementation methods. +In this example, when the URI matches ``admin/*'``, the array ``['admin', 'superadmin']`` +will be passed in ``$arguments`` to the ``group`` filter's ``before()`` methods. +When the URI matches ``admin/users/*'``, the array ``['users.manage']`` +will be passed in ``$arguments`` to the ``permission`` filter's ``before()`` methods. ****************** Confirming Filters diff --git a/user_guide_src/source/incoming/filters/012.php b/user_guide_src/source/incoming/filters/012.php new file mode 100644 index 000000000000..799bd57fce7d --- /dev/null +++ b/user_guide_src/source/incoming/filters/012.php @@ -0,0 +1,17 @@ + ['before' => ['admin/*']], + 'permission:users.manage' => ['before' => ['admin/users/*']], + ]; + + // ... +} diff --git a/user_guide_src/source/incoming/incomingrequest.rst b/user_guide_src/source/incoming/incomingrequest.rst index ed3c40ec7f2b..5a58ea1c1608 100644 --- a/user_guide_src/source/incoming/incomingrequest.rst +++ b/user_guide_src/source/incoming/incomingrequest.rst @@ -228,11 +228,10 @@ The object gives you full abilities to grab any part of the request on it's own: .. literalinclude:: incomingrequest/021.php -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: +You can work with the current URI string (the path relative to your baseURL) using the ``getRoutePath()``. -.. literalinclude:: incomingrequest/022.php +.. note:: The ``getRoutePath()`` method can be used since v4.4.0. Prior to v4.4.0, + the ``getPath()`` method returned the path relative to your baseURL. Uploaded Files ************** @@ -476,15 +475,22 @@ The methods provided by the parent classes that are available are: :returns: The current URI path relative to baseURL :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. + This method returns the current URI path relative to baseURL. + + .. note:: Prior to v4.4.0, this was the safest method to determine the + "current URI", since ``IncomingRequest::$uri`` might not be aware of + the complete App configuration for base URLs. .. php:method:: setPath($path) + .. deprecated:: 4.4.0 + :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. + .. note:: Prior to v4.4.0, used mostly just for testing purposes, this + allowed you to set the relative path value for the current request + instead of relying on URI detection. This also updated the + underlying ``URI`` instance with the new path. + diff --git a/user_guide_src/source/incoming/incomingrequest/021.php b/user_guide_src/source/incoming/incomingrequest/021.php index 558bb6a98e6f..9a1b8601b07c 100644 --- a/user_guide_src/source/incoming/incomingrequest/021.php +++ b/user_guide_src/source/incoming/incomingrequest/021.php @@ -7,7 +7,8 @@ echo $uri->getUserInfo(); // snoopy:password echo $uri->getHost(); // example.com echo $uri->getPort(); // 88 -echo $uri->getPath(); // path/to/page +echo $uri->getPath(); // /path/to/page +echo $uri->getRoutePath(); // path/to/page echo $uri->getQuery(); // foo=bar&bar=baz print_r($uri->getSegments()); // Array ( [0] => path [1] => to [2] => page ) echo $uri->getSegment(1); // path diff --git a/user_guide_src/source/incoming/incomingrequest/022.php b/user_guide_src/source/incoming/incomingrequest/022.php deleted file mode 100644 index 723d81e3a3a3..000000000000 --- a/user_guide_src/source/incoming/incomingrequest/022.php +++ /dev/null @@ -1,15 +0,0 @@ -setPath('users/list'); - - $menu = new MyMenu(); - - $this->assertTrue('users/list', $menu->getActiveLink()); - } -} diff --git a/user_guide_src/source/incoming/routing.rst b/user_guide_src/source/incoming/routing.rst index 8b42914d4a8d..0fa702ff10d2 100644 --- a/user_guide_src/source/incoming/routing.rst +++ b/user_guide_src/source/incoming/routing.rst @@ -355,7 +355,7 @@ The value for the filter can be a string or an array of strings: * matching the aliases defined in **app/Config/Filters.php**. * filter classnames -See :doc:`Controller filters ` for more information on setting up filters. +See :doc:`Controller Filters ` for more information on setting up filters. .. Warning:: If you set filters to routes in **app/Config/Routes.php** (not in **app/Config/Filters.php**), it is recommended to disable Auto Routing (Legacy). @@ -395,6 +395,16 @@ You specify an array for the filter value: .. literalinclude:: routing/037.php +Filter Arguments +^^^^^^^^^^^^^^^^ + +Additional arguments may be passed to a filter: + +.. literalinclude:: routing/067.php + +In this example, the array ``['dual', 'noreturn']`` will be passed in ``$arguments`` +to the filter's ``before()`` and ``after()`` implementation methods. + .. _assigning-namespace: Assigning Namespace @@ -565,7 +575,11 @@ Routes Configuration Options **************************** The RoutesCollection class provides several options that affect all routes, and can be modified to meet your -application's needs. These options are available at the top of **app/Config/Routes.php**. +application's needs. These options are available in **app/Config/Routing.php**. + +.. note:: The config file **app/Config/Routing.php** has been added since v4.4.0. + In previous versions, the setter methods were used in **app/Config/Routes.php** + to change settings. .. _routing-default-namespace: @@ -589,11 +603,16 @@ Translate URI Dashes ==================== This option enables you to automatically replace dashes (``-``) with underscores in the controller and method -URI segments, thus saving you additional route entries if you need to do that. This is required because the -dash isn't a valid class or method name character and would cause a fatal error if you try to use it: +URI segments when used in Auto Routing, thus saving you additional route entries if you need to do that. This is required because the dash isn't a valid class or method name character and would cause a fatal error if you try to use it: .. literalinclude:: routing/049.php +.. note:: When using Auto Routing (Improved), prior to v4.4.0, if + ``$translateURIDashes`` is true, two URIs correspond to a single controller + method, one URI for dashes (e.g., **foo-bar**) and one URI for underscores + (e.g., **foo_bar**). This was incorrect behavior. Since v4.4.0, the URI for + underscores (**foo_bar**) is not accessible. + .. _use-defined-routes-only: Use Defined Routes Only @@ -605,7 +624,7 @@ When no defined route is found that matches the URI, the system will attempt to controllers and methods when Auto Routing is enabled. You can disable this automatic matching, and restrict routes -to only those defined by you, by setting the ``setAutoRoute()`` option to false: +to only those defined by you, by setting the ``$autoRoute`` property to false: .. literalinclude:: routing/050.php @@ -621,6 +640,8 @@ a valid class/method pair, just like you would show in any route, or a Closure: .. literalinclude:: routing/051.php +Using the ``$override404`` property within the routing config file, you can use closures. Defining the override in the Routing file is restricted to class/method pairs. + .. note:: The ``set404Override()`` method does not change the Response status code to ``404``. If you don't set the status code in the controller you set, the default status code ``200`` will be returned. See :php:meth:`CodeIgniter\\HTTP\\Response::setStatusCode()` for @@ -662,9 +683,9 @@ and execute the corresponding controller methods. Enable Auto Routing =================== -To use it, you need to change the setting ``setAutoRoute()`` option to true in **app/Config/Routes.php**:: +To use it, you need to change the setting ``$autoRoute`` option to true in **app/Config/Routing.php**:: - $routes->setAutoRoute(true); + public bool $autoRoute = true; And you need to change the property ``$autoRoutesImproved`` to ``true`` in **app/Config/Feature.php**:: @@ -741,6 +762,32 @@ In this example, if the user were to visit **example.com/products**, and a ``Pro .. important:: 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-improved-module-routing: + +Module Routing +============== + +.. versionadded:: 4.4.0 + +You can use auto routing even if you use :doc:`../general/modules` and place +the controllers in a different namespace. + +To route to a module, the ``$moduleRoutes`` property in **app/Config/Routing.php** +must be set:: + + public array $moduleRoutes = [ + 'blog' => 'Acme\Blog\Controllers', + ]; + +The key is the first URI segment for the module, and the value is the controller +namespace. In the above configuration, **http://localhost:8080/blog/foo/bar** +will be routed to ``Acme\Blog\Controllers\Foo::getBar()``. + +.. note:: If you define ``$moduleRoutes``, the routing for the module takes + precedence. In the above example, even if you have the ``App\Controllers\Blog`` + controller, **http://localhost:8080/blog** will be routed to the default + controller ``Acme\Blog\Controllers\Home``. + .. _auto-routing-legacy: Auto Routing (Legacy) @@ -763,7 +810,7 @@ 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**:: +To use it, you need to change the setting ``$autoRoute`` option to true in **app/Config/Routing.php**:: $routes->setAutoRoute(true); @@ -931,3 +978,16 @@ You can sort the routes by *Handler*: .. code-block:: console php spark routes -h + +.. _routing-spark-routes-specify-host: + +Specify Host +------------ + +.. versionadded:: 4.4.0 + +You can specify the host in the request URL with the ``--host`` option: + +.. code-block:: console + + php spark routes --host accounts.example.com diff --git a/user_guide_src/source/incoming/routing/045.php b/user_guide_src/source/incoming/routing/045.php index 98aef9276f9d..e9534931dbc8 100644 --- a/user_guide_src/source/incoming/routing/045.php +++ b/user_guide_src/source/incoming/routing/045.php @@ -1,6 +1,12 @@ setDefaultNamespace(''); +// In app/Config/Routing.php +class Routing extends BaseRouting +{ + // ... + public string $defaultNamespace = ''; + // ... +} // Controller is \Users $routes->get('users', 'Users::index'); diff --git a/user_guide_src/source/incoming/routing/046.php b/user_guide_src/source/incoming/routing/046.php index fbbbee2300df..8998fe70c039 100644 --- a/user_guide_src/source/incoming/routing/046.php +++ b/user_guide_src/source/incoming/routing/046.php @@ -1,5 +1,6 @@ setDefaultNamespace('App'); // Controller is \App\Users diff --git a/user_guide_src/source/incoming/routing/049.php b/user_guide_src/source/incoming/routing/049.php index 31d49508ab86..9f53bee87476 100644 --- a/user_guide_src/source/incoming/routing/049.php +++ b/user_guide_src/source/incoming/routing/049.php @@ -1,3 +1,12 @@ setTranslateURIDashes(true); diff --git a/user_guide_src/source/incoming/routing/050.php b/user_guide_src/source/incoming/routing/050.php index c331102cd9f4..6f5446f5a654 100644 --- a/user_guide_src/source/incoming/routing/050.php +++ b/user_guide_src/source/incoming/routing/050.php @@ -1,3 +1,12 @@ setAutoRoute(false); diff --git a/user_guide_src/source/incoming/routing/051.php b/user_guide_src/source/incoming/routing/051.php index dddd067f0f26..bced5d53147a 100644 --- a/user_guide_src/source/incoming/routing/051.php +++ b/user_guide_src/source/incoming/routing/051.php @@ -1,5 +1,13 @@ set404Override('App\Errors::show404'); diff --git a/user_guide_src/source/incoming/filters/010.php b/user_guide_src/source/incoming/routing/067.php similarity index 100% rename from user_guide_src/source/incoming/filters/010.php rename to user_guide_src/source/incoming/routing/067.php diff --git a/user_guide_src/source/installation/installing_composer.rst b/user_guide_src/source/installation/installing_composer.rst index 8305ca9798d2..9af6a0f0fa71 100644 --- a/user_guide_src/source/installation/installing_composer.rst +++ b/user_guide_src/source/installation/installing_composer.rst @@ -46,9 +46,12 @@ The command above will create a **project-root** folder. If you omit the "project-root" argument, the command will create an "appstarter" folder, which can be renamed as appropriate. -.. note:: CodeIgniter autoloader does not allow special characters that are illegal in filenames on certain operating systems. +.. note:: Before v4.4.0, CodeIgniter autoloader did not allow special + characters that are illegal in filenames on certain operating systems. The symbols that can be used are ``/``, ``_``, ``.``, ``:``, ``\`` and space. - So if you install CodeIgniter under the folder that contains the special characters like ``(``, ``)``, etc., CodeIgniter won't work. + So if you installed CodeIgniter under the folder that contains the special + characters like ``(``, ``)``, etc., CodeIgniter didn't work. Since v4.4.0, + this restriction has been removed. .. important:: When you deploy to your production server, don't forget to run the following command: diff --git a/user_guide_src/source/installation/installing_manual.rst b/user_guide_src/source/installation/installing_manual.rst index 7e592046f3a5..8b55e375de0b 100644 --- a/user_guide_src/source/installation/installing_manual.rst +++ b/user_guide_src/source/installation/installing_manual.rst @@ -22,9 +22,12 @@ Installation Download the `latest version `_, and extract it to become your project root. -.. note:: CodeIgniter autoloader does not allow special characters that are illegal in filenames on certain operating systems. +.. note:: Before v4.4.0, CodeIgniter autoloader did not allow special + characters that are illegal in filenames on certain operating systems. The symbols that can be used are ``/``, ``_``, ``.``, ``:``, ``\`` and space. - So if you install CodeIgniter under the folder that contains the special characters like ``(``, ``)``, etc., CodeIgniter won't work. + So if you installed CodeIgniter under the folder that contains the special + characters like ``(``, ``)``, etc., CodeIgniter didn't work. Since v4.4.0, + this restriction has been removed. Initial Configuration ===================== diff --git a/user_guide_src/source/installation/upgrade_440.rst b/user_guide_src/source/installation/upgrade_440.rst new file mode 100644 index 000000000000..00fab1b5b980 --- /dev/null +++ b/user_guide_src/source/installation/upgrade_440.rst @@ -0,0 +1,249 @@ +############################## +Upgrading from 4.3.x to 4.4.0 +############################## + +Please refer to the upgrade instructions corresponding to your installation method. + +- :ref:`Composer Installation App Starter Upgrading ` +- :ref:`Composer Installation Adding CodeIgniter4 to an Existing Project Upgrading ` +- :ref:`Manual Installation Upgrading ` + +.. contents:: + :local: + :depth: 2 + +SECURITY +******** + +When Using $this->validate() +============================ + +There was a known potential vulnerability in :ref:`$this->validate() ` in the Controller to bypass validation. +The attack could allow developers to misinterpret unvalidated empty data as +validated and proceed with processing. + +The :ref:`Validation::getValidated() ` +method has been added to ensure that validated data is obtained. + +Therefore, when you use ``$this->validate()`` in your Controllers, you should +use the new ``Validation::getValidated()`` method to get the validated data. + +.. literalinclude:: ../libraries/validation/045.php + :lines: 2- + +Breaking Changes +**************** + +.. _upgrade-440-uri-setsegment: + +URI::setSegment() Change +======================== + +Dut to a bug, in previous versions an exception was not thrown if the last segment +``+2`` was specified. This bug has been fixed. + +If your code depends on this bug, fix the segment number. + +.. literalinclude:: upgrade_440/002.php + :lines: 2- + +Site URI Changes +================ + +- Because of the rework for the current URI determination, the framework may return + site URIs or the URI paths slightly differently than in previous versions. It may + break your test code. Update assertions if the existing tests fail. +- When your baseURL has sub-directories and you get the relative path to baseURL of + the current URI by the ``URI::getPath()`` method, you must use the new + ``SiteURI::getRoutePath()`` method instead. + +See :ref:`v440-site-uri-changes` for details. + +When You Extend Exceptions +========================== + +If you are extending ``CodeIgniter\Debug\Exceptions`` and have not overridden +the ``exceptionHandler()`` method, defining the new ``Config\Exceptions::handler()`` +method in your **app/Config/Exceptions.php** will cause the specified Exception +Handler to be executed. + +Your overridden code will no longer be executed, so make any necessary changes +by defining your own exception handler. + +See :ref:`custom-exception-handlers` for the detail. + +Auto Routing (Improved) and translateURIDashes +============================================== + +When using Auto Routing (Improved) and ``$translateURIDashes`` is true +(``$routes->setTranslateURIDashes(true)``), in previous versions due to a bug +two URIs correspond to a single controller method, one URI for dashes +(e.g., **foo-bar**) and one URI for underscores (e.g., **foo_bar**). + +This bug was fixed and now URIs for underscores (**foo_bar**) is not accessible. + +If you have links to URIs for underscores (**foo_bar**), update them with URIs +for dashes (**foo-bar**). + +When Passing Classname with Namespace to Factories +================================================== + +The behavior of passing a classname with a namespace to Factories has been changed. +See :ref:`ChangeLog ` for details. + +If you have code like ``model('\Myth\Auth\Models\UserModel::class')`` or +``model('Myth\Auth\Models\UserModel')`` (the code may be in the third-party packages), +and you expect to load your ``App\Models\UserModel``, you need to define the +classname to be loaded before the first loading of that class:: + + Factories::define('models', 'Myth\Auth\Models\UserModel', 'App\Models\UserModel'); + +See :ref:`factories-defining-classname-to-be-loaded` for details. + +Interface Changes +================= + +Some interface changes have been made. Classes that implement them should update +their APIs to reflect the changes. See :ref:`v440-interface-changes` for details. + +Method Signature Changes +======================== + +Some method signature changes have been made. Classes that extend them should +update their APIs to reflect the changes. See :ref:`v440-method-signature-changes` +for details. + +Also, the parameter types of some constructors and ``Services::security()`` have changed. +If you call them with the parameters, change the parameter values. +See :ref:`v440-parameter-type-changes` for details. + +RouteCollection::$routes +======================== + +The array structure of the protected property ``$routes`` has been modified for +performance. + +If you extend ``RouteCollection`` and use the ``$routes``, update your code to +match the new array structure. + +Mandatory File Changes +********************** + +index.php and spark +=================== + +The following files received significant changes and +**you must merge the updated versions** with your application: + +- ``public/index.php`` (see also :ref:`v440-codeigniter-and-exit`) +- ``spark`` + +.. important:: If you don't update the above files, CodeIgniter will not work + properly after running ``composer update``. + + The upgrade procedure, for example, is as follows: + + .. code-block:: console + + composer update + cp vendor/codeigniter4/framework/public/index.php public/index.php + cp vendor/codeigniter4/framework/spark spark + +Config Files +============ + +app/Config/App.php +------------------ + +- The property ``$proxyIPs`` must be an array. If you don't use proxy servers, + it must be ``public array $proxyIPs = [];``. + +.. _upgrade-440-config-routing: + +app/Config/Routing.php +---------------------- + +To clean up the routing system, the following changes were made: + +- New **app/Config/Routing.php** file that holds the settings that used to be in the Routes file. +- The **app/Config/Routes.php** file was simplified so that it only contains the routes without settings and verbiage to clutter the file. +- The environment-specific routes files are no longer loaded automatically. + +So you need to do: + +1. Copy **app/Config/Routing.php** from the new framework to your **app/Config** + directory, and configure it. +2. Remove all settings in **app/Config/Routes.php** that are no longer needed. +3. If you use the environment-specific routes files, add them to the ``$routeFiles`` property in **app/Config/Routing.php**. + +app/Config/Cookie.php +--------------------- + +The Cookie config items in **app/Config/App.php** are no longer used. + +1. Copy **app/Config/Cookie.php** from the new framework to your **app/Config** + directory, and configure it. +2. Remove the properties (from ``$cookiePrefix`` to ``$cookieSameSite``) in + **app/Config/App.php**. + +app/Config/Security.php +----------------------- + +The CSRF config items in **app/Config/App.php** are no longer used. + +1. Copy **app/Config/Security.php** from the new framework to your **app/Config** + directory, and configure it. +2. Remove the properties (from ``$CSRFTokenName`` to ``$CSRFSameSite``) in + **app/Config/App.php**. + +app/Config/Session.php +---------------------- + +The Session config items in **app/Config/App.php** are no longer used. + +1. Copy **app/Config/Session.php** from the new framework to your **app/Config** + directory, and configure it. +2. Remove the properties (from ``$sessionDriver`` to ``$sessionDBGroup``) in + **app/Config/App.php**. + +Breaking Enhancements +********************* + +- **Routing:** The method signature of ``RouteCollection::__construct()`` has been changed. + The third parameter ``Routing $routing`` has been added. Extending classes + should likewise add the parameter so as not to break LSP. +- **Validation:** The method signature of ``Validation::check()`` has been changed. + The ``string`` typehint on the ``$rule`` parameter was removed. Extending classes + should likewise remove the typehint so as not to break LSP. + +Project Files +************* + +Some files in the **project space** (root, app, public, writable) received updates. Due to +these files being outside of the **system** scope they will not be changed without your intervention. + +There are some third-party CodeIgniter modules available to assist with merging changes to +the project space: `Explore on Packagist `_. + +Content Changes +=============== + +The following files received significant changes (including deprecations or visual adjustments) +and it is recommended that you merge the updated versions with your application: + +Config +------ + +- app/Config/CURLRequest.php + - The default value of :ref:`$shareOptions ` has been change to ``false``. +- app/Config/Exceptions.php + - Added the new method ``handler()`` that define custom Exception Handlers. + See :ref:`custom-exception-handlers`. + +All Changes +=========== + +This is a list of all files in the **project space** that received changes; +many will be simple comments or formatting that have no effect on the runtime: + +- @TODO diff --git a/user_guide_src/source/installation/upgrade_440/002.php b/user_guide_src/source/installation/upgrade_440/002.php new file mode 100644 index 000000000000..9cabdfcfcff3 --- /dev/null +++ b/user_guide_src/source/installation/upgrade_440/002.php @@ -0,0 +1,12 @@ +setSegment(4, 'three'); +// The URI will be http://example.com/one/two/three + +// After: +$uri->setSegment(4, 'three'); // Will throw Exception +$uri->setSegment(3, 'three'); +// The URI will be http://example.com/one/two/three diff --git a/user_guide_src/source/installation/upgrading.rst b/user_guide_src/source/installation/upgrading.rst index 7157c2ffd04d..c675327d7412 100644 --- a/user_guide_src/source/installation/upgrading.rst +++ b/user_guide_src/source/installation/upgrading.rst @@ -16,6 +16,7 @@ See also :doc:`./backward_compatibility_notes`. backward_compatibility_notes + upgrade_440 upgrade_438 upgrade_437 upgrade_436 diff --git a/user_guide_src/source/libraries/curlrequest.rst b/user_guide_src/source/libraries/curlrequest.rst index 18afd51f771c..4da540937cb3 100644 --- a/user_guide_src/source/libraries/curlrequest.rst +++ b/user_guide_src/source/libraries/curlrequest.rst @@ -23,17 +23,23 @@ to change very little to move over to use Guzzle. Config for CURLRequest ********************** +.. _curlrequest-sharing-options: + Sharing Options =============== -Due to historical reasons, by default, the CURLRequest shares all the options between requests. -If you send more than one request with an instance of the class, -this behavior may cause an error request with unnecessary headers and body. +.. note:: Since v4.4.0, the default value has been changed to ``false``. This + setting exists only for backward compatibility. New users do not need to + change the setting. -You can change the behavior by editing the following config parameter value in **app/Config/CURLRequest.php** to ``false``: +If you want to share all the options between requests, set ``$shareOptions`` to +``true`` in **app/Config/CURLRequest.php**: .. literalinclude:: curlrequest/001.php +If you send more than one request with an instance of the class, this behavior +may cause an error request with unnecessary headers and body. + .. note:: Before v4.2.0, the request body is not reset even if ``$shareOptions`` is false due to a bug. ******************* @@ -296,6 +302,17 @@ has been disabled. Any files that you want to send must be passed as instances o ``form_params`` for ``application/x-www-form-urlencoded`` requests, and ``multipart`` for ``multipart/form-data`` requests. +.. _curlrequest-request-options-proxy: + +proxy +===== + +.. versionadded:: 4.4.0 + +You can set a proxy by passing an associative array as the ``proxy`` option: + +.. literalinclude:: curlrequest/035.php + query ===== diff --git a/user_guide_src/source/libraries/curlrequest/001.php b/user_guide_src/source/libraries/curlrequest/001.php index e89e0692462f..303366336dbb 100644 --- a/user_guide_src/source/libraries/curlrequest/001.php +++ b/user_guide_src/source/libraries/curlrequest/001.php @@ -6,7 +6,6 @@ class CURLRequest extends BaseConfig { - public $shareOptions = false; - // ... + public bool $shareOptions = true; } diff --git a/user_guide_src/source/libraries/curlrequest/035.php b/user_guide_src/source/libraries/curlrequest/035.php new file mode 100644 index 000000000000..a729f9f3be22 --- /dev/null +++ b/user_guide_src/source/libraries/curlrequest/035.php @@ -0,0 +1,7 @@ +request( + 'GET', + 'http://example.com', + ['proxy' => 'http://localhost:3128'] +); diff --git a/user_guide_src/source/libraries/images.rst b/user_guide_src/source/libraries/images.rst index 8c4ed8c7b711..0507c5d36529 100644 --- a/user_guide_src/source/libraries/images.rst +++ b/user_guide_src/source/libraries/images.rst @@ -85,7 +85,9 @@ Image Quality ``save()`` can take an additional parameter ``$quality`` to alter the resulting image quality. Values range from 0 to 100 with 90 being the framework default. This parameter -only applies to JPEG images and will be ignored otherwise: +only applies to JPEG and WEBP images, will be ignored otherwise: + +.. note:: The parameter ``$quality`` for WebP can be used since v4.4.0. .. literalinclude:: images/005.php diff --git a/user_guide_src/source/libraries/sessions.rst b/user_guide_src/source/libraries/sessions.rst index f1df897bfad7..6d5e9af653d5 100644 --- a/user_guide_src/source/libraries/sessions.rst +++ b/user_guide_src/source/libraries/sessions.rst @@ -344,6 +344,30 @@ intend to reuse that same key in the same request, you'd want to use .. literalinclude:: sessions/036.php +Closing a Session +================= + +.. _session-close: + +close() +------- + +.. versionadded:: 4.4.0 + +To close the current session manually after you no longer need it, use the +``close()`` method: + +.. literalinclude:: sessions/044.php + +You do not have to close the session manually, PHP will close it automatically +after your script terminated. But as session data is locked to prevent concurrent +writes only one request may operate on a session at any time. You may improve +your site performance by closing the session as soon as all changes to session +data are done. + +This method will work in exactly the same way as PHP's +`session_write_close() `_ function. + Destroying a Session ==================== @@ -548,7 +572,7 @@ DatabaseHandler Driver supported, due to lack of advisory locking mechanisms on other platforms. Using sessions without locks can cause all sorts of problems, especially with heavy usage of AJAX, and we will not - support such cases. Use ``session_write_close()`` after you've + support such cases. Use the :ref:`session-close` method after you've done processing session data if you're having performance issues. diff --git a/user_guide_src/source/libraries/sessions/003.php b/user_guide_src/source/libraries/sessions/003.php index ed979ddc0738..13d9b068f8cc 100644 --- a/user_guide_src/source/libraries/sessions/003.php +++ b/user_guide_src/source/libraries/sessions/003.php @@ -1,3 +1,3 @@ close(); diff --git a/user_guide_src/source/libraries/sessions/044.php b/user_guide_src/source/libraries/sessions/044.php new file mode 100644 index 000000000000..13d9b068f8cc --- /dev/null +++ b/user_guide_src/source/libraries/sessions/044.php @@ -0,0 +1,3 @@ +close(); diff --git a/user_guide_src/source/libraries/uploaded_files.rst b/user_guide_src/source/libraries/uploaded_files.rst index b202d1b2d0c3..560706c2b73f 100644 --- a/user_guide_src/source/libraries/uploaded_files.rst +++ b/user_guide_src/source/libraries/uploaded_files.rst @@ -304,6 +304,16 @@ version, use ``getMimeType()`` instead: .. literalinclude:: uploaded_files/015.php +getClientPath() +--------------- + +.. versionadded:: 4.4.0 + +Returns the `webkit relative path `_ of the uploaded file when the client has uploaded files via directory upload. +In PHP versions below 8.1, this returns ``null`` + +.. literalinclude:: uploaded_files/023.php + Moving Files ============ diff --git a/user_guide_src/source/libraries/uploaded_files/023.php b/user_guide_src/source/libraries/uploaded_files/023.php new file mode 100644 index 000000000000..f8accc9087b1 --- /dev/null +++ b/user_guide_src/source/libraries/uploaded_files/023.php @@ -0,0 +1,4 @@ +getClientPath(); +echo $clientPath; // dir/file.txt, or dir/sub_dir/file.txt diff --git a/user_guide_src/source/libraries/uri.rst b/user_guide_src/source/libraries/uri.rst index bfd189859bf4..3ce031bf64bf 100644 --- a/user_guide_src/source/libraries/uri.rst +++ b/user_guide_src/source/libraries/uri.rst @@ -14,34 +14,47 @@ relative URI to an existing one and have it resolved safely and correctly. Creating URI instances ====================== -Creating a URI instance is as simple as creating a new class instance: +Creating a URI instance is as simple as creating a new class instance. + +When you create the new instance, you can pass a full or partial URL in the constructor and it will be parsed +into its appropriate sections: .. literalinclude:: uri/001.php + :lines: 2- -Alternatively, you can use the ``service()`` function to return an instance for you: +Alternatively, you can use the :php:func:`service()` function to return an instance for you: -.. literalinclude:: uri/002.php +.. literalinclude:: uri/003.php + :lines: 2- -When you create the new instance, you can pass a full or partial URL in the constructor and it will be parsed -into its appropriate sections: +Since v4.4.0, if you don't pass a URL, it returns the current URI: -.. literalinclude:: uri/003.php +.. literalinclude:: uri/002.php + :lines: 2- + +.. note:: The above code returns the ``SiteURI`` instance, that extends the ``URI`` + class. The ``URI`` class is for general URIs, but the ``SiteURI`` class is + for your site URIs. The Current URI --------------- Many times, all you really want is an object representing the current URL of this request. -You can use one of the functions available in the :doc:`../helpers/url_helper`: +You can use the :php:func:`current_url()` function available in the :doc:`../helpers/url_helper`: .. literalinclude:: uri/004.php + :lines: 2- You must pass ``true`` as the first parameter, otherwise, it will return the string representation of the current URL. This URI is based on the path (relative to your ``baseURL``) as determined by the current request object and your settings in ``Config\App`` (``baseURL``, ``indexPage``, and ``forceGlobalSecureRequests``). -Assuming that you're in a controller that extends ``CodeIgniter\Controller`` you can get this relative path: + +Assuming that you're in a controller that extends ``CodeIgniter\Controller``, you +can also get the current SiteURI instance: .. literalinclude:: uri/005.php + :lines: 2- =========== URI Strings @@ -136,6 +149,10 @@ can be used to manipulate it: .. note:: When setting the path this way, or any other way the class allows, it is sanitized to encode any dangerous characters, and remove dot segments for safety. +.. note:: Since v4.4.0, the ``SiteURI::getRoutePath()`` method, + returns the URI path relative to baseURL, and the ``SiteURI::getPath()`` + method always returns the full URI path with leading ``/``. + Query ----- diff --git a/user_guide_src/source/libraries/uri/001.php b/user_guide_src/source/libraries/uri/001.php index 524e67669110..7219d03d248f 100644 --- a/user_guide_src/source/libraries/uri/001.php +++ b/user_guide_src/source/libraries/uri/001.php @@ -1,3 +1,3 @@ request->getPath(); +$uri = $this->request->getUri(); diff --git a/user_guide_src/source/libraries/validation.rst b/user_guide_src/source/libraries/validation.rst index 6142a9f9f6be..3224961e0821 100644 --- a/user_guide_src/source/libraries/validation.rst +++ b/user_guide_src/source/libraries/validation.rst @@ -128,6 +128,9 @@ this code and save it to your **app/Controllers/** folder: In previous versions, you need to use ``if (strtolower($this->request->getMethod()) !== 'post')``. +.. note:: The :ref:`$this->validator->getValidated() ` + method can be used since v4.4.0. + The Routes ========== @@ -249,6 +252,7 @@ Loading the Library The library is loaded as a service named **validation**: .. literalinclude:: validation/004.php + :lines: 2- This automatically loads the ``Config\Validation`` file which contains settings for including multiple Rulesets, and collections of rules that can be easily reused. @@ -278,6 +282,7 @@ This method sets a single rule. It has the method signature:: The ``$rules`` either takes in a pipe-delimited list of rules or an array collection of rules: .. literalinclude:: validation/005.php + :lines: 2- The value you pass to ``$field`` must match the key of any data array that is sent in. If the data is taken directly from ``$_POST``, then it must be an exact match for @@ -297,10 +302,12 @@ setRules() Like ``setRule()``, but accepts an array of field names and their rules: .. literalinclude:: validation/006.php + :lines: 2- To give a labeled error message you can set up as: .. literalinclude:: validation/007.php + :lines: 2- .. _validation-withrequest: @@ -311,15 +318,18 @@ If your data is in a nested associative array, you can use "dot array syntax" to easily validate your data: .. literalinclude:: validation/009.php + :lines: 2- You can use the ``*`` wildcard symbol to match any one level of the array: .. literalinclude:: validation/010.php + :lines: 2- "dot array syntax" can also be useful when you have single dimension array data. For example, data returned by multi select dropdown: .. literalinclude:: validation/011.php + :lines: 2- withRequest() ============= @@ -330,15 +340,22 @@ current Request object and it will take all of the input data and set it as the data to be validated: .. literalinclude:: validation/008.php + :lines: 2- -.. note:: This method gets JSON data from +.. warning:: When you use this method, you should use the + :ref:`getValidated() ` method to get the + validated data. Because this method gets JSON data from :ref:`$request->getJSON() ` when the request is a JSON request (``Content-Type: application/json``), or gets Raw data from :ref:`$request->getRawInput() ` when the request is a PUT, PATCH, DELETE request and is not HTML form post (``Content-Type: multipart/form-data``), - or gets data from :ref:`$request->getVar() `. + or gets data from :ref:`$request->getVar() `, + and an attacker could change what data is validated. + +.. note:: The :ref:`getValidated() ` + method can be used since v4.4.0. *********************** Working with Validation @@ -358,6 +375,7 @@ The optional third parameter ``$dbGroup`` is the database group to use. This method returns true if the validation is successful. .. literalinclude:: validation/043.php + :lines: 2- Running Multiple Validations ============================ @@ -372,13 +390,41 @@ errors from previous run. Be aware that ``reset()`` will invalidate any data, ru you previously set, so ``setRules()``, ``setRuleGroup()`` etc. need to be repeated: .. literalinclude:: validation/019.php + :lines: 2- Validating 1 Value ================== -Validate one value against a rule: +The ``check()`` method validates one value against the rules. +The first parameter ``$value`` is the value to validate. The second parameter +``$rule`` is the validation rules. +The optional third parameter ``$errors`` is the the custom error message. .. literalinclude:: validation/012.php + :lines: 2- + +.. note:: Prior to v4.4.0, this method's second parameter, ``$rule``, was + typehinted to accept ``string``. In v4.4.0 and after, the typehint was + removed to allow arrays, too. + +.. note:: This method calls the ``setRule()`` method to set the rules internally. + +.. _validation-getting-validated-data: + +Getting Validated Data +====================== + +.. versionadded:: 4.4.0 + +The actual validated data can be retrieved with the ``getValidated()`` method. +This method returns an array of only those elements that have been validated by +the validation rules. + +.. literalinclude:: validation/044.php + :lines: 2- + +.. literalinclude:: validation/045.php + :lines: 2- Saving Sets of Validation Rules to the Config File ================================================== @@ -405,6 +451,7 @@ How to Specify Rule Group You can specify the group to use when you call the ``run()`` method: .. literalinclude:: validation/014.php + :lines: 2- How to Save Error Messages -------------------------- @@ -424,17 +471,21 @@ See :ref:`validation-custom-errors` for details on the formatting of the array. Getting & Setting Rule Groups ----------------------------- -**Get Rule Group** +Get Rule Group +^^^^^^^^^^^^^^ This method gets a rule group from the validation configuration: .. literalinclude:: validation/017.php + :lines: 2- -**Set Rule Group** +Set Rule Group +^^^^^^^^^^^^^^ This method sets a rule group from the validation configuration to the validation service: .. literalinclude:: validation/018.php + :lines: 2- .. _validation-placeholders: @@ -447,6 +498,7 @@ the name of the field (or array key) that was passed in as ``$data`` surrounded replaced by the **value** of the matched incoming field. An example should clarify this: .. literalinclude:: validation/020.php + :lines: 2- .. note:: Since v4.3.5, you must set the validation rules for the placeholder field (``id``). @@ -455,10 +507,12 @@ In this set of rules, it states that the email address should be unique in the d that has an id matching the placeholder's value. Assuming that the form POST data had the following: .. literalinclude:: validation/021.php + :lines: 2- then the ``{id}`` placeholder would be replaced with the number **4**, giving this revised rule: .. literalinclude:: validation/022.php + :lines: 2- So it will ignore the row in the database that has ``id=4`` when it verifies the email is unique. @@ -493,10 +547,12 @@ These are two ways to provide custom error messages. As the last parameter: .. literalinclude:: validation/023.php + :lines: 2- Or as a labeled style: .. literalinclude:: validation/024.php + :lines: 2- If you'd like to include a field's "human" name, or the optional parameter some rules allow for (such as max_length), or the value that was validated you can add the ``{field}``, ``{param}`` and ``{value}`` tags to your message, respectively:: @@ -518,6 +574,7 @@ Let's say we have a file with translations located here: **app/Languages/en/Rule We can simply use the language lines defined in this file, like this: .. literalinclude:: validation/025.php + :lines: 2- .. _validation-getting-all-errors: @@ -527,6 +584,7 @@ Getting All Errors If you need to retrieve all error messages for failed fields, you can use the ``getErrors()`` method: .. literalinclude:: validation/026.php + :lines: 2- If no errors exist, an empty array will be returned. @@ -557,6 +615,7 @@ You can retrieve the error for a single field with the ``getError()`` method. Th name: .. literalinclude:: validation/027.php + :lines: 2- If no error exists, an empty string will be returned. @@ -568,10 +627,12 @@ Check If Error Exists You can check to see if an error exists with the ``hasError()`` method. The only parameter is the field name: .. literalinclude:: validation/028.php + :lines: 2- When specifying a field with a wildcard, all errors matching the mask will be checked: .. literalinclude:: validation/029.php + :lines: 2- .. _validation-redirect-and-validation-errors: @@ -615,6 +676,7 @@ An array named ``$errors`` is available within the view that contains a list of the name of the field that had the error, and the value is the error message, like this: .. literalinclude:: validation/031.php + :lines: 2- There are actually two types of views that you can create. The first has an array of all of the errors, and is what we just looked at. The other type is simpler, and only contains a single variable, ``$error`` that contains the @@ -686,6 +748,7 @@ Using a Custom Rule Your new custom rule could now be used just like any other rule: .. literalinclude:: validation/036.php + :lines: 2- Allowing Parameters ------------------- @@ -720,6 +783,7 @@ you may use a closure instead of a rule class. You need to use an array for validation rules: .. literalinclude:: validation/040.php + :lines: 2- You must set the error message for the closure rule. When you specify the error message, set the array key for the closure rule. @@ -728,6 +792,7 @@ In the above code, the ``required`` rule has the key ``0``, and the closure has Or you can use the following parameters: .. literalinclude:: validation/041.php + :lines: 2- *************** Available Rules @@ -737,6 +802,7 @@ Available Rules There can be no spaces before and after ``ignore_value``. .. literalinclude:: validation/038.php + :lines: 2- Rules for General Use ===================== diff --git a/user_guide_src/source/libraries/validation/001.php b/user_guide_src/source/libraries/validation/001.php index af2acb754a7b..e797b21a6ae0 100644 --- a/user_guide_src/source/libraries/validation/001.php +++ b/user_guide_src/source/libraries/validation/001.php @@ -2,8 +2,6 @@ namespace App\Controllers; -use Config\Services; - class Form extends BaseController { protected $helpers = ['form']; @@ -20,6 +18,9 @@ public function index() return view('signup'); } + // If you want to get the validated data. + $validData = $this->validator->getValidated(); + return view('success'); } } diff --git a/user_guide_src/source/libraries/validation/008.php b/user_guide_src/source/libraries/validation/008.php index 1b570f1aad82..38656dda37cb 100644 --- a/user_guide_src/source/libraries/validation/008.php +++ b/user_guide_src/source/libraries/validation/008.php @@ -1,3 +1,11 @@ withRequest($this->request)->run(); +$validation = \Config\Services::validation(); +$request = \Config\Services::request(); + +if ($validation->withRequest($request)->run()) { + // If you want to get the validated data. + $validData = $validation->getValidated(); + + // ... +} diff --git a/user_guide_src/source/libraries/validation/012.php b/user_guide_src/source/libraries/validation/012.php index a43f9ab1a498..9b9e89c21c87 100644 --- a/user_guide_src/source/libraries/validation/012.php +++ b/user_guide_src/source/libraries/validation/012.php @@ -1,3 +1,5 @@ check($value, 'required'); +if ($validation->check($value, 'required')) { + // $value is valid. +} diff --git a/user_guide_src/source/libraries/validation/044.php b/user_guide_src/source/libraries/validation/044.php new file mode 100644 index 000000000000..7f3a0e0e0902 --- /dev/null +++ b/user_guide_src/source/libraries/validation/044.php @@ -0,0 +1,21 @@ +setRules([ + 'username' => 'required', + 'password' => 'required|min_length[10]', +]); + +$data = [ + 'username' => 'john', + 'password' => 'BPi-$Swu7U5lm$dX', + 'csrf_token' => '8b9218a55906f9dcc1dc263dce7f005a', +]; + +if ($validation->run($data)) { + $validatedData = $validation->getValidated(); + // $validatedData = [ + // 'username' => 'john', + // 'password' => 'BPi-$Swu7U5lm$dX', + // ]; +} diff --git a/user_guide_src/source/libraries/validation/045.php b/user_guide_src/source/libraries/validation/045.php new file mode 100644 index 000000000000..1becb578dc21 --- /dev/null +++ b/user_guide_src/source/libraries/validation/045.php @@ -0,0 +1,18 @@ +validate([ + 'username' => 'required', + 'password' => 'required|min_length[10]', +])) { + // The validation failed. + return view('login', [ + 'errors' => $this->validator->getErrors(), + ]); +} + +// The validation was successful. + +// Get the validated data. +$validData = $this->validator->getValidated(); diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index c5c257c6b800..f1f8b42c8a62 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -133,6 +133,25 @@ business logic and create objects that are pleasant to use. .. literalinclude:: entities/007.php +.. _entities-special-getter-setter: + +Special Getter/Setter +===================== + +.. versionadded:: 4.4.0 + +For example, if your Entity's parent class already has a ``getParent()`` method +defined, and your Entity also has a column named ``parent``, when you try to add +business logic to the ``getParent()`` method in your Entity class, the method is +already defined. + +In such a case, you can use the special getter/setter. Instead of ``getX()``/``setX()``, +set ``_getX()``/``_setX()``. + +In the above example, if your Entity has the ``_getParent()`` method, the method +will be used when you get ``$entity->parent``, and the ``_setParent()`` method +will be used when you set ``$entity->parent``. + ************ Data Mapping ************ diff --git a/user_guide_src/source/outgoing/localization.rst b/user_guide_src/source/outgoing/localization.rst index a2d11c360486..f84e9b029004 100644 --- a/user_guide_src/source/outgoing/localization.rst +++ b/user_guide_src/source/outgoing/localization.rst @@ -59,6 +59,9 @@ will be used to set the locale. Should you ever need to set the locale directly, see `Setting the Current Locale`_. +Since v4.4.0, ``IncomingRequest::setValidLocales()`` has been added to set +(and reset) valid locales that are set from ``Config\App::$supportedLocales`` setting. + Content Negotiation ------------------- diff --git a/user_guide_src/source/outgoing/response.rst b/user_guide_src/source/outgoing/response.rst index 205ee8280775..507e479b5f56 100644 --- a/user_guide_src/source/outgoing/response.rst +++ b/user_guide_src/source/outgoing/response.rst @@ -167,6 +167,16 @@ Use the optional ``setFileName()`` method to change the filename as it is sent t .. note:: The response object MUST be returned for the download to be sent to the client. This allows the response to be passed through all **after** filters before being sent to the client. +.. _open-file-in-browser: + +Open File in Browser +-------------------- + +Some browsers can display files such as PDF. To tell the browser to display the file instead of saving it, call the +``DownloadResponse::inline()`` method. + +.. literalinclude:: response/033.php + HTTP Caching ============ diff --git a/user_guide_src/source/outgoing/response/033.php b/user_guide_src/source/outgoing/response/033.php new file mode 100644 index 000000000000..8afaf84960ab --- /dev/null +++ b/user_guide_src/source/outgoing/response/033.php @@ -0,0 +1,6 @@ +response->download($name, $data)->inline(); diff --git a/user_guide_src/source/outgoing/table.rst b/user_guide_src/source/outgoing/table.rst index 0ccf8da0904d..bec15f86dbbf 100644 --- a/user_guide_src/source/outgoing/table.rst +++ b/user_guide_src/source/outgoing/table.rst @@ -71,6 +71,34 @@ to the Table constructor: .. literalinclude:: table/008.php +.. _table-sync-rows-with-headings: + +Synchronizing Rows with Headings +================================ + +.. versionadded:: 4.4.0 + +The ``setSyncRowsWithHeading(true)`` method enables that each data value +is placed in the same column as defined in ``setHeading()`` if an +associative array was used as parameter. This is especially useful +when dealing with data loaded via REST API where the order is not to +your liking, or if the API returned too much data. + +If a data row contains a key that is not present in the heading, its value is +filtered. Conversely, if a data row does not have a key listed in the heading, +an empty cell will be placed in its place. + +.. literalinclude:: table/019.php + +.. important:: You must call ``setSyncRowsWithHeading(true)`` and + ``setHeading([...])`` before adding any rows via ``addRow([...])`` where + the rearrangement of columns takes place. + +Using an array as input to ``generate()`` produces the same result: + +.. literalinclude:: table/020.php + + *************** Class Reference *************** @@ -188,3 +216,12 @@ Class Reference Example .. literalinclude:: table/018.php + + .. php:method:: setSyncRowsWithHeading(bool $orderByKey) + + :returns: Table instance (method chaining) + :rtype: Table + + Enables each row data key to be ordered by heading keys. This gives + more control of data being displaced in the correct column. Make + sure to set this value before calling the first ``addRow()`` method. diff --git a/user_guide_src/source/outgoing/table/019.php b/user_guide_src/source/outgoing/table/019.php new file mode 100644 index 000000000000..5478867fddfa --- /dev/null +++ b/user_guide_src/source/outgoing/table/019.php @@ -0,0 +1,40 @@ +setHeading(['name' => 'Name', 'color' => 'Color', 'size' => 'Size']) + ->setSyncRowsWithHeading(true) + ->addRow(['color' => 'Blue', 'name' => 'Fred', 'size' => 'Small']) + ->addRow(['size' => 'Large', 'age' => '24', 'name' => 'Mary']) + ->addRow(['color' => 'Green']); + +echo $table->generate(); +?> + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameColorSize
FredBlueSmall
MaryLarge
Green
diff --git a/user_guide_src/source/outgoing/table/020.php b/user_guide_src/source/outgoing/table/020.php new file mode 100644 index 000000000000..42bda4d2f3bc --- /dev/null +++ b/user_guide_src/source/outgoing/table/020.php @@ -0,0 +1,24 @@ + 'Blue', + 'name' => 'Fred', + 'size' => 'Small', + ], + [ + 'size' => 'Large', + 'age' => '24', + 'name' => 'Mary', + ], + [ + 'color' => 'Green', + ], +]; + +$table = new \CodeIgniter\View\Table(); + +$table->setHeading(['name' => 'Name', 'color' => 'Color', 'size' => 'Size']) + ->setSyncRowsWithHeading(true); + +echo $table->generate($data); diff --git a/user_guide_src/source/outgoing/view_layouts.rst b/user_guide_src/source/outgoing/view_layouts.rst index 2600b1c08922..6e6c672710fd 100644 --- a/user_guide_src/source/outgoing/view_layouts.rst +++ b/user_guide_src/source/outgoing/view_layouts.rst @@ -12,6 +12,8 @@ any view being rendered. You could create different layouts to support one-colum blog archive pages, and more. Layouts are never directly rendered. Instead, you render a view, which specifies the layout that it wants to extend. +.. _creating-a-layout: + ***************** Creating A Layout ***************** @@ -31,8 +33,24 @@ E.g. **app/Views/default.php**:: -The ``renderSection()`` method only has one argument - the name of the section. That way any child views know -what to name the content section. +The ``renderSection()`` method has two arguments: ``$sectionName`` and ``$saveData``. ``$sectionName`` is the name of +the section used by any child view to name the content section. If the boolean argument ``$saveData`` is set to true, +the method saves data for subsequent calls. Otherwise, the method cleans the data after displaying the contents. + +E.g. **app/Views/welcome_message.php**:: + + + + + <?= $this->renderSection('page_title', true) ?> + + +

renderSection('page_title') ?>

+

renderSection('content') ?>

+ + + +.. note:: ``$saveData`` can be used since v4.4.0. ********************** Using Layouts in Views diff --git a/user_guide_src/source/testing/controllers.rst b/user_guide_src/source/testing/controllers.rst index 130364260cba..8f38f68c4e3d 100644 --- a/user_guide_src/source/testing/controllers.rst +++ b/user_guide_src/source/testing/controllers.rst @@ -101,6 +101,11 @@ representing a valid URI: It is a good practice to always provide the URI during testing to avoid surprises. +.. note:: Since v4.4.0, this method creates a new Request instance with the URI. + Because the Request instance should have the URI instance. Also if the hostname + in the URI string is invalid with ``Config\App``, the valid hostname will be + set. + withBody($body) --------------- diff --git a/user_guide_src/source/testing/debugging.rst b/user_guide_src/source/testing/debugging.rst index b8da022d018f..6c589ead52aa 100644 --- a/user_guide_src/source/testing/debugging.rst +++ b/user_guide_src/source/testing/debugging.rst @@ -172,3 +172,18 @@ The ``getVarData()`` method should return an array containing arrays of key/valu outer array's key is the name of the section on the Vars tab: .. literalinclude:: debugging/006.php + +.. _debug-toolbar-hot-reload: + +Hot Reloading +============= + +.. versionadded:: 4.4.0 + +The Debug Toolbar includes a feature called Hot Reloading that allows you to make changes to your application's code and have them automatically reloaded in the browser without having to refresh the page. This is a great time-saver during development. + +To enable Hot Reloading while you are developing, you can click the button on the left side of the toolbar that looks like a refresh icon. This will enable Hot Reloading for all pages until you disable it. + +Hot Reloading works by scanning the files within the **app** directory every second and looking for changes. If it finds any, it will send a message to the browser to reload the page. It does not scan any other directories, so if you are making changes to files outside of the **app** directory, you will need to manually refresh the page. + +If you need to watch files outside of the **app** directory, or are finding it slow due to the size of your project, you can specify the directories to scan and the file extensions to scan for in the ``$watchedDirectories`` and ``$watchedExtensions`` properties of the **app/Config/Toolbar.php** configuration file. diff --git a/user_guide_src/source/tutorial/create_news_items.rst b/user_guide_src/source/tutorial/create_news_items.rst index 2c49a17c2f0a..4ed6badbae07 100644 --- a/user_guide_src/source/tutorial/create_news_items.rst +++ b/user_guide_src/source/tutorial/create_news_items.rst @@ -3,7 +3,7 @@ Create News Items .. contents:: :local: - :depth: 2 + :depth: 3 You now know how you can read data from a database using CodeIgniter, but you haven't written any information to the database yet. In this section, @@ -29,33 +29,17 @@ You can read more about the CSRF protection in :doc:`Security <../libraries/secu Create a Form ************* -View -==== +Create news/create View File +============================ To input data into the database, you need to create a form where you can input the information to be stored. This means you'll be needing a form with two fields, one for the title and one for the text. You'll derive -the slug from our title in the model. Create a new view at -**app/Views/news/create.php**:: +the slug from our title in the model. -

+Create a new view at **app/Views/news/create.php**: - getFlashdata('error') ?> - - -
- - - - -
- - - -
- - -
+.. literalinclude:: create_news_items/006.php There are probably only four things here that look unfamiliar. @@ -72,43 +56,61 @@ The :php:func:`csrf_field()` function creates a hidden input with a CSRF token t The :php:func:`set_value()` function provided by the :doc:`../helpers/form_helper` is used to show old input data when errors occur. -Controller -========== +News Controller +=============== + +Go back to your ``News`` controller. + +Add News::new() to Display the Form +----------------------------------- -Go back to your **News** controller. You're going to do two things here, -check whether the form was submitted and whether the submitted data -passed the validation rules. -You'll use the :ref:`validation method in Controller ` to do this. +First, create a method to display the HTML form you have created. .. literalinclude:: create_news_items/002.php -The code above adds a lot of functionality. +We load the :doc:`Form helper <../helpers/form_helper>` with the +:php:func:`helper()` function. Most helper functions require the helper to be +loaded before use. + +Then it returns the created form view. + +Add News::create() to Create a News Item +---------------------------------------- -First we load the :doc:`Form helper <../helpers/form_helper>` with the :php:func:`helper()` function. -Most helper functions require the helper to be loaded before use. +Next, create a method to create a news item from the submitted data. -Next, we check if we deal with the **POST** request with the -:doc:`IncomingRequest <../incoming/incomingrequest>` object ``$this->request``. -It is set in the controller by the framework. -The :ref:`IncomingRequest::is() ` method checks the type of the request. -Since the route for **create()** endpoint handles both: **GET** and **POST** requests we can safely assume that if the request is not POST then it is a GET type. -the form is loaded and returned to display. +You're going to do three things here: -Then, we get the necessary items from the POST data by the user and set them in the ``$post`` variable. -We also use the :doc:`IncomingRequest <../incoming/incomingrequest>` object ``$this->request``. +1. checks whether the submitted data passed the validation rules. +2. saves the news item to the database. +3. returns a success page. + +.. literalinclude:: create_news_items/005.php + +The code above adds a lot of functionality. -After that, the Controller-provided helper function :ref:`validateData() ` -is used to validate ``$post`` data. +Validate the Data +^^^^^^^^^^^^^^^^^ + +You'll use the Controller-provided helper function :ref:`validate() ` to validate the submitted data. In this case, the title and body fields are required and in the specific length. CodeIgniter has a powerful validation library as demonstrated above. You can read more about the :doc:`Validation library <../libraries/validation>`. -If the validation fails, the form is loaded and returned to display. +If the validation fails, we call the ``new()`` method you just created and return +the HTML form. + +Save the News Item +^^^^^^^^^^^^^^^^^^ + +If the validation passed all the rules, we get the validated data by +:ref:`$this->validator->getValidated() ` and +set them in the ``$post`` variable. -If the validation passed all the rules, the **NewsModel** is loaded and called. This -takes care of passing the news item into the model. The :ref:`model-save` method handles -inserting or updating the record automatically, based on whether it finds an array key -matching the primary key. +The ``NewsModel`` is loaded and called. This takes care of passing the news item +into the model. The :ref:`model-save` method handles inserting or updating the +record automatically, based on whether it finds an array key matching the primary +key. This contains a new function :php:func:`url_title()`. This function - provided by the :doc:`URL helper <../helpers/url_helper>` - strips down @@ -116,26 +118,29 @@ the string you pass it, replacing all spaces by dashes (``-``) and makes sure everything is in lowercase characters. This leaves you with a nice slug, perfect for creating URIs. -After this, view files are loaded and returned to display a success message. Create a view at -**app/Views/news/success.php** and write a success message. +Return Success Page +^^^^^^^^^^^^^^^^^^^ + +After this, view files are loaded and returned to display a success message. +Create a view at **app/Views/news/success.php** and write a success message. This could be as simple as::

News item created successfully.

-Model Updating -************** +NewsModel Updating +****************** The only thing that remains is ensuring that your model is set up to allow data to be saved properly. The ``save()`` method that was used will determine whether the information should be inserted or if the row already exists and should be updated, based on the presence of a primary key. In this case, there is no ``id`` field passed to it, -so it will insert a new row into it's table, **news**. +so it will insert a new row into it's table, ``news``. However, by default the insert and update methods in the Model will not actually save any data because it doesn't know what fields are -safe to be updated. Edit the **NewsModel** to provide it a list of updatable +safe to be updated. Edit the ``NewsModel`` to provide it a list of updatable fields in the ``$allowedFields`` property. .. literalinclude:: create_news_items/003.php @@ -146,19 +151,28 @@ never need to do that, since it is an auto-incrementing field in the database. This helps protect against Mass Assignment Vulnerabilities. If your model is handling your timestamps, you would also leave those out. -Routing -******* +Adding Routing Rules +******************** Before you can start adding news items into your CodeIgniter application you have to add an extra rule to **app/Config/Routes.php** file. Make sure your -file contains the following. This makes sure CodeIgniter sees ``create()`` -as a method instead of a news item's slug. You can read more about different -routing types in :doc:`../incoming/routing`. +file contains the following: .. literalinclude:: create_news_items/004.php +The route directive for ``'news/new'`` is placed before the directive for ``'news/(:segment)'`` to ensure that the form to create a news item is displayed. + +The ``$routes->post()`` line defines the router for a POST request. It matches +only a POST request to the URI path **/news**, and it maps to the ``create()`` method of +the ``News`` class. + +You can read more about different routing types in :ref:`defined-route-routing`. + +Create a News Item +****************** + Now point your browser to your local development environment where you -installed CodeIgniter and add ``/news/create`` to the URL. +installed CodeIgniter and add **/news/create** to the URL. Add some news and check out the different pages you made. .. image:: ../images/tutorial3.png @@ -176,9 +190,29 @@ Congratulations You just completed your first CodeIgniter4 application! -The image underneath shows your project's **app** folder, -with all of the files that you created in red. -The two modified configuration files (**Config/Routes.php** & **Config/Filters.php**) are not shown. - -.. image:: ../images/tutorial9.png - :align: left +The diagram underneath shows your project's **app** folder, with all of the +files that you created or modified. + +.. code-block:: none + + app/ + ├── Config + │   ├── Filters.php (Modified) + │   └── Routes.php (Modified) + ├── Controllers + │   ├── News.php + │   └── Pages.php + ├── Models + │   └── NewsModel.php + └── Views + ├── news + │   ├── create.php + │   ├── index.php + │   ├── success.php + │   └── view.php + ├── pages + │   ├── about.php + │   └── home.php + └── templates + ├── footer.php + └── header.php diff --git a/user_guide_src/source/tutorial/create_news_items/002.php b/user_guide_src/source/tutorial/create_news_items/002.php index be1ad6a54836..0b196dfa7245 100644 --- a/user_guide_src/source/tutorial/create_news_items/002.php +++ b/user_guide_src/source/tutorial/create_news_items/002.php @@ -3,46 +3,18 @@ namespace App\Controllers; use App\Models\NewsModel; +use CodeIgniter\Exceptions\PageNotFoundException; class News extends BaseController { // ... - public function create() + public function new() { helper('form'); - // Checks whether the form is submitted. - if (! $this->request->is('post')) { - // The form is not submitted, so returns the form. - return view('templates/header', ['title' => 'Create a news item']) - . view('news/create') - . view('templates/footer'); - } - - $post = $this->request->getPost(['title', 'body']); - - // Checks whether the submitted data passed the validation rules. - if (! $this->validateData($post, [ - 'title' => 'required|max_length[255]|min_length[3]', - 'body' => 'required|max_length[5000]|min_length[10]', - ])) { - // The validation fails, so returns the form. - return view('templates/header', ['title' => 'Create a news item']) - . view('news/create') - . view('templates/footer'); - } - - $model = model(NewsModel::class); - - $model->save([ - 'title' => $post['title'], - 'slug' => url_title($post['title'], '-', true), - 'body' => $post['body'], - ]); - return view('templates/header', ['title' => 'Create a news item']) - . view('news/success') + . view('news/create') . view('templates/footer'); } } diff --git a/user_guide_src/source/tutorial/create_news_items/004.php b/user_guide_src/source/tutorial/create_news_items/004.php index 3de06f181d29..6b04f3c66ffd 100644 --- a/user_guide_src/source/tutorial/create_news_items/004.php +++ b/user_guide_src/source/tutorial/create_news_items/004.php @@ -5,10 +5,10 @@ use App\Controllers\News; use App\Controllers\Pages; -$routes->match(['get', 'post'], 'news/create', [News::class, 'create']); -$routes->get('news/(:segment)', [News::class, 'view']); $routes->get('news', [News::class, 'index']); +$routes->get('news/new', [News::class, 'new']); // Add this line +$routes->post('news', [News::class, 'create']); // Add this line +$routes->get('news/(:segment)', [News::class, 'show']); + $routes->get('pages', [Pages::class, 'index']); $routes->get('(:segment)', [Pages::class, 'view']); - -// ... diff --git a/user_guide_src/source/tutorial/create_news_items/005.php b/user_guide_src/source/tutorial/create_news_items/005.php new file mode 100644 index 000000000000..6d565789141a --- /dev/null +++ b/user_guide_src/source/tutorial/create_news_items/005.php @@ -0,0 +1,40 @@ +validate([ + 'title' => 'required|max_length[255]|min_length[3]', + 'body' => 'required|max_length[5000]|min_length[10]', + ])) { + // The validation fails, so returns the form. + return $this->new(); + } + + // Gets the validated data. + $post = $this->validator->getValidated(); + + $model = model(NewsModel::class); + + $model->save([ + 'title' => $post['title'], + 'slug' => url_title($post['title'], '-', true), + 'body' => $post['body'], + ]); + + return view('templates/header', ['title' => 'Create a news item']) + . view('news/success') + . view('templates/footer'); + } +} diff --git a/user_guide_src/source/tutorial/create_news_items/006.php b/user_guide_src/source/tutorial/create_news_items/006.php new file mode 100644 index 000000000000..b19bbfe9b2c5 --- /dev/null +++ b/user_guide_src/source/tutorial/create_news_items/006.php @@ -0,0 +1,18 @@ +

+ +getFlashdata('error') ?> + + +
+ + + + +
+ + + +
+ + +
diff --git a/user_guide_src/source/tutorial/index.rst b/user_guide_src/source/tutorial/index.rst index 7675d0be9dc3..ce2ac3299ae7 100644 --- a/user_guide_src/source/tutorial/index.rst +++ b/user_guide_src/source/tutorial/index.rst @@ -78,7 +78,7 @@ Setting Development Mode By default, CodeIgniter starts up in production mode. This is a safety feature to keep your site a bit more secure in case settings are messed up once it is live. -So first let's fix that. Copy or rename the ``env`` file to ``.env``. Open it up. +So first let's fix that. Copy or rename the **env** file to **.env**. Open it up. This file contains server-specific settings. This means you never will need to commit any sensitive information to your version control system. It includes diff --git a/user_guide_src/source/tutorial/news_section.rst b/user_guide_src/source/tutorial/news_section.rst index 9fa090701f67..183b2f0c8570 100644 --- a/user_guide_src/source/tutorial/news_section.rst +++ b/user_guide_src/source/tutorial/news_section.rst @@ -51,7 +51,7 @@ The seed records might be something like:: Connect to Your Database ************************ -The local configuration file, ``.env``, that you created when you installed +The local configuration file, **.env**, that you created when you installed CodeIgniter, should have the database property settings uncommented and set appropriately for the database you want to use. Make sure you've configured your database properly as described in :doc:`../database/configuration`:: @@ -71,7 +71,10 @@ are the place where you retrieve, insert, and update information in your database or other data stores. They provide access to your data. You can read more about it in :doc:`../models/model`. -Open up the **app/Models/** directory and create a new file called +Create NewsModel +================ + +Open up the **app/Models** directory and create a new file called **NewsModel.php** and add the following code. .. literalinclude:: news_section/001.php @@ -81,6 +84,9 @@ creates a new model by extending ``CodeIgniter\Model`` and loads the database library. This will make the database class available through the ``$this->db`` object. +Add NewsModel::getNews() Method +=============================== + Now that the database and a model have been set up, you'll need a method to get all of our posts from our database. To do this, the database abstraction layer that is included with CodeIgniter - @@ -101,7 +107,7 @@ query; :doc:`Query Builder <../database/query_builder>` does this for you. The two methods used here, ``findAll()`` and ``first()``, are provided by the ``CodeIgniter\Model`` class. They already know the table to use based on the ``$table`` -property we set in **NewsModel** class, earlier. They are helper methods +property we set in ``NewsModel`` class, earlier. They are helper methods that use the Query Builder to run their commands on the current table, and returning an array of results in the format of your choice. In this example, ``findAll()`` returns an array of array. @@ -112,8 +118,12 @@ Display the News Now that the queries are written, the model should be tied to the views that are going to display the news items to the user. This could be done in our ``Pages`` controller created earlier, but for the sake of clarity, -a new ``News`` controller is defined. Create the new controller at -**app/Controllers/News.php**. +a new ``News`` controller is defined. + +Create News Controller +====================== + +Create the new controller at **app/Controllers/News.php**. .. literalinclude:: news_section/003.php @@ -126,7 +136,7 @@ access to the current ``Request`` and ``Response`` objects, as well as the Next, there are two methods, one to view all news items, and one for a specific news item. -Next, the :php:func:`model()` function is used to create the **NewsModel** instance. +Next, the :php:func:`model()` function is used to create the ``NewsModel`` instance. This is a helper function. You can read more about it in :doc:`../general/common_functions`. You could also write ``$model = new NewsModel();``, if you don't use it. @@ -134,6 +144,9 @@ You can see that the ``$slug`` variable is passed to the model's method in the second method. The model is using this slug to identify the news item to be returned. +Complete News::index() Method +============================= + Now the data is retrieved by the controller through our model, but nothing is displayed yet. The next thing to do is, passing this data to the views. Modify the ``index()`` method to look like this: @@ -143,8 +156,12 @@ the views. Modify the ``index()`` method to look like this: The code above gets all news records from the model and assigns it to a variable. The value for the title is also assigned to the ``$data['title']`` element and all data is passed to the views. You now need to create a -view to render the news items. Create **app/Views/news/index.php** -and add the next piece of code. +view to render the news items. + +Create news/index View File +=========================== + +Create **app/Views/news/index.php** and add the next piece of code. .. literalinclude:: news_section/005.php @@ -158,11 +175,14 @@ wrote our template in PHP mixed with HTML. If you prefer to use a template language, you can use CodeIgniter's :doc:`View Parser ` or a third party parser. +Complete News::show() Method +============================ + The news overview page is now done, but a page to display individual news items is still absent. The model created earlier is made in such a way that it can easily be used for this functionality. You only need to add some code to the controller and create a new view. Go back to the -``News`` controller and update the ``view()`` method with the following: +``News`` controller and update the ``show()`` method with the following: .. literalinclude:: news_section/006.php @@ -171,23 +191,27 @@ the ``PageNotFoundException`` class. Instead of calling the ``getNews()`` method without a parameter, the ``$slug`` variable is passed, so it will return the specific news item. + +Create news/view View File +========================== + The only thing left to do is create the corresponding view at **app/Views/news/view.php**. Put the following code in this file. .. literalinclude:: news_section/007.php -Routing -******* +Adding Routing Rules +******************** -Modify your routing file -(**app/Config/Routes.php**) so it looks as follows. -This makes sure the requests reach the ``News`` controller instead of -going directly to the ``Pages`` controller. The first line routes URI's -with a slug to the ``view()`` method in the ``News`` controller. +Modify your **app/Config/Routes.php** file, so it looks as follows: .. literalinclude:: news_section/008.php -Point your browser to your "news" page, i.e., ``localhost:8080/news``, +This makes sure the requests reach the ``News`` controller instead of +going directly to the ``Pages`` controller. The second ``$routes->get()`` line +routes URI's with a slug to the ``show()`` method in the ``News`` controller. + +Point your browser to your "news" page, i.e., **localhost:8080/news**, you should see a list of the news items, each of which has a link to display just the one article. diff --git a/user_guide_src/source/tutorial/news_section/003.php b/user_guide_src/source/tutorial/news_section/003.php index 13c3b7263db6..22b24577b01e 100644 --- a/user_guide_src/source/tutorial/news_section/003.php +++ b/user_guide_src/source/tutorial/news_section/003.php @@ -13,7 +13,7 @@ public function index() $data['news'] = $model->getNews(); } - public function view($slug = null) + public function show($slug = null) { $model = model(NewsModel::class); diff --git a/user_guide_src/source/tutorial/news_section/006.php b/user_guide_src/source/tutorial/news_section/006.php index 657211792f46..95e48aa77586 100644 --- a/user_guide_src/source/tutorial/news_section/006.php +++ b/user_guide_src/source/tutorial/news_section/006.php @@ -9,7 +9,7 @@ class News extends BaseController { // ... - public function view($slug = null) + public function show($slug = null) { $model = model(NewsModel::class); diff --git a/user_guide_src/source/tutorial/news_section/008.php b/user_guide_src/source/tutorial/news_section/008.php index 077efd42254d..df11598451a1 100644 --- a/user_guide_src/source/tutorial/news_section/008.php +++ b/user_guide_src/source/tutorial/news_section/008.php @@ -2,12 +2,11 @@ // ... -use App\Controllers\News; +use App\Controllers\News; // Add this line use App\Controllers\Pages; -$routes->get('news/(:segment)', [News::class, 'view']); -$routes->get('news', [News::class, 'index']); +$routes->get('news', [News::class, 'index']); // Add this line +$routes->get('news/(:segment)', [News::class, 'show']); // Add this line + $routes->get('pages', [Pages::class, 'index']); $routes->get('(:segment)', [Pages::class, 'view']); - -// ... diff --git a/user_guide_src/source/tutorial/static_pages.rst b/user_guide_src/source/tutorial/static_pages.rst index 76fd2fb11e56..9745cbb633de 100644 --- a/user_guide_src/source/tutorial/static_pages.rst +++ b/user_guide_src/source/tutorial/static_pages.rst @@ -16,6 +16,9 @@ It is the glue of your web application. Let's Make our First Controller ******************************* +Create Pages Controller +======================= + Create a file at **app/Controllers/Pages.php** with the following code. @@ -47,6 +50,9 @@ The **controller is what will become the center of every request** to your web application. Like any PHP class, you refer to it within your controllers as ``$this``. +Create Views +============ + Now that you've created your first method, it's time to make some basic page templates. We will be creating two "views" (page templates) that act as our page footer and header. @@ -80,15 +86,21 @@ includes the following code:: Adding Logic to the Controller ****************************** +Create home.php and about.php +============================= + Earlier you set up a controller with a ``view()`` method. The method -accepts one parameter, which is the name of the page to be loaded. The -static page bodies will be located in the **app/Views/pages/** -directory. +accepts one parameter, which is the name of the page to be loaded. + +The static page bodies will be located in the **app/Views/pages** directory. In that directory, create two files named **home.php** and **about.php**. Within those files, type some text - anything you'd like - and save them. If you like to be particularly un-original, try "Hello World!". +Complete Pages::view() Method +============================= + In order to load those pages, you'll have to check whether the requested page actually exists. This will be the body of the ``view()`` method in the ``Pages`` controller created above: @@ -129,24 +141,22 @@ view. throw errors on case-sensitive platforms. You can read more about it in :doc:`../outgoing/views`. -Routing -******* +Setting Routing Rules +********************* We have made the controller. The next thing is to set routing rules. Routing associates a URI with a controller's method. -Let's do that. Open the routing file located at -**app/Config/Routes.php** and look for the "Route Definitions" -section of the configuration file. +Let's do that. Open the routes file located at **app/Config/Routes.php**. -The only uncommented line there to start with should be: +The only route directive there to start with should be: .. literalinclude:: static_pages/003.php This directive says that any incoming request without any content specified should be handled by the ``index()`` method inside the ``Home`` controller. -Add the following lines, **after** the route directive for '/'. +Add the following lines, **after** the route directive for ``'/'``. .. literalinclude:: static_pages/004.php :lines: 2- @@ -161,7 +171,7 @@ arguments. More information about routing can be found in the :doc:`../incoming/routing`. Here, the second rule in the ``$routes`` object matches a GET request -to the URI path ``/pages``, and it maps to the ``index()`` method of the ``Pages`` class. +to the URI path **/pages**, and it maps to the ``index()`` method of the ``Pages`` class. The third rule in the ``$routes`` object matches a GET request to a URI segment using the placeholder ``(:segment)``, and passes the parameter to the @@ -171,8 +181,8 @@ Running the App *************** Ready to test? You cannot run the app using PHP's built-in server, -since it will not properly process the ``.htaccess`` rules that are provided in -``public``, and which eliminate the need to specify "**index.php/**" +since it will not properly process the **.htaccess** rules that are provided in +**public**, and which eliminate the need to specify "**index.php/**" as part of a URL. CodeIgniter has its own command that you can use though. From the command line, at the root of your project: @@ -182,9 +192,9 @@ From the command line, at the root of your project: php spark serve will start a web server, accessible on port 8080. If you set the location field -in your browser to ``localhost:8080``, you should see the CodeIgniter welcome page. +in your browser to **localhost:8080**, you should see the CodeIgniter welcome page. -Now visit ``localhost:8080/home``. Did it get routed correctly to the ``view()`` +Now visit **localhost:8080/home**. Did it get routed correctly to the ``view()`` method in the ``Pages`` controller? Awesome! You should see something like the following: diff --git a/user_guide_src/source/tutorial/static_pages/003.php b/user_guide_src/source/tutorial/static_pages/003.php index 956c097d390f..fc4914a6923b 100644 --- a/user_guide_src/source/tutorial/static_pages/003.php +++ b/user_guide_src/source/tutorial/static_pages/003.php @@ -1,9 +1,8 @@ get('/', 'Home::index'); - -// ...