diff --git a/.gitignore b/.gitignore
index edc7bd8..762ce68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,125 +1,7 @@
-#-------------------------
-# Operating Specific Junk Files
-#-------------------------
-
-# OS X
-.DS_Store
-.AppleDouble
-.LSOverride
-
-# OS X Thumbnails
-._*
-
-# Windows image file caches
-Thumbs.db
-ehthumbs.db
-Desktop.ini
-
-# Recycle Bin used on file shares
-$RECYCLE.BIN/
-
-# Windows Installer files
-*.cab
-*.msi
-*.msm
-*.msp
-
-# Windows shortcuts
-*.lnk
-
-# Linux
-*~
-
-# KDE directory preferences
-.directory
-
-# Linux trash folder which might appear on any partition or disk
-.Trash-*
-
-#-------------------------
-# Environment Files
-#-------------------------
-# These should never be under version control,
-# as it poses a security risk.
-.vagrant
-Vagrantfile
-
-#-------------------------
-# Temporary Files
-#-------------------------
-writable/cache/*
-!writable/cache/index.html
-
-writable/logs/*
-!writable/logs/index.html
-
-writable/session/*
-!writable/session/index.html
-
-writable/uploads/*
-!writable/uploads/index.html
-
-writable/debugbar/*
-!writable/debugbar/.gitkeep
-
-php_errors.log
-
-#-------------------------
-# User Guide Temp Files
-#-------------------------
-user_guide_src/build/*
-user_guide_src/cilexer/build/*
-user_guide_src/cilexer/dist/*
-user_guide_src/cilexer/pycilexer.egg-info/*
-
-#-------------------------
-# Test Files
-#-------------------------
-tests/coverage*
-
-# Don't save phpunit under version control.
-phpunit
-
-#-------------------------
-# Composer
-#-------------------------
-vendor/
-
-#-------------------------
-# IDE / Development Files
-#-------------------------
-
-# Modules Testing
-_modules/*
-
-# phpenv local config
-.php-version
-
-# Jetbrains editors (PHPStorm, etc)
-.idea/
-*.iml
-
-# Netbeans
-nbproject/
-build/
-nbbuild/
-dist/
-nbdist/
-nbactions.xml
-nb-configuration.xml
-.nb-gradle/
-
-# Sublime Text
-*.tmlanguage.cache
-*.tmPreferences.cache
-*.stTheme.cache
-*.sublime-workspace
-*.sublime-project
-.phpintel
-/api/
-
-# Visual Studio Code
-.vscode/
-
-/results/
-/phpunit*.xml
+apache_log
+apache_log/*
+apache_log/error.log
+writable
+writable*
+.idea
+vendor/*
diff --git a/system/.htaccess b/system/.htaccess
new file mode 100644
index 0000000..3462048
--- /dev/null
+++ b/system/.htaccess
@@ -0,0 +1,6 @@
+
+ Require all denied
+
+
+ Deny from all
+
diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php
new file mode 100644
index 0000000..01ce428
--- /dev/null
+++ b/system/API/ResponseTrait.php
@@ -0,0 +1,374 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\API;
+
+use CodeIgniter\Format\FormatterInterface;
+use CodeIgniter\HTTP\IncomingRequest;
+use CodeIgniter\HTTP\ResponseInterface;
+
+/**
+ * Provides common, more readable, methods to provide
+ * consistent HTTP responses under a variety of common
+ * situations when working as an API.
+ *
+ * @property bool $stringAsHtml Whether to treat string data as HTML in JSON response.
+ * Setting `true` is only for backward compatibility.
+ */
+trait ResponseTrait
+{
+ /**
+ * Allows child classes to override the
+ * status code that is used in their API.
+ *
+ * @var array
+ */
+ protected $codes = [
+ 'created' => 201,
+ 'deleted' => 200,
+ 'updated' => 200,
+ 'no_content' => 204,
+ 'invalid_request' => 400,
+ 'unsupported_response_type' => 400,
+ 'invalid_scope' => 400,
+ 'temporarily_unavailable' => 400,
+ 'invalid_grant' => 400,
+ 'invalid_credentials' => 400,
+ 'invalid_refresh' => 400,
+ 'no_data' => 400,
+ 'invalid_data' => 400,
+ 'access_denied' => 401,
+ 'unauthorized' => 401,
+ 'invalid_client' => 401,
+ 'forbidden' => 403,
+ 'resource_not_found' => 404,
+ 'not_acceptable' => 406,
+ 'resource_exists' => 409,
+ 'conflict' => 409,
+ 'resource_gone' => 410,
+ 'payload_too_large' => 413,
+ 'unsupported_media_type' => 415,
+ 'too_many_requests' => 429,
+ 'server_error' => 500,
+ 'unsupported_grant_type' => 501,
+ 'not_implemented' => 501,
+ ];
+
+ /**
+ * How to format the response data.
+ * Either 'json' or 'xml'. If null is set, it will be determined through
+ * content negotiation.
+ *
+ * @var string|null
+ * @phpstan-var 'html'|'json'|'xml'|null
+ */
+ protected $format = 'json';
+
+ /**
+ * Current Formatter instance. This is usually set by ResponseTrait::format
+ *
+ * @var FormatterInterface|null
+ */
+ protected $formatter;
+
+ /**
+ * Provides a single, simple method to return an API response, formatted
+ * to match the requested format, with proper content-type and status code.
+ *
+ * @param array|string|null $data
+ *
+ * @return ResponseInterface
+ */
+ protected function respond($data = null, ?int $status = null, string $message = '')
+ {
+ if ($data === null && $status === null) {
+ $status = 404;
+ $output = null;
+ $this->format($data);
+ } elseif ($data === null && is_numeric($status)) {
+ $output = null;
+ $this->format($data);
+ } else {
+ $status ??= 200;
+ $output = $this->format($data);
+ }
+
+ if ($output !== null) {
+ if ($this->format === 'json') {
+ return $this->response->setJSON($output)->setStatusCode($status, $message);
+ }
+
+ if ($this->format === 'xml') {
+ return $this->response->setXML($output)->setStatusCode($status, $message);
+ }
+ }
+
+ return $this->response->setBody($output)->setStatusCode($status, $message);
+ }
+
+ /**
+ * Used for generic failures that no custom methods exist for.
+ *
+ * @param array|string $messages
+ * @param int $status HTTP status code
+ * @param string|null $code Custom, API-specific, error code
+ *
+ * @return ResponseInterface
+ */
+ protected function fail($messages, int $status = 400, ?string $code = null, string $customMessage = '')
+ {
+ if (! is_array($messages)) {
+ $messages = ['error' => $messages];
+ }
+
+ $response = [
+ 'status' => $status,
+ 'error' => $code ?? $status,
+ 'messages' => $messages,
+ ];
+
+ return $this->respond($response, $status, $customMessage);
+ }
+
+ // --------------------------------------------------------------------
+ // Response Helpers
+ // --------------------------------------------------------------------
+
+ /**
+ * Used after successfully creating a new resource.
+ *
+ * @param array|string|null $data
+ *
+ * @return ResponseInterface
+ */
+ protected function respondCreated($data = null, string $message = '')
+ {
+ return $this->respond($data, $this->codes['created'], $message);
+ }
+
+ /**
+ * Used after a resource has been successfully deleted.
+ *
+ * @param array|string|null $data
+ *
+ * @return ResponseInterface
+ */
+ protected function respondDeleted($data = null, string $message = '')
+ {
+ return $this->respond($data, $this->codes['deleted'], $message);
+ }
+
+ /**
+ * Used after a resource has been successfully updated.
+ *
+ * @param array|string|null $data
+ *
+ * @return ResponseInterface
+ */
+ protected function respondUpdated($data = null, string $message = '')
+ {
+ return $this->respond($data, $this->codes['updated'], $message);
+ }
+
+ /**
+ * Used after a command has been successfully executed but there is no
+ * meaningful reply to send back to the client.
+ *
+ * @return ResponseInterface
+ */
+ protected function respondNoContent(string $message = 'No Content')
+ {
+ return $this->respond(null, $this->codes['no_content'], $message);
+ }
+
+ /**
+ * Used when the client is either didn't send authorization information,
+ * or had bad authorization credentials. User is encouraged to try again
+ * with the proper information.
+ *
+ * @return ResponseInterface
+ */
+ protected function failUnauthorized(string $description = 'Unauthorized', ?string $code = null, string $message = '')
+ {
+ return $this->fail($description, $this->codes['unauthorized'], $code, $message);
+ }
+
+ /**
+ * Used when access is always denied to this resource and no amount
+ * of trying again will help.
+ *
+ * @return ResponseInterface
+ */
+ protected function failForbidden(string $description = 'Forbidden', ?string $code = null, string $message = '')
+ {
+ return $this->fail($description, $this->codes['forbidden'], $code, $message);
+ }
+
+ /**
+ * Used when a specified resource cannot be found.
+ *
+ * @return ResponseInterface
+ */
+ protected function failNotFound(string $description = 'Not Found', ?string $code = null, string $message = '')
+ {
+ return $this->fail($description, $this->codes['resource_not_found'], $code, $message);
+ }
+
+ /**
+ * Used when the data provided by the client cannot be validated.
+ *
+ * @return ResponseInterface
+ *
+ * @deprecated Use failValidationErrors instead
+ */
+ protected function failValidationError(string $description = 'Bad Request', ?string $code = null, string $message = '')
+ {
+ return $this->fail($description, $this->codes['invalid_data'], $code, $message);
+ }
+
+ /**
+ * Used when the data provided by the client cannot be validated on one or more fields.
+ *
+ * @param list|string $errors
+ *
+ * @return ResponseInterface
+ */
+ protected function failValidationErrors($errors, ?string $code = null, string $message = '')
+ {
+ return $this->fail($errors, $this->codes['invalid_data'], $code, $message);
+ }
+
+ /**
+ * Use when trying to create a new resource and it already exists.
+ *
+ * @return ResponseInterface
+ */
+ protected function failResourceExists(string $description = 'Conflict', ?string $code = null, string $message = '')
+ {
+ return $this->fail($description, $this->codes['resource_exists'], $code, $message);
+ }
+
+ /**
+ * Use when a resource was previously deleted. This is different than
+ * Not Found, because here we know the data previously existed, but is now gone,
+ * where Not Found means we simply cannot find any information about it.
+ *
+ * @return ResponseInterface
+ */
+ protected function failResourceGone(string $description = 'Gone', ?string $code = null, string $message = '')
+ {
+ return $this->fail($description, $this->codes['resource_gone'], $code, $message);
+ }
+
+ /**
+ * Used when the user has made too many requests for the resource recently.
+ *
+ * @return ResponseInterface
+ */
+ protected function failTooManyRequests(string $description = 'Too Many Requests', ?string $code = null, string $message = '')
+ {
+ return $this->fail($description, $this->codes['too_many_requests'], $code, $message);
+ }
+
+ /**
+ * Used when there is a server error.
+ *
+ * @param string $description The error message to show the user.
+ * @param string|null $code A custom, API-specific, error code.
+ * @param string $message A custom "reason" message to return.
+ */
+ protected function failServerError(string $description = 'Internal Server Error', ?string $code = null, string $message = ''): ResponseInterface
+ {
+ return $this->fail($description, $this->codes['server_error'], $code, $message);
+ }
+
+ // --------------------------------------------------------------------
+ // Utility Methods
+ // --------------------------------------------------------------------
+
+ /**
+ * Handles formatting a response. Currently, makes some heavy assumptions
+ * and needs updating! :)
+ *
+ * @param array|string|null $data
+ *
+ * @return string|null
+ */
+ protected function format($data = null)
+ {
+ $format = service('format');
+
+ $mime = ($this->format === null) ? $format->getConfig()->supportedResponseFormats[0]
+ : "application/{$this->format}";
+
+ // Determine correct response type through content negotiation if not explicitly declared
+ if (
+ ! in_array($this->format, ['json', 'xml'], true)
+ && $this->request instanceof IncomingRequest
+ ) {
+ $mime = $this->request->negotiate(
+ 'media',
+ $format->getConfig()->supportedResponseFormats,
+ false
+ );
+ }
+
+ $this->response->setContentType($mime);
+
+ // if we don't have a formatter, make one
+ if (! isset($this->formatter)) {
+ // if no formatter, use the default
+ $this->formatter = $format->getFormatter($mime);
+ }
+
+ $asHtml = $this->stringAsHtml ?? false;
+
+ // Returns as HTML.
+ if (
+ ($mime === 'application/json' && $asHtml && is_string($data))
+ || ($mime !== 'application/json' && is_string($data))
+ ) {
+ // The content type should be text/... and not application/...
+ $contentType = $this->response->getHeaderLine('Content-Type');
+ $contentType = str_replace('application/json', 'text/html', $contentType);
+ $contentType = str_replace('application/', 'text/', $contentType);
+ $this->response->setContentType($contentType);
+ $this->format = 'html';
+
+ return $data;
+ }
+
+ if ($mime !== 'application/json') {
+ // Recursively convert objects into associative arrays
+ // Conversion not required for JSONFormatter
+ $data = json_decode(json_encode($data), true);
+ }
+
+ return $this->formatter->format($data);
+ }
+
+ /**
+ * Sets the format the response should be in.
+ *
+ * @param string|null $format Response format
+ * @phpstan-param 'json'|'xml' $format
+ *
+ * @return $this
+ */
+ protected function setResponseFormat(?string $format = null)
+ {
+ $this->format = ($format === null) ? null : strtolower($format);
+
+ return $this;
+ }
+}
diff --git a/system/Autoloader/Autoloader.php b/system/Autoloader/Autoloader.php
new file mode 100644
index 0000000..28c183f
--- /dev/null
+++ b/system/Autoloader/Autoloader.php
@@ -0,0 +1,561 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Autoloader;
+
+use CodeIgniter\Exceptions\ConfigException;
+use Composer\Autoload\ClassLoader;
+use Composer\InstalledVersions;
+use Config\Autoload;
+use Config\Kint as KintConfig;
+use Config\Modules;
+use Config\Services;
+use InvalidArgumentException;
+use Kint;
+use Kint\Renderer\CliRenderer;
+use Kint\Renderer\RichRenderer;
+use RuntimeException;
+
+/**
+ * An autoloader that uses both PSR4 autoloading, and traditional classmaps.
+ *
+ * Given a foo-bar package of classes in the file system at the following paths:
+ * ```
+ * /path/to/packages/foo-bar/
+ * /src
+ * Baz.php # Foo\Bar\Baz
+ * Qux/
+ * Quux.php # Foo\Bar\Qux\Quux
+ * ```
+ * you can add the path to the configuration array that is passed in the constructor.
+ * The Config array consists of 2 primary keys, both of which are associative arrays:
+ * 'psr4', and 'classmap'.
+ * ```
+ * $Config = [
+ * 'psr4' => [
+ * 'Foo\Bar' => '/path/to/packages/foo-bar'
+ * ],
+ * 'classmap' => [
+ * 'MyClass' => '/path/to/class/file.php'
+ * ]
+ * ];
+ * ```
+ * Example:
+ * ```
+ * register();
+ * ```
+ *
+ * @see \CodeIgniter\Autoloader\AutoloaderTest
+ */
+class Autoloader
+{
+ /**
+ * Stores namespaces as key, and path as values.
+ *
+ * @var array>
+ */
+ protected $prefixes = [];
+
+ /**
+ * Stores class name as key, and path as values.
+ *
+ * @var array
+ */
+ protected $classmap = [];
+
+ /**
+ * Stores files as a list.
+ *
+ * @var list
+ */
+ protected $files = [];
+
+ /**
+ * Stores helper list.
+ * Always load the URL helper, it should be used in most apps.
+ *
+ * @var list
+ */
+ protected $helpers = ['url'];
+
+ /**
+ * Reads in the configuration array (described above) and stores
+ * the valid parts that we'll need.
+ *
+ * @return $this
+ */
+ public function initialize(Autoload $config, Modules $modules)
+ {
+ $this->prefixes = [];
+ $this->classmap = [];
+ $this->files = [];
+
+ // We have to have one or the other, though we don't enforce the need
+ // to have both present in order to work.
+ if ($config->psr4 === [] && $config->classmap === []) {
+ throw new InvalidArgumentException('Config array must contain either the \'psr4\' key or the \'classmap\' key.');
+ }
+
+ if ($config->psr4 !== []) {
+ $this->addNamespace($config->psr4);
+ }
+
+ if ($config->classmap !== []) {
+ $this->classmap = $config->classmap;
+ }
+
+ if ($config->files !== []) {
+ $this->files = $config->files;
+ }
+
+ if (isset($config->helpers)) {
+ $this->helpers = [...$this->helpers, ...$config->helpers];
+ }
+
+ if (is_file(COMPOSER_PATH)) {
+ $this->loadComposerAutoloader($modules);
+ }
+
+ return $this;
+ }
+
+ private function loadComposerAutoloader(Modules $modules): void
+ {
+ // The path to the vendor directory.
+ // We do not want to enforce this, so set the constant if Composer was used.
+ if (! defined('VENDORPATH')) {
+ define('VENDORPATH', dirname(COMPOSER_PATH) . DIRECTORY_SEPARATOR);
+ }
+
+ /** @var ClassLoader $composer */
+ $composer = include COMPOSER_PATH;
+
+ // Should we load through Composer's namespaces, also?
+ if ($modules->discoverInComposer) {
+ // @phpstan-ignore-next-line
+ $this->loadComposerNamespaces($composer, $modules->composerPackages ?? []);
+ }
+
+ unset($composer);
+ }
+
+ /**
+ * Register the loader with the SPL autoloader stack.
+ *
+ * @return void
+ */
+ public function register()
+ {
+ // Register classmap loader for the files in our class map.
+ spl_autoload_register($this->loadClassmap(...), true);
+
+ // Register the PSR-4 autoloader.
+ spl_autoload_register($this->loadClass(...), true);
+
+ // Load our non-class files
+ foreach ($this->files as $file) {
+ $this->includeFile($file);
+ }
+ }
+
+ /**
+ * Unregister autoloader.
+ *
+ * This method is for testing.
+ */
+ public function unregister(): void
+ {
+ spl_autoload_unregister($this->loadClass(...));
+ spl_autoload_unregister($this->loadClassmap(...));
+ }
+
+ /**
+ * Registers namespaces with the autoloader.
+ *
+ * @param array|string>|string $namespace
+ *
+ * @return $this
+ */
+ public function addNamespace($namespace, ?string $path = null)
+ {
+ if (is_array($namespace)) {
+ foreach ($namespace as $prefix => $namespacedPath) {
+ $prefix = trim($prefix, '\\');
+
+ if (is_array($namespacedPath)) {
+ foreach ($namespacedPath as $dir) {
+ $this->prefixes[$prefix][] = rtrim($dir, '\\/') . DIRECTORY_SEPARATOR;
+ }
+
+ continue;
+ }
+
+ $this->prefixes[$prefix][] = rtrim($namespacedPath, '\\/') . DIRECTORY_SEPARATOR;
+ }
+ } else {
+ $this->prefixes[trim($namespace, '\\')][] = rtrim($path, '\\/') . DIRECTORY_SEPARATOR;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get namespaces with prefixes as keys and paths as values.
+ *
+ * If a prefix param is set, returns only paths to the given prefix.
+ *
+ * @return array>|list
+ * @phpstan-return ($prefix is null ? array> : list)
+ */
+ public function getNamespace(?string $prefix = null)
+ {
+ if ($prefix === null) {
+ return $this->prefixes;
+ }
+
+ return $this->prefixes[trim($prefix, '\\')] ?? [];
+ }
+
+ /**
+ * Removes a single namespace from the psr4 settings.
+ *
+ * @return $this
+ */
+ public function removeNamespace(string $namespace)
+ {
+ if (isset($this->prefixes[trim($namespace, '\\')])) {
+ unset($this->prefixes[trim($namespace, '\\')]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Load a class using available class mapping.
+ *
+ * @internal For `spl_autoload_register` use.
+ */
+ public function loadClassmap(string $class): void
+ {
+ $file = $this->classmap[$class] ?? '';
+
+ if (is_string($file) && $file !== '') {
+ $this->includeFile($file);
+ }
+ }
+
+ /**
+ * Loads the class file for a given class name.
+ *
+ * @internal For `spl_autoload_register` use.
+ *
+ * @param string $class The fully qualified class name.
+ */
+ public function loadClass(string $class): void
+ {
+ $this->loadInNamespace($class);
+ }
+
+ /**
+ * Loads the class file for a given class name.
+ *
+ * @param string $class The fully-qualified class name
+ *
+ * @return false|string The mapped file name on success, or boolean false on fail
+ */
+ protected function loadInNamespace(string $class)
+ {
+ if (! str_contains($class, '\\')) {
+ return false;
+ }
+
+ foreach ($this->prefixes as $namespace => $directories) {
+ if (str_starts_with($class, $namespace)) {
+ $relativeClassPath = str_replace('\\', DIRECTORY_SEPARATOR, substr($class, strlen($namespace)));
+
+ foreach ($directories as $directory) {
+ $directory = rtrim($directory, '\\/');
+
+ $filePath = $directory . $relativeClassPath . '.php';
+ $filename = $this->includeFile($filePath);
+
+ if ($filename) {
+ return $filename;
+ }
+ }
+ }
+ }
+
+ // never found a mapped file
+ return false;
+ }
+
+ /**
+ * A central way to include a file. Split out primarily for testing purposes.
+ *
+ * @return false|string The filename on success, false if the file is not loaded
+ */
+ protected function includeFile(string $file)
+ {
+ if (is_file($file)) {
+ include_once $file;
+
+ return $file;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check file path.
+ *
+ * Checks special characters that are illegal in filenames on certain
+ * operating systems and special characters requiring special escaping
+ * to manipulate at the command line. Replaces spaces and consecutive
+ * dashes with a single dash. Trim period, dash and underscore from beginning
+ * 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
+ {
+ // Only allow characters deemed safe for POSIX portable filenames.
+ // Plus the forward slash for directory separators since this might be a path.
+ // http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_278
+ // Modified to allow backslash and colons for on Windows machines.
+ $result = preg_match_all('/[^0-9\p{L}\s\/\-_.:\\\\]/u', $filename, $matches);
+
+ if ($result > 0) {
+ $chars = implode('', $matches[0]);
+
+ throw new InvalidArgumentException(
+ 'The file path contains special characters "' . $chars
+ . '" that are not allowed: "' . $filename . '"'
+ );
+ }
+ if ($result === false) {
+ $message = preg_last_error_msg();
+
+ throw new RuntimeException($message . '. filename: "' . $filename . '"');
+ }
+
+ // Clean up our filename edges.
+ $cleanFilename = trim($filename, '.-_');
+
+ if ($filename !== $cleanFilename) {
+ throw new InvalidArgumentException('The characters ".-_" are not allowed in filename edges: "' . $filename . '"');
+ }
+
+ return $cleanFilename;
+ }
+
+ private function loadComposerNamespaces(ClassLoader $composer, array $composerPackages): void
+ {
+ $namespacePaths = $composer->getPrefixesPsr4();
+
+ // Get rid of duplicated namespaces.
+ $duplicatedNamespaces = ['CodeIgniter', APP_NAMESPACE, 'Config'];
+
+ foreach ($duplicatedNamespaces as $ns) {
+ if (isset($namespacePaths[$ns . '\\'])) {
+ unset($namespacePaths[$ns . '\\']);
+ }
+ }
+
+ if (! method_exists(InstalledVersions::class, 'getAllRawData')) {
+ throw new RuntimeException(
+ 'Your Composer version is too old.'
+ . ' Please update Composer (run `composer self-update`) to v2.0.14 or later'
+ . ' and remove your vendor/ directory, and run `composer update`.'
+ );
+ }
+ // This method requires Composer 2.0.14 or later.
+ $allData = InstalledVersions::getAllRawData();
+ $packageList = [];
+
+ foreach ($allData as $list) {
+ $packageList = array_merge($packageList, $list['versions']);
+ }
+
+ // Check config for $composerPackages.
+ $only = $composerPackages['only'] ?? [];
+ $exclude = $composerPackages['exclude'] ?? [];
+ if ($only !== [] && $exclude !== []) {
+ throw new ConfigException('Cannot use "only" and "exclude" at the same time in "Config\Modules::$composerPackages".');
+ }
+
+ // Get install paths of packages to add namespace for auto-discovery.
+ $installPaths = [];
+ if ($only !== []) {
+ foreach ($packageList as $packageName => $data) {
+ if (in_array($packageName, $only, true) && isset($data['install_path'])) {
+ $installPaths[] = $data['install_path'];
+ }
+ }
+ } else {
+ foreach ($packageList as $packageName => $data) {
+ if (! in_array($packageName, $exclude, true) && isset($data['install_path'])) {
+ $installPaths[] = $data['install_path'];
+ }
+ }
+ }
+
+ $newPaths = [];
+
+ foreach ($namespacePaths as $namespace => $srcPaths) {
+ $add = false;
+
+ foreach ($srcPaths as $path) {
+ foreach ($installPaths as $installPath) {
+ if (str_starts_with($path, $installPath)) {
+ $add = true;
+ break 2;
+ }
+ }
+ }
+
+ if ($add) {
+ // Composer stores namespaces with trailing slash. We don't.
+ $newPaths[rtrim($namespace, '\\ ')] = $srcPaths;
+ }
+ }
+
+ $this->addNamespace($newPaths);
+ }
+
+ /**
+ * Locates autoload information from Composer, if available.
+ *
+ * @deprecated No longer used.
+ *
+ * @return void
+ */
+ protected function discoverComposerNamespaces()
+ {
+ if (! is_file(COMPOSER_PATH)) {
+ return;
+ }
+
+ /**
+ * @var ClassLoader $composer
+ */
+ $composer = include COMPOSER_PATH;
+ $paths = $composer->getPrefixesPsr4();
+ $classes = $composer->getClassMap();
+
+ unset($composer);
+
+ // Get rid of CodeIgniter so we don't have duplicates
+ if (isset($paths['CodeIgniter\\'])) {
+ unset($paths['CodeIgniter\\']);
+ }
+
+ $newPaths = [];
+
+ foreach ($paths as $key => $value) {
+ // Composer stores namespaces with trailing slash. We don't.
+ $newPaths[rtrim($key, '\\ ')] = $value;
+ }
+
+ $this->prefixes = array_merge($this->prefixes, $newPaths);
+ $this->classmap = array_merge($this->classmap, $classes);
+ }
+
+ /**
+ * Loads helpers
+ */
+ public function loadHelpers(): void
+ {
+ helper($this->helpers);
+ }
+
+ /**
+ * Initializes Kint
+ */
+ public function initializeKint(bool $debug = false): void
+ {
+ if ($debug) {
+ $this->autoloadKint();
+ $this->configureKint();
+ } elseif (class_exists(Kint::class)) {
+ // In case that Kint is already loaded via Composer.
+ Kint::$enabled_mode = false;
+ }
+
+ helper('kint');
+ }
+
+ private function autoloadKint(): void
+ {
+ // If we have KINT_DIR it means it's already loaded via composer
+ if (! defined('KINT_DIR')) {
+ spl_autoload_register(function ($class): void {
+ $class = explode('\\', $class);
+
+ if (array_shift($class) !== 'Kint') {
+ return;
+ }
+
+ $file = SYSTEMPATH . 'ThirdParty/Kint/' . implode('/', $class) . '.php';
+
+ if (is_file($file)) {
+ require_once $file;
+ }
+ });
+
+ require_once SYSTEMPATH . 'ThirdParty/Kint/init.php';
+ }
+ }
+
+ private function configureKint(): void
+ {
+ $config = new KintConfig();
+
+ Kint::$depth_limit = $config->maxDepth;
+ Kint::$display_called_from = $config->displayCalledFrom;
+ Kint::$expanded = $config->expanded;
+
+ if (isset($config->plugins) && is_array($config->plugins)) {
+ Kint::$plugins = $config->plugins;
+ }
+
+ $csp = Services::csp();
+ if ($csp->enabled()) {
+ RichRenderer::$js_nonce = $csp->getScriptNonce();
+ RichRenderer::$css_nonce = $csp->getStyleNonce();
+ }
+
+ RichRenderer::$theme = $config->richTheme;
+ RichRenderer::$folder = $config->richFolder;
+ RichRenderer::$sort = $config->richSort;
+ if (isset($config->richObjectPlugins) && is_array($config->richObjectPlugins)) {
+ RichRenderer::$value_plugins = $config->richObjectPlugins;
+ }
+ if (isset($config->richTabPlugins) && is_array($config->richTabPlugins)) {
+ RichRenderer::$tab_plugins = $config->richTabPlugins;
+ }
+
+ CliRenderer::$cli_colors = $config->cliColors;
+ CliRenderer::$force_utf8 = $config->cliForceUTF8;
+ CliRenderer::$detect_width = $config->cliDetectWidth;
+ CliRenderer::$min_terminal_width = $config->cliMinWidth;
+ }
+}
diff --git a/system/Autoloader/FileLocator.php b/system/Autoloader/FileLocator.php
new file mode 100644
index 0000000..14b9a13
--- /dev/null
+++ b/system/Autoloader/FileLocator.php
@@ -0,0 +1,404 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Autoloader;
+
+/**
+ * Allows loading non-class files in a namespaced manner.
+ * Works with Helpers, Views, etc.
+ *
+ * @see \CodeIgniter\Autoloader\FileLocatorTest
+ */
+class FileLocator implements FileLocatorInterface
+{
+ /**
+ * The Autoloader to use.
+ *
+ * @var Autoloader
+ */
+ protected $autoloader;
+
+ /**
+ * List of classnames that did not exist.
+ *
+ * @var list
+ */
+ private array $invalidClassnames = [];
+
+ public function __construct(Autoloader $autoloader)
+ {
+ $this->autoloader = $autoloader;
+ }
+
+ /**
+ * Attempts to locate a file by examining the name for a namespace
+ * and looking through the PSR-4 namespaced files that we know about.
+ *
+ * @param string $file The relative file path or namespaced file to
+ * locate. If not namespaced, search in the app
+ * folder.
+ * @param non-empty-string|null $folder The folder within the namespace that we should
+ * look for the file. If $file does not contain
+ * this value, it will be appended to the namespace
+ * folder.
+ * @param string $ext The file extension the file should have.
+ *
+ * @return false|string The path to the file, or false if not found.
+ */
+ public function locateFile(string $file, ?string $folder = null, string $ext = 'php')
+ {
+ $file = $this->ensureExt($file, $ext);
+
+ // Clears the folder name if it is at the beginning of the filename
+ if ($folder !== null && str_starts_with($file, $folder)) {
+ $file = substr($file, strlen($folder . '/'));
+ }
+
+ // Is not namespaced? Try the application folder.
+ if (! str_contains($file, '\\')) {
+ return $this->legacyLocate($file, $folder);
+ }
+
+ // Standardize slashes to handle nested directories.
+ $file = strtr($file, '/', '\\');
+ $file = ltrim($file, '\\');
+
+ $segments = explode('\\', $file);
+
+ // The first segment will be empty if a slash started the filename.
+ if ($segments[0] === '') {
+ unset($segments[0]);
+ }
+
+ $paths = [];
+ $filename = '';
+
+ // Namespaces always comes with arrays of paths
+ $namespaces = $this->autoloader->getNamespace();
+
+ foreach (array_keys($namespaces) as $namespace) {
+ if (substr($file, 0, strlen($namespace) + 1) === $namespace . '\\') {
+ $fileWithoutNamespace = substr($file, strlen($namespace));
+
+ // There may be sub-namespaces of the same vendor,
+ // so overwrite them with namespaces found later.
+ $paths = $namespaces[$namespace];
+ $filename = ltrim(str_replace('\\', '/', $fileWithoutNamespace), '/');
+ }
+ }
+
+ // if no namespaces matched then quit
+ if ($paths === []) {
+ return false;
+ }
+
+ // Check each path in the namespace
+ foreach ($paths as $path) {
+ // Ensure trailing slash
+ $path = rtrim($path, '/') . '/';
+
+ // If we have a folder name, then the calling function
+ // expects this file to be within that folder, like 'Views',
+ // or 'libraries'.
+ if ($folder !== null && ! str_contains($path . $filename, '/' . $folder . '/')) {
+ $path .= trim($folder, '/') . '/';
+ }
+
+ $path .= $filename;
+ if (is_file($path)) {
+ return $path;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Examines a file and returns the fully qualified class name.
+ */
+ public function getClassname(string $file): string
+ {
+ if (is_dir($file)) {
+ return '';
+ }
+
+ $php = file_get_contents($file);
+ $tokens = token_get_all($php);
+ $dlm = false;
+ $namespace = '';
+ $className = '';
+
+ foreach ($tokens as $i => $token) {
+ if ($i < 2) {
+ continue;
+ }
+
+ if ((isset($tokens[$i - 2][1]) && ($tokens[$i - 2][1] === 'phpnamespace' || $tokens[$i - 2][1] === 'namespace')) || ($dlm && $tokens[$i - 1][0] === T_NS_SEPARATOR && $token[0] === T_STRING)) {
+ if (! $dlm) {
+ $namespace = 0;
+ }
+ if (isset($token[1])) {
+ $namespace = $namespace ? $namespace . '\\' . $token[1] : $token[1];
+ $dlm = true;
+ }
+ } elseif ($dlm && ($token[0] !== T_NS_SEPARATOR) && ($token[0] !== T_STRING)) {
+ $dlm = false;
+ }
+
+ if (($tokens[$i - 2][0] === T_CLASS || (isset($tokens[$i - 2][1]) && $tokens[$i - 2][1] === 'phpclass'))
+ && $tokens[$i - 1][0] === T_WHITESPACE
+ && $token[0] === T_STRING) {
+ $className = $token[1];
+ break;
+ }
+ }
+
+ if ($className === '') {
+ return '';
+ }
+
+ return $namespace . '\\' . $className;
+ }
+
+ /**
+ * Searches through all of the defined namespaces looking for a file.
+ * Returns an array of all found locations for the defined file.
+ *
+ * Example:
+ *
+ * $locator->search('Config/Routes.php');
+ * // Assuming PSR4 namespaces include foo and bar, might return:
+ * [
+ * 'app/Modules/foo/Config/Routes.php',
+ * 'app/Modules/bar/Config/Routes.php',
+ * ]
+ *
+ * @return list
+ */
+ public function search(string $path, string $ext = 'php', bool $prioritizeApp = true): array
+ {
+ $path = $this->ensureExt($path, $ext);
+
+ $foundPaths = [];
+ $appPaths = [];
+
+ foreach ($this->getNamespaces() as $namespace) {
+ if (isset($namespace['path']) && is_file($namespace['path'] . $path)) {
+ $fullPath = $namespace['path'] . $path;
+ $fullPath = realpath($fullPath) ?: $fullPath;
+
+ if ($prioritizeApp) {
+ $foundPaths[] = $fullPath;
+ } elseif (str_starts_with($fullPath, APPPATH)) {
+ $appPaths[] = $fullPath;
+ } else {
+ $foundPaths[] = $fullPath;
+ }
+ }
+ }
+
+ if (! $prioritizeApp && $appPaths !== []) {
+ $foundPaths = [...$foundPaths, ...$appPaths];
+ }
+
+ // Remove any duplicates
+ return array_values(array_unique($foundPaths));
+ }
+
+ /**
+ * Ensures a extension is at the end of a filename
+ */
+ protected function ensureExt(string $path, string $ext): string
+ {
+ if ($ext !== '') {
+ $ext = '.' . $ext;
+
+ if (! str_ends_with($path, $ext)) {
+ $path .= $ext;
+ }
+ }
+
+ return $path;
+ }
+
+ /**
+ * Return the namespace mappings we know about.
+ *
+ * @return array>
+ */
+ protected function getNamespaces()
+ {
+ $namespaces = [];
+
+ // Save system for last
+ $system = [];
+
+ foreach ($this->autoloader->getNamespace() as $prefix => $paths) {
+ foreach ($paths as $path) {
+ if ($prefix === 'CodeIgniter') {
+ $system[] = [
+ 'prefix' => $prefix,
+ 'path' => rtrim($path, '\\/') . DIRECTORY_SEPARATOR,
+ ];
+
+ continue;
+ }
+
+ $namespaces[] = [
+ 'prefix' => $prefix,
+ 'path' => rtrim($path, '\\/') . DIRECTORY_SEPARATOR,
+ ];
+ }
+ }
+
+ return array_merge($namespaces, $system);
+ }
+
+ /**
+ * Find the qualified name of a file according to
+ * the namespace of the first matched namespace path.
+ *
+ * @return false|string The qualified name or false if the path is not found
+ */
+ public function findQualifiedNameFromPath(string $path)
+ {
+ $path = realpath($path) ?: $path;
+
+ if (! is_file($path)) {
+ return false;
+ }
+
+ foreach ($this->getNamespaces() as $namespace) {
+ $namespace['path'] = realpath($namespace['path']) ?: $namespace['path'];
+
+ if ($namespace['path'] === '') {
+ continue;
+ }
+
+ if (mb_strpos($path, $namespace['path']) === 0) {
+ $className = $namespace['prefix'] . '\\' .
+ ltrim(
+ str_replace(
+ '/',
+ '\\',
+ mb_substr($path, mb_strlen($namespace['path']))
+ ),
+ '\\'
+ );
+ // Remove the file extension (.php)
+ $className = mb_substr($className, 0, -4);
+
+ if (in_array($className, $this->invalidClassnames, true)) {
+ continue;
+ }
+
+ // Check if this exists
+ if (class_exists($className)) {
+ return $className;
+ }
+
+ // If the class does not exist, it is an invalid classname.
+ $this->invalidClassnames[] = $className;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Scans the defined namespaces, returning a list of all files
+ * that are contained within the subpath specified by $path.
+ *
+ * @return list List of file paths
+ */
+ public function listFiles(string $path): array
+ {
+ if ($path === '') {
+ return [];
+ }
+
+ $files = [];
+ helper('filesystem');
+
+ foreach ($this->getNamespaces() as $namespace) {
+ $fullPath = $namespace['path'] . $path;
+ $fullPath = realpath($fullPath) ?: $fullPath;
+
+ if (! is_dir($fullPath)) {
+ continue;
+ }
+
+ $tempFiles = get_filenames($fullPath, true, false, false);
+
+ if ($tempFiles !== []) {
+ $files = array_merge($files, $tempFiles);
+ }
+ }
+
+ return $files;
+ }
+
+ /**
+ * Scans the provided namespace, returning a list of all files
+ * that are contained within the sub path specified by $path.
+ *
+ * @return list List of file paths
+ */
+ public function listNamespaceFiles(string $prefix, string $path): array
+ {
+ if ($path === '' || ($prefix === '')) {
+ return [];
+ }
+
+ $files = [];
+ helper('filesystem');
+
+ // autoloader->getNamespace($prefix) returns an array of paths for that namespace
+ foreach ($this->autoloader->getNamespace($prefix) as $namespacePath) {
+ $fullPath = rtrim($namespacePath, '/') . '/' . $path;
+ $fullPath = realpath($fullPath) ?: $fullPath;
+
+ if (! is_dir($fullPath)) {
+ continue;
+ }
+
+ $tempFiles = get_filenames($fullPath, true, false, false);
+
+ if ($tempFiles !== []) {
+ $files = array_merge($files, $tempFiles);
+ }
+ }
+
+ return $files;
+ }
+
+ /**
+ * Checks the app folder to see if the file can be found.
+ * Only for use with filenames that DO NOT include namespacing.
+ *
+ * @param non-empty-string|null $folder
+ *
+ * @return false|string The path to the file, or false if not found.
+ */
+ protected function legacyLocate(string $file, ?string $folder = null)
+ {
+ $path = APPPATH . ($folder === null ? $file : $folder . '/' . $file);
+ $path = realpath($path) ?: $path;
+
+ if (is_file($path)) {
+ return $path;
+ }
+
+ return false;
+ }
+}
diff --git a/system/Autoloader/FileLocatorCached.php b/system/Autoloader/FileLocatorCached.php
new file mode 100644
index 0000000..adf4533
--- /dev/null
+++ b/system/Autoloader/FileLocatorCached.php
@@ -0,0 +1,172 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Autoloader;
+
+use CodeIgniter\Cache\CacheInterface;
+use CodeIgniter\Cache\FactoriesCache\FileVarExportHandler;
+
+/**
+ * FileLocator with Cache
+ *
+ * @see \CodeIgniter\Autoloader\FileLocatorCachedTest
+ */
+final class FileLocatorCached implements FileLocatorInterface
+{
+ /**
+ * @var CacheInterface|FileVarExportHandler
+ */
+ private $cacheHandler;
+
+ /**
+ * Cache data
+ *
+ * [method => data]
+ * E.g.,
+ * [
+ * 'search' => [$path => $foundPaths],
+ * ]
+ */
+ private array $cache = [];
+
+ /**
+ * Is the cache updated?
+ */
+ private bool $cacheUpdated = false;
+
+ private string $cacheKey = 'FileLocatorCache';
+
+ /**
+ * @param CacheInterface|FileVarExportHandler|null $cache
+ */
+ public function __construct(private readonly FileLocator $locator, $cache = null)
+ {
+ $this->cacheHandler = $cache ?? new FileVarExportHandler();
+
+ $this->loadCache();
+ }
+
+ private function loadCache(): void
+ {
+ $data = $this->cacheHandler->get($this->cacheKey);
+
+ if (is_array($data)) {
+ $this->cache = $data;
+ }
+ }
+
+ public function __destruct()
+ {
+ $this->saveCache();
+ }
+
+ private function saveCache(): void
+ {
+ if ($this->cacheUpdated) {
+ $this->cacheHandler->save($this->cacheKey, $this->cache, 3600 * 24);
+ }
+ }
+
+ /**
+ * Delete cache data
+ */
+ public function deleteCache(): void
+ {
+ $this->cacheUpdated = false;
+ $this->cacheHandler->delete($this->cacheKey);
+ }
+
+ public function findQualifiedNameFromPath(string $path): false|string
+ {
+ if (isset($this->cache['findQualifiedNameFromPath'][$path])) {
+ return $this->cache['findQualifiedNameFromPath'][$path];
+ }
+
+ $classname = $this->locator->findQualifiedNameFromPath($path);
+
+ $this->cache['findQualifiedNameFromPath'][$path] = $classname;
+ $this->cacheUpdated = true;
+
+ return $classname;
+ }
+
+ public function getClassname(string $file): string
+ {
+ if (isset($this->cache['getClassname'][$file])) {
+ return $this->cache['getClassname'][$file];
+ }
+
+ $classname = $this->locator->getClassname($file);
+
+ $this->cache['getClassname'][$file] = $classname;
+ $this->cacheUpdated = true;
+
+ return $classname;
+ }
+
+ public function search(string $path, string $ext = 'php', bool $prioritizeApp = true): array
+ {
+ if (isset($this->cache['search'][$path][$ext][$prioritizeApp])) {
+ return $this->cache['search'][$path][$ext][$prioritizeApp];
+ }
+
+ $foundPaths = $this->locator->search($path, $ext, $prioritizeApp);
+
+ $this->cache['search'][$path][$ext][$prioritizeApp] = $foundPaths;
+ $this->cacheUpdated = true;
+
+ return $foundPaths;
+ }
+
+ public function listFiles(string $path): array
+ {
+ if (isset($this->cache['listFiles'][$path])) {
+ return $this->cache['listFiles'][$path];
+ }
+
+ $files = $this->locator->listFiles($path);
+
+ $this->cache['listFiles'][$path] = $files;
+ $this->cacheUpdated = true;
+
+ return $files;
+ }
+
+ public function listNamespaceFiles(string $prefix, string $path): array
+ {
+ if (isset($this->cache['listNamespaceFiles'][$prefix][$path])) {
+ return $this->cache['listNamespaceFiles'][$prefix][$path];
+ }
+
+ $files = $this->locator->listNamespaceFiles($prefix, $path);
+
+ $this->cache['listNamespaceFiles'][$prefix][$path] = $files;
+ $this->cacheUpdated = true;
+
+ return $files;
+ }
+
+ public function locateFile(string $file, ?string $folder = null, string $ext = 'php'): false|string
+ {
+ if (isset($this->cache['locateFile'][$file][$folder][$ext])) {
+ return $this->cache['locateFile'][$file][$folder][$ext];
+ }
+
+ $files = $this->locator->locateFile($file, $folder, $ext);
+
+ $this->cache['locateFile'][$file][$folder][$ext] = $files;
+ $this->cacheUpdated = true;
+
+ return $files;
+ }
+}
diff --git a/system/Autoloader/FileLocatorInterface.php b/system/Autoloader/FileLocatorInterface.php
new file mode 100644
index 0000000..3f7355a
--- /dev/null
+++ b/system/Autoloader/FileLocatorInterface.php
@@ -0,0 +1,82 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Autoloader;
+
+/**
+ * Allows loading non-class files in a namespaced manner.
+ * Works with Helpers, Views, etc.
+ */
+interface FileLocatorInterface
+{
+ /**
+ * Attempts to locate a file by examining the name for a namespace
+ * and looking through the PSR-4 namespaced files that we know about.
+ *
+ * @param string $file The relative file path or namespaced file to
+ * locate. If not namespaced, search in the app
+ * folder.
+ * @param non-empty-string|null $folder The folder within the namespace that we should
+ * look for the file. If $file does not contain
+ * this value, it will be appended to the namespace
+ * folder.
+ * @param string $ext The file extension the file should have.
+ *
+ * @return false|string The path to the file, or false if not found.
+ */
+ public function locateFile(string $file, ?string $folder = null, string $ext = 'php');
+
+ /**
+ * Examines a file and returns the fully qualified class name.
+ */
+ public function getClassname(string $file): string;
+
+ /**
+ * Searches through all of the defined namespaces looking for a file.
+ * Returns an array of all found locations for the defined file.
+ *
+ * Example:
+ *
+ * $locator->search('Config/Routes.php');
+ * // Assuming PSR4 namespaces include foo and bar, might return:
+ * [
+ * 'app/Modules/foo/Config/Routes.php',
+ * 'app/Modules/bar/Config/Routes.php',
+ * ]
+ */
+ public function search(string $path, string $ext = 'php', bool $prioritizeApp = true): array;
+
+ /**
+ * Find the qualified name of a file according to
+ * the namespace of the first matched namespace path.
+ *
+ * @return false|string The qualified name or false if the path is not found
+ */
+ public function findQualifiedNameFromPath(string $path);
+
+ /**
+ * Scans the defined namespaces, returning a list of all files
+ * that are contained within the subpath specified by $path.
+ *
+ * @return list List of file paths
+ */
+ public function listFiles(string $path): array;
+
+ /**
+ * Scans the provided namespace, returning a list of all files
+ * that are contained within the sub path specified by $path.
+ *
+ * @return list List of file paths
+ */
+ public function listNamespaceFiles(string $prefix, string $path): array;
+}
diff --git a/system/BaseModel.php b/system/BaseModel.php
new file mode 100644
index 0000000..9b8bb70
--- /dev/null
+++ b/system/BaseModel.php
@@ -0,0 +1,1953 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter;
+
+use Closure;
+use CodeIgniter\Database\BaseConnection;
+use CodeIgniter\Database\BaseResult;
+use CodeIgniter\Database\Exceptions\DatabaseException;
+use CodeIgniter\Database\Exceptions\DataException;
+use CodeIgniter\Database\Query;
+use CodeIgniter\DataConverter\DataConverter;
+use CodeIgniter\Entity\Entity;
+use CodeIgniter\Exceptions\ModelException;
+use CodeIgniter\I18n\Time;
+use CodeIgniter\Pager\Pager;
+use CodeIgniter\Validation\ValidationInterface;
+use Config\Feature;
+use Config\Services;
+use InvalidArgumentException;
+use ReflectionClass;
+use ReflectionException;
+use ReflectionProperty;
+use stdClass;
+
+/**
+ * The BaseModel class provides a number of convenient features that
+ * makes working with a databases less painful. Extending this class
+ * provide means of implementing various database systems
+ *
+ * It will:
+ * - simplifies pagination
+ * - allow specifying the return type (array, object, etc) with each call
+ * - automatically set and update timestamps
+ * - handle soft deletes
+ * - ensure validation is run against objects when saving items
+ * - process various callbacks
+ * - allow intermingling calls to the db connection
+ *
+ * @phpstan-type row_array array
+ * @phpstan-type event_data_beforeinsert array{data: row_array}
+ * @phpstan-type event_data_afterinsert array{id: int|string, data: row_array, result: bool}
+ * @phpstan-type event_data_beforefind array{id?: int|string, method: string, singleton: bool, limit?: int, offset?: int}
+ * @phpstan-type event_data_afterfind array{id: int|string|null|list, data: row_array|list|object|null, method: string, singleton: bool}
+ * @phpstan-type event_data_beforeupdate array{id: null|list, data: row_array}
+ * @phpstan-type event_data_afterupdate array{id: null|list, data: row_array|object, result: bool}
+ * @phpstan-type event_data_beforedelete array{id: null|list, purge: bool}
+ * @phpstan-type event_data_afterdelete array{id: null|list, data: null, purge: bool, result: bool}
+ */
+abstract class BaseModel
+{
+ /**
+ * Pager instance.
+ * Populated after calling $this->paginate()
+ *
+ * @var Pager
+ */
+ public $pager;
+
+ /**
+ * Database Connection
+ *
+ * @var BaseConnection
+ */
+ protected $db;
+
+ /**
+ * Last insert ID
+ *
+ * @var int|string
+ */
+ protected $insertID = 0;
+
+ /**
+ * The Database connection group that
+ * should be instantiated.
+ *
+ * @var non-empty-string|null
+ */
+ protected $DBGroup;
+
+ /**
+ * The format that the results should be returned as.
+ * Will be overridden if the as* methods are used.
+ *
+ * @var string
+ */
+ protected $returnType = 'array';
+
+ /**
+ * Used by asArray() and asObject() to provide
+ * temporary overrides of model default.
+ *
+ * @var 'array'|'object'|class-string
+ */
+ protected $tempReturnType;
+
+ /**
+ * Array of column names and the type of value to cast.
+ *
+ * @var array [column => type]
+ */
+ protected array $casts = [];
+
+ /**
+ * Custom convert handlers.
+ *
+ * @var array [type => classname]
+ */
+ protected array $castHandlers = [];
+
+ protected ?DataConverter $converter = null;
+
+ /**
+ * If this model should use "softDeletes" and
+ * simply set a date when rows are deleted, or
+ * do hard deletes.
+ *
+ * @var bool
+ */
+ protected $protectFields = true;
+
+ /**
+ * An array of field names that are allowed
+ * to be set by the user in inserts/updates.
+ *
+ * @var list
+ */
+ protected $allowedFields = [];
+
+ /**
+ * If true, will set created_at, and updated_at
+ * values during insert and update routines.
+ *
+ * @var bool
+ */
+ protected $useTimestamps = false;
+
+ /**
+ * The type of column that created_at and updated_at
+ * are expected to.
+ *
+ * Allowed: 'datetime', 'date', 'int'
+ *
+ * @var string
+ */
+ protected $dateFormat = 'datetime';
+
+ /**
+ * The column used for insert timestamps
+ *
+ * @var string
+ */
+ protected $createdField = 'created_at';
+
+ /**
+ * The column used for update timestamps
+ *
+ * @var string
+ */
+ protected $updatedField = 'updated_at';
+
+ /**
+ * If this model should use "softDeletes" and
+ * simply set a date when rows are deleted, or
+ * do hard deletes.
+ *
+ * @var bool
+ */
+ protected $useSoftDeletes = false;
+
+ /**
+ * Used by withDeleted to override the
+ * model's softDelete setting.
+ *
+ * @var bool
+ */
+ protected $tempUseSoftDeletes;
+
+ /**
+ * The column used to save soft delete state
+ *
+ * @var string
+ */
+ protected $deletedField = 'deleted_at';
+
+ /**
+ * Whether to allow inserting empty data.
+ */
+ protected bool $allowEmptyInserts = false;
+
+ /**
+ * Whether to update Entity's only changed data.
+ */
+ protected bool $updateOnlyChanged = true;
+
+ /**
+ * Rules used to validate data in insert(), update(), and save() methods.
+ *
+ * The array must match the format of data passed to the Validation
+ * library.
+ *
+ * @see https://codeigniter4.github.io/userguide/models/model.html#setting-validation-rules
+ *
+ * @var array|string>|string>|string
+ */
+ protected $validationRules = [];
+
+ /**
+ * Contains any custom error messages to be
+ * used during data validation.
+ *
+ * @var array>
+ */
+ protected $validationMessages = [];
+
+ /**
+ * Skip the model's validation. Used in conjunction with skipValidation()
+ * to skip data validation for any future calls.
+ *
+ * @var bool
+ */
+ protected $skipValidation = false;
+
+ /**
+ * Whether rules should be removed that do not exist
+ * in the passed data. Used in updates.
+ *
+ * @var bool
+ */
+ protected $cleanValidationRules = true;
+
+ /**
+ * Our validator instance.
+ *
+ * @var ValidationInterface|null
+ */
+ protected $validation;
+
+ /*
+ * Callbacks.
+ *
+ * Each array should contain the method names (within the model)
+ * that should be called when those events are triggered.
+ *
+ * "Update" and "delete" methods are passed the same items that
+ * are given to their respective method.
+ *
+ * "Find" methods receive the ID searched for (if present), and
+ * 'afterFind' additionally receives the results that were found.
+ */
+
+ /**
+ * Whether to trigger the defined callbacks
+ *
+ * @var bool
+ */
+ protected $allowCallbacks = true;
+
+ /**
+ * Used by allowCallbacks() to override the
+ * model's allowCallbacks setting.
+ *
+ * @var bool
+ */
+ protected $tempAllowCallbacks;
+
+ /**
+ * Callbacks for beforeInsert
+ *
+ * @var list
+ */
+ protected $beforeInsert = [];
+
+ /**
+ * Callbacks for afterInsert
+ *
+ * @var list
+ */
+ protected $afterInsert = [];
+
+ /**
+ * Callbacks for beforeUpdate
+ *
+ * @var list
+ */
+ protected $beforeUpdate = [];
+
+ /**
+ * Callbacks for afterUpdate
+ *
+ * @var list
+ */
+ protected $afterUpdate = [];
+
+ /**
+ * Callbacks for beforeInsertBatch
+ *
+ * @var list
+ */
+ protected $beforeInsertBatch = [];
+
+ /**
+ * Callbacks for afterInsertBatch
+ *
+ * @var list
+ */
+ protected $afterInsertBatch = [];
+
+ /**
+ * Callbacks for beforeUpdateBatch
+ *
+ * @var list
+ */
+ protected $beforeUpdateBatch = [];
+
+ /**
+ * Callbacks for afterUpdateBatch
+ *
+ * @var list
+ */
+ protected $afterUpdateBatch = [];
+
+ /**
+ * Callbacks for beforeFind
+ *
+ * @var list
+ */
+ protected $beforeFind = [];
+
+ /**
+ * Callbacks for afterFind
+ *
+ * @var list
+ */
+ protected $afterFind = [];
+
+ /**
+ * Callbacks for beforeDelete
+ *
+ * @var list
+ */
+ protected $beforeDelete = [];
+
+ /**
+ * Callbacks for afterDelete
+ *
+ * @var list
+ */
+ protected $afterDelete = [];
+
+ public function __construct(?ValidationInterface $validation = null)
+ {
+ $this->tempReturnType = $this->returnType;
+ $this->tempUseSoftDeletes = $this->useSoftDeletes;
+ $this->tempAllowCallbacks = $this->allowCallbacks;
+
+ $this->validation = $validation;
+
+ $this->initialize();
+ $this->createDataConverter();
+ }
+
+ /**
+ * Creates DataConverter instance.
+ */
+ protected function createDataConverter(): void
+ {
+ if ($this->useCasts()) {
+ $this->converter = new DataConverter(
+ $this->casts,
+ $this->castHandlers,
+ $this->db
+ );
+ }
+ }
+
+ /**
+ * Are casts used?
+ */
+ protected function useCasts(): bool
+ {
+ return $this->casts !== [];
+ }
+
+ /**
+ * Initializes the instance with any additional steps.
+ * Optionally implemented by child classes.
+ *
+ * @return void
+ */
+ protected function initialize()
+ {
+ }
+
+ /**
+ * Fetches the row of database.
+ * This method works only with dbCalls.
+ *
+ * @param bool $singleton Single or multiple results
+ * @param array|int|string|null $id One primary key or an array of primary keys
+ *
+ * @return array|object|null The resulting row of data, or null.
+ */
+ abstract protected function doFind(bool $singleton, $id = null);
+
+ /**
+ * Fetches the column of database.
+ * This method works only with dbCalls.
+ *
+ * @param string $columnName Column Name
+ *
+ * @return array|null The resulting row of data, or null if no data found.
+ *
+ * @throws DataException
+ */
+ abstract protected function doFindColumn(string $columnName);
+
+ /**
+ * Fetches all results, while optionally limiting them.
+ * This method works only with dbCalls.
+ *
+ * @param int|null $limit Limit
+ * @param int $offset Offset
+ *
+ * @return array
+ */
+ abstract protected function doFindAll(?int $limit = null, int $offset = 0);
+
+ /**
+ * Returns the first row of the result set.
+ * This method works only with dbCalls.
+ *
+ * @return array|object|null
+ */
+ abstract protected function doFirst();
+
+ /**
+ * Inserts data into the current database.
+ * This method works only with dbCalls.
+ *
+ * @param array $row Row data
+ * @phpstan-param row_array $row
+ *
+ * @return bool
+ */
+ abstract protected function doInsert(array $row);
+
+ /**
+ * Compiles batch insert and runs the queries, validating each row prior.
+ * This method works only with dbCalls.
+ *
+ * @param array|null $set An associative array of insert values
+ * @param bool|null $escape Whether to escape values
+ * @param int $batchSize The size of the batch to run
+ * @param bool $testing True means only number of records is returned, false will execute the query
+ *
+ * @return bool|int Number of rows inserted or FALSE on failure
+ */
+ abstract protected function doInsertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false);
+
+ /**
+ * Updates a single record in the database.
+ * This method works only with dbCalls.
+ *
+ * @param array|int|string|null $id ID
+ * @param array|null $row Row data
+ * @phpstan-param row_array|null $row
+ */
+ abstract protected function doUpdate($id = null, $row = null): bool;
+
+ /**
+ * Compiles an update and runs the query.
+ * This method works only with dbCalls.
+ *
+ * @param array|null $set An associative array of update values
+ * @param string|null $index The where key
+ * @param int $batchSize The size of the batch to run
+ * @param bool $returnSQL True means SQL is returned, false will execute the query
+ *
+ * @return false|int|list Number of rows affected or FALSE on failure, SQL array when testMode
+ *
+ * @throws DatabaseException
+ */
+ abstract protected function doUpdateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false);
+
+ /**
+ * Deletes a single record from the database where $id matches.
+ * This method works only with dbCalls.
+ *
+ * @param array|int|string|null $id The rows primary key(s)
+ * @param bool $purge Allows overriding the soft deletes setting.
+ *
+ * @return bool|string
+ *
+ * @throws DatabaseException
+ */
+ abstract protected function doDelete($id = null, bool $purge = false);
+
+ /**
+ * Permanently deletes all rows that have been marked as deleted.
+ * through soft deletes (deleted = 1).
+ * This method works only with dbCalls.
+ *
+ * @return bool|string Returns a string if in test mode.
+ */
+ abstract protected function doPurgeDeleted();
+
+ /**
+ * Works with the find* methods to return only the rows that
+ * have been deleted.
+ * This method works only with dbCalls.
+ *
+ * @return void
+ */
+ abstract protected function doOnlyDeleted();
+
+ /**
+ * Compiles a replace and runs the query.
+ * This method works only with dbCalls.
+ *
+ * @param array|null $row Row data
+ * @phpstan-param row_array|null $row
+ * @param bool $returnSQL Set to true to return Query String
+ *
+ * @return BaseResult|false|Query|string
+ */
+ abstract protected function doReplace(?array $row = null, bool $returnSQL = false);
+
+ /**
+ * Grabs the last error(s) that occurred from the Database connection.
+ * This method works only with dbCalls.
+ *
+ * @return array|null
+ */
+ abstract protected function doErrors();
+
+ /**
+ * Public getter to return the id value using the idValue() method.
+ * For example with SQL this will return $data->$this->primaryKey.
+ *
+ * @param array|object $row Row data
+ * @phpstan-param row_array|object $row
+ *
+ * @return array|int|string|null
+ */
+ abstract public function getIdValue($row);
+
+ /**
+ * Override countAllResults to account for soft deleted accounts.
+ * This method works only with dbCalls.
+ *
+ * @param bool $reset Reset
+ * @param bool $test Test
+ *
+ * @return int|string
+ */
+ abstract public function countAllResults(bool $reset = true, bool $test = false);
+
+ /**
+ * Loops over records in batches, allowing you to operate on them.
+ * This method works only with dbCalls.
+ *
+ * @param int $size Size
+ * @param Closure $userFunc Callback Function
+ *
+ * @return void
+ *
+ * @throws DataException
+ */
+ abstract public function chunk(int $size, Closure $userFunc);
+
+ /**
+ * Fetches the row of database.
+ *
+ * @param array|int|string|null $id One primary key or an array of primary keys
+ *
+ * @return array|object|null The resulting row of data, or null.
+ * @phpstan-return ($id is int|string ? row_array|object|null : list)
+ */
+ public function find($id = null)
+ {
+ $singleton = is_numeric($id) || is_string($id);
+
+ if ($this->tempAllowCallbacks) {
+ // Call the before event and check for a return
+ $eventData = $this->trigger('beforeFind', [
+ 'id' => $id,
+ 'method' => 'find',
+ 'singleton' => $singleton,
+ ]);
+
+ if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
+ return $eventData['data'];
+ }
+ }
+
+ $eventData = [
+ 'id' => $id,
+ 'data' => $this->doFind($singleton, $id),
+ 'method' => 'find',
+ 'singleton' => $singleton,
+ ];
+
+ if ($this->tempAllowCallbacks) {
+ $eventData = $this->trigger('afterFind', $eventData);
+ }
+
+ $this->tempReturnType = $this->returnType;
+ $this->tempUseSoftDeletes = $this->useSoftDeletes;
+ $this->tempAllowCallbacks = $this->allowCallbacks;
+
+ return $eventData['data'];
+ }
+
+ /**
+ * Fetches the column of database.
+ *
+ * @param string $columnName Column Name
+ *
+ * @return array|null The resulting row of data, or null if no data found.
+ *
+ * @throws DataException
+ */
+ public function findColumn(string $columnName)
+ {
+ if (str_contains($columnName, ',')) {
+ throw DataException::forFindColumnHaveMultipleColumns();
+ }
+
+ $resultSet = $this->doFindColumn($columnName);
+
+ return $resultSet ? array_column($resultSet, $columnName) : null;
+ }
+
+ /**
+ * Fetches all results, while optionally limiting them.
+ *
+ * @param int $limit Limit
+ * @param int $offset Offset
+ *
+ * @return array
+ */
+ public function findAll(?int $limit = null, int $offset = 0)
+ {
+ $limitZeroAsAll = config(Feature::class)->limitZeroAsAll ?? true;
+ if ($limitZeroAsAll) {
+ $limit ??= 0;
+ }
+
+ if ($this->tempAllowCallbacks) {
+ // Call the before event and check for a return
+ $eventData = $this->trigger('beforeFind', [
+ 'method' => 'findAll',
+ 'limit' => $limit,
+ 'offset' => $offset,
+ 'singleton' => false,
+ ]);
+
+ if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
+ return $eventData['data'];
+ }
+ }
+
+ $eventData = [
+ 'data' => $this->doFindAll($limit, $offset),
+ 'limit' => $limit,
+ 'offset' => $offset,
+ 'method' => 'findAll',
+ 'singleton' => false,
+ ];
+
+ if ($this->tempAllowCallbacks) {
+ $eventData = $this->trigger('afterFind', $eventData);
+ }
+
+ $this->tempReturnType = $this->returnType;
+ $this->tempUseSoftDeletes = $this->useSoftDeletes;
+ $this->tempAllowCallbacks = $this->allowCallbacks;
+
+ return $eventData['data'];
+ }
+
+ /**
+ * Returns the first row of the result set.
+ *
+ * @return array|object|null
+ */
+ public function first()
+ {
+ if ($this->tempAllowCallbacks) {
+ // Call the before event and check for a return
+ $eventData = $this->trigger('beforeFind', [
+ 'method' => 'first',
+ 'singleton' => true,
+ ]);
+
+ if (isset($eventData['returnData']) && $eventData['returnData'] === true) {
+ return $eventData['data'];
+ }
+ }
+
+ $eventData = [
+ 'data' => $this->doFirst(),
+ 'method' => 'first',
+ 'singleton' => true,
+ ];
+
+ if ($this->tempAllowCallbacks) {
+ $eventData = $this->trigger('afterFind', $eventData);
+ }
+
+ $this->tempReturnType = $this->returnType;
+ $this->tempUseSoftDeletes = $this->useSoftDeletes;
+ $this->tempAllowCallbacks = $this->allowCallbacks;
+
+ return $eventData['data'];
+ }
+
+ /**
+ * A convenience method that will attempt to determine whether the
+ * data should be inserted or updated. Will work with either
+ * an array or object. When using with custom class objects,
+ * you must ensure that the class will provide access to the class
+ * variables, even if through a magic method.
+ *
+ * @param array|object $row Row data
+ * @phpstan-param row_array|object $row
+ *
+ * @throws ReflectionException
+ */
+ public function save($row): bool
+ {
+ if ((array) $row === []) {
+ return true;
+ }
+
+ if ($this->shouldUpdate($row)) {
+ $response = $this->update($this->getIdValue($row), $row);
+ } else {
+ $response = $this->insert($row, false);
+
+ if ($response !== false) {
+ $response = true;
+ }
+ }
+
+ return $response;
+ }
+
+ /**
+ * This method is called on save to determine if entry have to be updated.
+ * If this method returns false insert operation will be executed
+ *
+ * @param array|object $row Row data
+ * @phpstan-param row_array|object $row
+ */
+ protected function shouldUpdate($row): bool
+ {
+ $id = $this->getIdValue($row);
+
+ return ! ($id === null || $id === [] || $id === '');
+ }
+
+ /**
+ * Returns last insert ID or 0.
+ *
+ * @return int|string
+ */
+ public function getInsertID()
+ {
+ return is_numeric($this->insertID) ? (int) $this->insertID : $this->insertID;
+ }
+
+ /**
+ * Inserts data into the database. If an object is provided,
+ * it will attempt to convert it to an array.
+ *
+ * @param array|object|null $row Row data
+ * @phpstan-param row_array|object|null $row
+ * @param bool $returnID Whether insert ID should be returned or not.
+ *
+ * @return bool|int|string insert ID or true on success. false on failure.
+ * @phpstan-return ($returnID is true ? int|string|false : bool)
+ *
+ * @throws ReflectionException
+ */
+ public function insert($row = null, bool $returnID = true)
+ {
+ $this->insertID = 0;
+
+ // Set $cleanValidationRules to false temporary.
+ $cleanValidationRules = $this->cleanValidationRules;
+ $this->cleanValidationRules = false;
+
+ $row = $this->transformDataToArray($row, 'insert');
+
+ // Validate data before saving.
+ if (! $this->skipValidation && ! $this->validate($row)) {
+ // Restore $cleanValidationRules
+ $this->cleanValidationRules = $cleanValidationRules;
+
+ return false;
+ }
+
+ // Restore $cleanValidationRules
+ $this->cleanValidationRules = $cleanValidationRules;
+
+ // Must be called first, so we don't
+ // strip out created_at values.
+ $row = $this->doProtectFieldsForInsert($row);
+
+ // doProtectFields() can further remove elements from
+ // $row, so we need to check for empty dataset again
+ if (! $this->allowEmptyInserts && $row === []) {
+ throw DataException::forEmptyDataset('insert');
+ }
+
+ // Set created_at and updated_at with same time
+ $date = $this->setDate();
+ $row = $this->setCreatedField($row, $date);
+ $row = $this->setUpdatedField($row, $date);
+
+ $eventData = ['data' => $row];
+
+ if ($this->tempAllowCallbacks) {
+ $eventData = $this->trigger('beforeInsert', $eventData);
+ }
+
+ $result = $this->doInsert($eventData['data']);
+
+ $eventData = [
+ 'id' => $this->insertID,
+ 'data' => $eventData['data'],
+ 'result' => $result,
+ ];
+
+ if ($this->tempAllowCallbacks) {
+ // Trigger afterInsert events with the inserted data and new ID
+ $this->trigger('afterInsert', $eventData);
+ }
+
+ $this->tempAllowCallbacks = $this->allowCallbacks;
+
+ // If insertion failed, get out of here
+ if (! $result) {
+ return $result;
+ }
+
+ // otherwise return the insertID, if requested.
+ return $returnID ? $this->insertID : $result;
+ }
+
+ /**
+ * Set datetime to created field.
+ *
+ * @phpstan-param row_array $row
+ * @param int|string $date timestamp or datetime string
+ */
+ protected function setCreatedField(array $row, $date): array
+ {
+ if ($this->useTimestamps && $this->createdField !== '' && ! array_key_exists($this->createdField, $row)) {
+ $row[$this->createdField] = $date;
+ }
+
+ return $row;
+ }
+
+ /**
+ * Set datetime to updated field.
+ *
+ * @phpstan-param row_array $row
+ * @param int|string $date timestamp or datetime string
+ */
+ protected function setUpdatedField(array $row, $date): array
+ {
+ if ($this->useTimestamps && $this->updatedField !== '' && ! array_key_exists($this->updatedField, $row)) {
+ $row[$this->updatedField] = $date;
+ }
+
+ return $row;
+ }
+
+ /**
+ * Compiles batch insert runs the queries, validating each row prior.
+ *
+ * @param list|null $set an associative array of insert values
+ * @phpstan-param list|null $set
+ * @param bool|null $escape Whether to escape values
+ * @param int $batchSize The size of the batch to run
+ * @param bool $testing True means only number of records is returned, false will execute the query
+ *
+ * @return bool|int Number of rows inserted or FALSE on failure
+ *
+ * @throws ReflectionException
+ */
+ public function insertBatch(?array $set = null, ?bool $escape = null, int $batchSize = 100, bool $testing = false)
+ {
+ // Set $cleanValidationRules to false temporary.
+ $cleanValidationRules = $this->cleanValidationRules;
+ $this->cleanValidationRules = false;
+
+ if (is_array($set)) {
+ foreach ($set as &$row) {
+ // If $row is using a custom class with public or protected
+ // properties representing the collection elements, we need to grab
+ // them as an array.
+ if (is_object($row) && ! $row instanceof stdClass) {
+ $row = $this->objectToArray($row, false, true);
+ }
+
+ // If it's still a stdClass, go ahead and convert to
+ // an array so doProtectFields and other model methods
+ // don't have to do special checks.
+ if (is_object($row)) {
+ $row = (array) $row;
+ }
+
+ // Convert any Time instances to appropriate $dateFormat
+ $row = $this->timeToString($row);
+
+ // Validate every row.
+ if (! $this->skipValidation && ! $this->validate($row)) {
+ // Restore $cleanValidationRules
+ $this->cleanValidationRules = $cleanValidationRules;
+
+ return false;
+ }
+
+ // Must be called first so we don't
+ // strip out created_at values.
+ $row = $this->doProtectFieldsForInsert($row);
+
+ // Set created_at and updated_at with same time
+ $date = $this->setDate();
+ $row = $this->setCreatedField($row, $date);
+ $row = $this->setUpdatedField($row, $date);
+ }
+ }
+
+ // Restore $cleanValidationRules
+ $this->cleanValidationRules = $cleanValidationRules;
+
+ $eventData = ['data' => $set];
+
+ if ($this->tempAllowCallbacks) {
+ $eventData = $this->trigger('beforeInsertBatch', $eventData);
+ }
+
+ $result = $this->doInsertBatch($eventData['data'], $escape, $batchSize, $testing);
+
+ $eventData = [
+ 'data' => $eventData['data'],
+ 'result' => $result,
+ ];
+
+ if ($this->tempAllowCallbacks) {
+ // Trigger afterInsert events with the inserted data and new ID
+ $this->trigger('afterInsertBatch', $eventData);
+ }
+
+ $this->tempAllowCallbacks = $this->allowCallbacks;
+
+ return $result;
+ }
+
+ /**
+ * Updates a single record in the database. If an object is provided,
+ * it will attempt to convert it into an array.
+ *
+ * @param array|int|string|null $id
+ * @param array|object|null $row Row data
+ * @phpstan-param row_array|object|null $row
+ *
+ * @throws ReflectionException
+ */
+ public function update($id = null, $row = null): bool
+ {
+ if (is_bool($id)) {
+ throw new InvalidArgumentException('update(): argument #1 ($id) should not be boolean.');
+ }
+
+ if (is_numeric($id) || is_string($id)) {
+ $id = [$id];
+ }
+
+ $row = $this->transformDataToArray($row, 'update');
+
+ // Validate data before saving.
+ if (! $this->skipValidation && ! $this->validate($row)) {
+ return false;
+ }
+
+ // Must be called first, so we don't
+ // strip out updated_at values.
+ $row = $this->doProtectFields($row);
+
+ // doProtectFields() can further remove elements from
+ // $row, so we need to check for empty dataset again
+ if ($row === []) {
+ throw DataException::forEmptyDataset('update');
+ }
+
+ $row = $this->setUpdatedField($row, $this->setDate());
+
+ $eventData = [
+ 'id' => $id,
+ 'data' => $row,
+ ];
+
+ if ($this->tempAllowCallbacks) {
+ $eventData = $this->trigger('beforeUpdate', $eventData);
+ }
+
+ $eventData = [
+ 'id' => $id,
+ 'data' => $eventData['data'],
+ 'result' => $this->doUpdate($id, $eventData['data']),
+ ];
+
+ if ($this->tempAllowCallbacks) {
+ $this->trigger('afterUpdate', $eventData);
+ }
+
+ $this->tempAllowCallbacks = $this->allowCallbacks;
+
+ return $eventData['result'];
+ }
+
+ /**
+ * Compiles an update and runs the query.
+ *
+ * @param list|null $set an associative array of insert values
+ * @phpstan-param list|null $set
+ * @param string|null $index The where key
+ * @param int $batchSize The size of the batch to run
+ * @param bool $returnSQL True means SQL is returned, false will execute the query
+ *
+ * @return false|int|list Number of rows affected or FALSE on failure, SQL array when testMode
+ *
+ * @throws DatabaseException
+ * @throws ReflectionException
+ */
+ public function updateBatch(?array $set = null, ?string $index = null, int $batchSize = 100, bool $returnSQL = false)
+ {
+ if (is_array($set)) {
+ foreach ($set as &$row) {
+ // If $row is using a custom class with public or protected
+ // properties representing the collection elements, we need to grab
+ // them as an array.
+ if (is_object($row) && ! $row instanceof stdClass) {
+ // For updates the index field is needed even if it is not changed.
+ // So set $onlyChanged to false.
+ $row = $this->objectToArray($row, false, true);
+ }
+
+ // If it's still a stdClass, go ahead and convert to
+ // an array so doProtectFields and other model methods
+ // don't have to do special checks.
+ if (is_object($row)) {
+ $row = (array) $row;
+ }
+
+ // Validate data before saving.
+ if (! $this->skipValidation && ! $this->validate($row)) {
+ return false;
+ }
+
+ // Save updateIndex for later
+ $updateIndex = $row[$index] ?? null;
+
+ if ($updateIndex === null) {
+ throw new InvalidArgumentException(
+ 'The index ("' . $index . '") for updateBatch() is missing in the data: '
+ . json_encode($row)
+ );
+ }
+
+ // Must be called first so we don't
+ // strip out updated_at values.
+ $row = $this->doProtectFields($row);
+
+ // Restore updateIndex value in case it was wiped out
+ if ($updateIndex !== null) {
+ $row[$index] = $updateIndex;
+ }
+
+ $row = $this->setUpdatedField($row, $this->setDate());
+ }
+ }
+
+ $eventData = ['data' => $set];
+
+ if ($this->tempAllowCallbacks) {
+ $eventData = $this->trigger('beforeUpdateBatch', $eventData);
+ }
+
+ $result = $this->doUpdateBatch($eventData['data'], $index, $batchSize, $returnSQL);
+
+ $eventData = [
+ 'data' => $eventData['data'],
+ 'result' => $result,
+ ];
+
+ if ($this->tempAllowCallbacks) {
+ // Trigger afterInsert events with the inserted data and new ID
+ $this->trigger('afterUpdateBatch', $eventData);
+ }
+
+ $this->tempAllowCallbacks = $this->allowCallbacks;
+
+ return $result;
+ }
+
+ /**
+ * Deletes a single record from the database where $id matches.
+ *
+ * @param array|int|string|null $id The rows primary key(s)
+ * @param bool $purge Allows overriding the soft deletes setting.
+ *
+ * @return BaseResult|bool
+ *
+ * @throws DatabaseException
+ */
+ public function delete($id = null, bool $purge = false)
+ {
+ if (is_bool($id)) {
+ throw new InvalidArgumentException('delete(): argument #1 ($id) should not be boolean.');
+ }
+
+ if ($id && (is_numeric($id) || is_string($id))) {
+ $id = [$id];
+ }
+
+ $eventData = [
+ 'id' => $id,
+ 'purge' => $purge,
+ ];
+
+ if ($this->tempAllowCallbacks) {
+ $this->trigger('beforeDelete', $eventData);
+ }
+
+ $eventData = [
+ 'id' => $id,
+ 'data' => null,
+ 'purge' => $purge,
+ 'result' => $this->doDelete($id, $purge),
+ ];
+
+ if ($this->tempAllowCallbacks) {
+ $this->trigger('afterDelete', $eventData);
+ }
+
+ $this->tempAllowCallbacks = $this->allowCallbacks;
+
+ return $eventData['result'];
+ }
+
+ /**
+ * Permanently deletes all rows that have been marked as deleted
+ * through soft deletes (deleted = 1).
+ *
+ * @return bool|string Returns a string if in test mode.
+ */
+ public function purgeDeleted()
+ {
+ if (! $this->useSoftDeletes) {
+ return true;
+ }
+
+ return $this->doPurgeDeleted();
+ }
+
+ /**
+ * Sets $useSoftDeletes value so that we can temporarily override
+ * the soft deletes settings. Can be used for all find* methods.
+ *
+ * @param bool $val Value
+ *
+ * @return $this
+ */
+ public function withDeleted(bool $val = true)
+ {
+ $this->tempUseSoftDeletes = ! $val;
+
+ return $this;
+ }
+
+ /**
+ * Works with the find* methods to return only the rows that
+ * have been deleted.
+ *
+ * @return $this
+ */
+ public function onlyDeleted()
+ {
+ $this->tempUseSoftDeletes = false;
+ $this->doOnlyDeleted();
+
+ return $this;
+ }
+
+ /**
+ * Compiles a replace and runs the query.
+ *
+ * @param array|null $row Row data
+ * @phpstan-param row_array|null $row
+ * @param bool $returnSQL Set to true to return Query String
+ *
+ * @return BaseResult|false|Query|string
+ */
+ public function replace(?array $row = null, bool $returnSQL = false)
+ {
+ // Validate data before saving.
+ if (($row !== null) && ! $this->skipValidation && ! $this->validate($row)) {
+ return false;
+ }
+
+ $row = $this->setUpdatedField((array) $row, $this->setDate());
+
+ return $this->doReplace($row, $returnSQL);
+ }
+
+ /**
+ * Grabs the last error(s) that occurred. If data was validated,
+ * it will first check for errors there, otherwise will try to
+ * grab the last error from the Database connection.
+ *
+ * The return array should be in the following format:
+ * ['source' => 'message']
+ *
+ * @param bool $forceDB Always grab the db error, not validation
+ *
+ * @return array
+ */
+ public function errors(bool $forceDB = false)
+ {
+ if ($this->validation === null) {
+ return $this->doErrors();
+ }
+
+ // Do we have validation errors?
+ if (! $forceDB && ! $this->skipValidation && ($errors = $this->validation->getErrors())) {
+ return $errors;
+ }
+
+ return $this->doErrors();
+ }
+
+ /**
+ * Works with Pager to get the size and offset parameters.
+ * Expects a GET variable (?page=2) that specifies the page of results
+ * to display.
+ *
+ * @param int|null $perPage Items per page
+ * @param string $group Will be used by the pagination library to identify a unique pagination set.
+ * @param int|null $page Optional page number (useful when the page number is provided in different way)
+ * @param int $segment Optional URI segment number (if page number is provided by URI segment)
+ *
+ * @return array|null
+ */
+ public function paginate(?int $perPage = null, string $group = 'default', ?int $page = null, int $segment = 0)
+ {
+ // Since multiple models may use the Pager, the Pager must be shared.
+ $pager = service('pager');
+
+ if ($segment !== 0) {
+ $pager->setSegment($segment, $group);
+ }
+
+ $page = $page >= 1 ? $page : $pager->getCurrentPage($group);
+ // Store it in the Pager library, so it can be paginated in the views.
+ $this->pager = $pager->store($group, $page, $perPage, $this->countAllResults(false), $segment);
+ $perPage = $this->pager->getPerPage($group);
+ $offset = ($pager->getCurrentPage($group) - 1) * $perPage;
+
+ return $this->findAll($perPage, $offset);
+ }
+
+ /**
+ * It could be used when you have to change default or override current allowed fields.
+ *
+ * @param array $allowedFields Array with names of fields
+ *
+ * @return $this
+ */
+ public function setAllowedFields(array $allowedFields)
+ {
+ $this->allowedFields = $allowedFields;
+
+ return $this;
+ }
+
+ /**
+ * Sets whether or not we should whitelist data set during
+ * updates or inserts against $this->availableFields.
+ *
+ * @param bool $protect Value
+ *
+ * @return $this
+ */
+ public function protect(bool $protect = true)
+ {
+ $this->protectFields = $protect;
+
+ return $this;
+ }
+
+ /**
+ * Ensures that only the fields that are allowed to be updated are
+ * in the data array.
+ *
+ * @used-by update() to protect against mass assignment vulnerabilities.
+ * @used-by updateBatch() to protect against mass assignment vulnerabilities.
+ *
+ * @param array $row Row data
+ * @phpstan-param row_array $row
+ *
+ * @throws DataException
+ */
+ protected function doProtectFields(array $row): array
+ {
+ if (! $this->protectFields) {
+ return $row;
+ }
+
+ if ($this->allowedFields === []) {
+ throw DataException::forInvalidAllowedFields(static::class);
+ }
+
+ foreach (array_keys($row) as $key) {
+ if (! in_array($key, $this->allowedFields, true)) {
+ unset($row[$key]);
+ }
+ }
+
+ return $row;
+ }
+
+ /**
+ * Ensures that only the fields that are allowed to be inserted are in
+ * the data array.
+ *
+ * @used-by insert() to protect against mass assignment vulnerabilities.
+ * @used-by insertBatch() to protect against mass assignment vulnerabilities.
+ *
+ * @param array $row Row data
+ * @phpstan-param row_array $row
+ *
+ * @throws DataException
+ */
+ protected function doProtectFieldsForInsert(array $row): array
+ {
+ return $this->doProtectFields($row);
+ }
+
+ /**
+ * Sets the date or current date if null value is passed.
+ *
+ * @param int|null $userData An optional PHP timestamp to be converted.
+ *
+ * @return int|string
+ *
+ * @throws ModelException
+ */
+ protected function setDate(?int $userData = null)
+ {
+ $currentDate = $userData ?? Time::now()->getTimestamp();
+
+ return $this->intToDate($currentDate);
+ }
+
+ /**
+ * A utility function to allow child models to use the type of
+ * date/time format that they prefer. This is primarily used for
+ * setting created_at, updated_at and deleted_at values, but can be
+ * used by inheriting classes.
+ *
+ * The available time formats are:
+ * - 'int' - Stores the date as an integer timestamp
+ * - 'datetime' - Stores the data in the SQL datetime format
+ * - 'date' - Stores the date (only) in the SQL date format.
+ *
+ * @param int $value value
+ *
+ * @return int|string
+ *
+ * @throws ModelException
+ */
+ protected function intToDate(int $value)
+ {
+ return match ($this->dateFormat) {
+ 'int' => $value,
+ 'datetime' => date($this->db->dateFormat['datetime'], $value),
+ 'date' => date($this->db->dateFormat['date'], $value),
+ default => throw ModelException::forNoDateFormat(static::class),
+ };
+ }
+
+ /**
+ * Converts Time value to string using $this->dateFormat.
+ *
+ * The available time formats are:
+ * - 'int' - Stores the date as an integer timestamp
+ * - 'datetime' - Stores the data in the SQL datetime format
+ * - 'date' - Stores the date (only) in the SQL date format.
+ *
+ * @param Time $value value
+ *
+ * @return int|string
+ */
+ protected function timeToDate(Time $value)
+ {
+ return match ($this->dateFormat) {
+ 'datetime' => $value->format($this->db->dateFormat['datetime']),
+ 'date' => $value->format($this->db->dateFormat['date']),
+ 'int' => $value->getTimestamp(),
+ default => (string) $value,
+ };
+ }
+
+ /**
+ * Set the value of the skipValidation flag.
+ *
+ * @param bool $skip Value
+ *
+ * @return $this
+ */
+ public function skipValidation(bool $skip = true)
+ {
+ $this->skipValidation = $skip;
+
+ return $this;
+ }
+
+ /**
+ * Allows to set (and reset) validation messages.
+ * It could be used when you have to change default or override current validate messages.
+ *
+ * @param array $validationMessages Value
+ *
+ * @return $this
+ */
+ public function setValidationMessages(array $validationMessages)
+ {
+ $this->validationMessages = $validationMessages;
+
+ return $this;
+ }
+
+ /**
+ * Allows to set field wise validation message.
+ * It could be used when you have to change default or override current validate messages.
+ *
+ * @param string $field Field Name
+ * @param array $fieldMessages Validation messages
+ *
+ * @return $this
+ */
+ public function setValidationMessage(string $field, array $fieldMessages)
+ {
+ $this->validationMessages[$field] = $fieldMessages;
+
+ return $this;
+ }
+
+ /**
+ * Allows to set (and reset) validation rules.
+ * It could be used when you have to change default or override current validate rules.
+ *
+ * @param array|string>|string> $validationRules Value
+ *
+ * @return $this
+ */
+ public function setValidationRules(array $validationRules)
+ {
+ $this->validationRules = $validationRules;
+
+ return $this;
+ }
+
+ /**
+ * Allows to set field wise validation rules.
+ * It could be used when you have to change default or override current validate rules.
+ *
+ * @param string $field Field Name
+ * @param array|string $fieldRules Validation rules
+ *
+ * @return $this
+ */
+ public function setValidationRule(string $field, $fieldRules)
+ {
+ $rules = $this->validationRules;
+
+ // ValidationRules can be either a string, which is the group name,
+ // or an array of rules.
+ if (is_string($rules)) {
+ $this->ensureValidation();
+
+ [$rules, $customErrors] = $this->validation->loadRuleGroup($rules);
+
+ $this->validationRules = $rules;
+ $this->validationMessages += $customErrors;
+ }
+
+ $this->validationRules[$field] = $fieldRules;
+
+ return $this;
+ }
+
+ /**
+ * Should validation rules be removed before saving?
+ * Most handy when doing updates.
+ *
+ * @param bool $choice Value
+ *
+ * @return $this
+ */
+ public function cleanRules(bool $choice = false)
+ {
+ $this->cleanValidationRules = $choice;
+
+ return $this;
+ }
+
+ /**
+ * Validate the row data against the validation rules (or the validation group)
+ * specified in the class property, $validationRules.
+ *
+ * @param array|object $row Row data
+ * @phpstan-param row_array|object $row
+ */
+ public function validate($row): bool
+ {
+ if ($this->skipValidation) {
+ return true;
+ }
+
+ $rules = $this->getValidationRules();
+
+ if ($rules === []) {
+ return true;
+ }
+
+ // Validation requires array, so cast away.
+ if (is_object($row)) {
+ $row = (array) $row;
+ }
+
+ if ($row === []) {
+ return true;
+ }
+
+ $rules = $this->cleanValidationRules ? $this->cleanValidationRules($rules, $row) : $rules;
+
+ // If no data existed that needs validation
+ // our job is done here.
+ if ($rules === []) {
+ return true;
+ }
+
+ $this->ensureValidation();
+
+ $this->validation->reset()->setRules($rules, $this->validationMessages);
+
+ return $this->validation->run($row, null, $this->DBGroup);
+ }
+
+ /**
+ * Returns the model's defined validation rules so that they
+ * can be used elsewhere, if needed.
+ *
+ * @param array $options Options
+ */
+ public function getValidationRules(array $options = []): array
+ {
+ $rules = $this->validationRules;
+
+ // ValidationRules can be either a string, which is the group name,
+ // or an array of rules.
+ if (is_string($rules)) {
+ $this->ensureValidation();
+
+ [$rules, $customErrors] = $this->validation->loadRuleGroup($rules);
+
+ $this->validationMessages += $customErrors;
+ }
+
+ if (isset($options['except'])) {
+ $rules = array_diff_key($rules, array_flip($options['except']));
+ } elseif (isset($options['only'])) {
+ $rules = array_intersect_key($rules, array_flip($options['only']));
+ }
+
+ return $rules;
+ }
+
+ protected function ensureValidation(): void
+ {
+ if ($this->validation === null) {
+ $this->validation = Services::validation(null, false);
+ }
+ }
+
+ /**
+ * Returns the model's validation messages, so they
+ * can be used elsewhere, if needed.
+ */
+ public function getValidationMessages(): array
+ {
+ return $this->validationMessages;
+ }
+
+ /**
+ * Removes any rules that apply to fields that have not been set
+ * currently so that rules don't block updating when only updating
+ * a partial row.
+ *
+ * @param array $rules Array containing field name and rule
+ * @param array $row Row data (@TODO Remove null in param type)
+ * @phpstan-param row_array $row
+ */
+ protected function cleanValidationRules(array $rules, ?array $row = null): array
+ {
+ if ($row === null || $row === []) {
+ return [];
+ }
+
+ foreach (array_keys($rules) as $field) {
+ if (! array_key_exists($field, $row)) {
+ unset($rules[$field]);
+ }
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Sets $tempAllowCallbacks value so that we can temporarily override
+ * the setting. Resets after the next method that uses triggers.
+ *
+ * @param bool $val value
+ *
+ * @return $this
+ */
+ public function allowCallbacks(bool $val = true)
+ {
+ $this->tempAllowCallbacks = $val;
+
+ return $this;
+ }
+
+ /**
+ * A simple event trigger for Model Events that allows additional
+ * data manipulation within the model. Specifically intended for
+ * usage by child models this can be used to format data,
+ * save/load related classes, etc.
+ *
+ * It is the responsibility of the callback methods to return
+ * the data itself.
+ *
+ * Each $eventData array MUST have a 'data' key with the relevant
+ * data for callback methods (like an array of key/value pairs to insert
+ * or update, an array of results, etc.)
+ *
+ * If callbacks are not allowed then returns $eventData immediately.
+ *
+ * @param string $event Event
+ * @param array $eventData Event Data
+ *
+ * @return array
+ *
+ * @throws DataException
+ */
+ protected function trigger(string $event, array $eventData)
+ {
+ // Ensure it's a valid event
+ if (! isset($this->{$event}) || $this->{$event} === []) {
+ return $eventData;
+ }
+
+ foreach ($this->{$event} as $callback) {
+ if (! method_exists($this, $callback)) {
+ throw DataException::forInvalidMethodTriggered($callback);
+ }
+
+ $eventData = $this->{$callback}($eventData);
+ }
+
+ return $eventData;
+ }
+
+ /**
+ * Sets the return type of the results to be as an associative array.
+ *
+ * @return $this
+ */
+ public function asArray()
+ {
+ $this->tempReturnType = 'array';
+
+ return $this;
+ }
+
+ /**
+ * Sets the return type to be of the specified type of object.
+ * Defaults to a simple object, but can be any class that has
+ * class vars with the same name as the collection columns,
+ * or at least allows them to be created.
+ *
+ * @param 'object'|class-string $class Class Name
+ *
+ * @return $this
+ */
+ public function asObject(string $class = 'object')
+ {
+ $this->tempReturnType = $class;
+
+ return $this;
+ }
+
+ /**
+ * Takes a class and returns an array of its public and protected
+ * properties as an array suitable for use in creates and updates.
+ * This method uses objectToRawArray() internally and does conversion
+ * to string on all Time instances
+ *
+ * @param object $object Object
+ * @param bool $onlyChanged Only Changed Property
+ * @param bool $recursive If true, inner entities will be cast as array as well
+ *
+ * @return array
+ *
+ * @throws ReflectionException
+ */
+ protected function objectToArray($object, bool $onlyChanged = true, bool $recursive = false): array
+ {
+ $properties = $this->objectToRawArray($object, $onlyChanged, $recursive);
+
+ // Convert any Time instances to appropriate $dateFormat
+ return $this->timeToString($properties);
+ }
+
+ /**
+ * Convert any Time instances to appropriate $dateFormat.
+ *
+ * @param array $properties
+ *
+ * @return array
+ */
+ protected function timeToString(array $properties): array
+ {
+ if ($properties === []) {
+ return [];
+ }
+
+ return array_map(function ($value) {
+ if ($value instanceof Time) {
+ return $this->timeToDate($value);
+ }
+
+ return $value;
+ }, $properties);
+ }
+
+ /**
+ * Takes a class and returns an array of its public and protected
+ * properties as an array with raw values.
+ *
+ * @param object $object Object
+ * @param bool $onlyChanged Only Changed Property
+ * @param bool $recursive If true, inner entities will be casted as array as well
+ *
+ * @return array Array with raw values.
+ *
+ * @throws ReflectionException
+ */
+ protected function objectToRawArray($object, bool $onlyChanged = true, bool $recursive = false): array
+ {
+ // Entity::toRawArray() returns array.
+ if (method_exists($object, 'toRawArray')) {
+ $properties = $object->toRawArray($onlyChanged, $recursive);
+ } else {
+ $mirror = new ReflectionClass($object);
+ $props = $mirror->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED);
+
+ $properties = [];
+
+ // Loop over each property,
+ // saving the name/value in a new array we can return.
+ foreach ($props as $prop) {
+ // Must make protected values accessible.
+ $prop->setAccessible(true);
+ $properties[$prop->getName()] = $prop->getValue($object);
+ }
+ }
+
+ return $properties;
+ }
+
+ /**
+ * Transform data to array.
+ *
+ * @param array|object|null $row Row data
+ * @phpstan-param row_array|object|null $row
+ * @param string $type Type of data (insert|update)
+ *
+ * @throws DataException
+ * @throws InvalidArgumentException
+ * @throws ReflectionException
+ *
+ * @used-by insert()
+ * @used-by update()
+ */
+ protected function transformDataToArray($row, string $type): array
+ {
+ if (! in_array($type, ['insert', 'update'], true)) {
+ throw new InvalidArgumentException(sprintf('Invalid type "%s" used upon transforming data to array.', $type));
+ }
+
+ if (! $this->allowEmptyInserts && ($row === null || (array) $row === [])) {
+ throw DataException::forEmptyDataset($type);
+ }
+
+ // If it validates with entire rules, all fields are needed.
+ if ($this->skipValidation === false && $this->cleanValidationRules === false) {
+ $onlyChanged = false;
+ } else {
+ $onlyChanged = ($type === 'update' && $this->updateOnlyChanged);
+ }
+
+ if ($this->useCasts()) {
+ if (is_array($row)) {
+ $row = $this->converter->toDataSource($row);
+ } elseif ($row instanceof stdClass) {
+ $row = (array) $row;
+ $row = $this->converter->toDataSource($row);
+ } elseif ($row instanceof Entity) {
+ $row = $this->converter->extract($row, $onlyChanged);
+ } elseif (is_object($row)) {
+ $row = $this->converter->extract($row, $onlyChanged);
+ }
+ }
+ // If $row is using a custom class with public or protected
+ // properties representing the collection elements, we need to grab
+ // them as an array.
+ elseif (is_object($row) && ! $row instanceof stdClass) {
+ $row = $this->objectToArray($row, $onlyChanged, true);
+ }
+
+ // If it's still a stdClass, go ahead and convert to
+ // an array so doProtectFields and other model methods
+ // don't have to do special checks.
+ if (is_object($row)) {
+ $row = (array) $row;
+ }
+
+ // If it's still empty here, means $row is no change or is empty object
+ if (! $this->allowEmptyInserts && ($row === null || $row === [])) {
+ throw DataException::forEmptyDataset($type);
+ }
+
+ // Convert any Time instances to appropriate $dateFormat
+ return $this->timeToString($row);
+ }
+
+ /**
+ * Provides the db connection and model's properties.
+ *
+ * @param string $name Name
+ *
+ * @return array|bool|float|int|object|string|null
+ */
+ public function __get(string $name)
+ {
+ if (property_exists($this, $name)) {
+ return $this->{$name};
+ }
+
+ return $this->db->{$name} ?? null;
+ }
+
+ /**
+ * Checks for the existence of properties across this model, and db connection.
+ *
+ * @param string $name Name
+ */
+ public function __isset(string $name): bool
+ {
+ if (property_exists($this, $name)) {
+ return true;
+ }
+
+ return isset($this->db->{$name});
+ }
+
+ /**
+ * Provides direct access to method in the database connection.
+ *
+ * @param string $name Name
+ * @param array $params Params
+ *
+ * @return $this|null
+ */
+ public function __call(string $name, array $params)
+ {
+ if (method_exists($this->db, $name)) {
+ return $this->db->{$name}(...$params);
+ }
+
+ return null;
+ }
+
+ /**
+ * Sets $allowEmptyInserts.
+ */
+ public function allowEmptyInserts(bool $value = true): self
+ {
+ $this->allowEmptyInserts = $value;
+
+ return $this;
+ }
+
+ /**
+ * Converts database data array to return type value.
+ *
+ * @param array $row Raw data from database
+ * @param 'array'|'object'|class-string $returnType
+ */
+ protected function convertToReturnType(array $row, string $returnType): array|object
+ {
+ if ($returnType === 'array') {
+ return $this->converter->fromDataSource($row);
+ }
+
+ if ($returnType === 'object') {
+ return (object) $this->converter->fromDataSource($row);
+ }
+
+ return $this->converter->reconstruct($returnType, $row);
+ }
+}
diff --git a/system/Boot.php b/system/Boot.php
new file mode 100644
index 0000000..54468a5
--- /dev/null
+++ b/system/Boot.php
@@ -0,0 +1,355 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter;
+
+use CodeIgniter\Cache\FactoriesCache;
+use CodeIgniter\CLI\Console;
+use CodeIgniter\Config\DotEnv;
+use Config\Autoload;
+use Config\Modules;
+use Config\Optimize;
+use Config\Paths;
+use Config\Services;
+
+/**
+ * Bootstrap for the application
+ *
+ * @codeCoverageIgnore
+ */
+class Boot
+{
+ /**
+ * Used by `public/index.php`
+ *
+ * Context
+ * web: Invoked by HTTP request
+ * php-cli: Invoked by CLI via `php public/index.php`
+ *
+ * @return int Exit code.
+ */
+ public static function bootWeb(Paths $paths): int
+ {
+ static::definePathConstants($paths);
+ if (! defined('APP_NAMESPACE')) {
+ static::loadConstants();
+ }
+ static::checkMissingExtensions();
+
+ static::loadDotEnv($paths);
+ static::defineEnvironment();
+ static::loadEnvironmentBootstrap($paths);
+
+ static::loadCommonFunctions();
+ static::loadAutoloader();
+ static::setExceptionHandler();
+ static::initializeKint();
+
+ $configCacheEnabled = class_exists(Optimize::class)
+ && (new Optimize())->configCacheEnabled;
+ if ($configCacheEnabled) {
+ $factoriesCache = static::loadConfigCache();
+ }
+
+ static::autoloadHelpers();
+
+ $app = static::initializeCodeIgniter();
+ static::runCodeIgniter($app);
+
+ if ($configCacheEnabled) {
+ static::saveConfigCache($factoriesCache);
+ }
+
+ // Exits the application, setting the exit code for CLI-based
+ // applications that might be watching.
+ return EXIT_SUCCESS;
+ }
+
+ /**
+ * Used by `spark`
+ *
+ * @return int Exit code.
+ */
+ public static function bootSpark(Paths $paths): int
+ {
+ static::definePathConstants($paths);
+ if (! defined('APP_NAMESPACE')) {
+ static::loadConstants();
+ }
+ static::checkMissingExtensions();
+
+ static::loadDotEnv($paths);
+ static::defineEnvironment();
+ static::loadEnvironmentBootstrap($paths);
+
+ static::loadCommonFunctions();
+ static::loadAutoloader();
+ static::setExceptionHandler();
+ static::initializeKint();
+ static::autoloadHelpers();
+
+ static::initializeCodeIgniter();
+ $console = static::initializeConsole();
+
+ return static::runCommand($console);
+ }
+
+ /**
+ * Used by `system/Test/bootstrap.php`
+ */
+ public static function bootTest(Paths $paths): void
+ {
+ static::loadConstants();
+ static::checkMissingExtensions();
+
+ static::loadDotEnv($paths);
+ static::loadEnvironmentBootstrap($paths, false);
+
+ static::loadCommonFunctions();
+ static::loadAutoloader();
+ static::setExceptionHandler();
+ static::initializeKint();
+ static::autoloadHelpers();
+ }
+
+ /**
+ * Used by `preload.php`
+ */
+ public static function preload(Paths $paths): void
+ {
+ static::definePathConstants($paths);
+ static::loadConstants();
+ static::defineEnvironment();
+ static::loadEnvironmentBootstrap($paths, false);
+
+ static::loadAutoloader();
+ }
+
+ /**
+ * Load environment settings from .env files into $_SERVER and $_ENV
+ */
+ protected static function loadDotEnv(Paths $paths): void
+ {
+ require_once $paths->systemDirectory . '/Config/DotEnv.php';
+ (new DotEnv($paths->appDirectory . '/../'))->load();
+ }
+
+ protected static function defineEnvironment(): void
+ {
+ if (! defined('ENVIRONMENT')) {
+ // @phpstan-ignore-next-line
+ $env = $_ENV['CI_ENVIRONMENT'] ?? $_SERVER['CI_ENVIRONMENT']
+ ?? getenv('CI_ENVIRONMENT')
+ ?: 'production';
+
+ define('ENVIRONMENT', $env);
+ }
+ }
+
+ protected static function loadEnvironmentBootstrap(Paths $paths, bool $exit = true): void
+ {
+ if (is_file($paths->appDirectory . '/Config/Boot/' . ENVIRONMENT . '.php')) {
+ require_once $paths->appDirectory . '/Config/Boot/' . ENVIRONMENT . '.php';
+
+ return;
+ }
+
+ if ($exit) {
+ header('HTTP/1.1 503 Service Unavailable.', true, 503);
+ echo 'The application environment is not set correctly.';
+
+ exit(EXIT_ERROR);
+ }
+ }
+
+ /**
+ * The path constants provide convenient access to the folders throughout
+ * the application. We have to set them up here, so they are available in
+ * the config files that are loaded.
+ */
+ protected static function definePathConstants(Paths $paths): void
+ {
+ // The path to the application directory.
+ if (! defined('APPPATH')) {
+ define('APPPATH', realpath(rtrim($paths->appDirectory, '\\/ ')) . DIRECTORY_SEPARATOR);
+ }
+
+ // The path to the project root directory. Just above APPPATH.
+ if (! defined('ROOTPATH')) {
+ define('ROOTPATH', realpath(APPPATH . '../') . DIRECTORY_SEPARATOR);
+ }
+
+ // The path to the system directory.
+ if (! defined('SYSTEMPATH')) {
+ define('SYSTEMPATH', realpath(rtrim($paths->systemDirectory, '\\/ ')) . DIRECTORY_SEPARATOR);
+ }
+
+ // The path to the writable directory.
+ if (! defined('WRITEPATH')) {
+ define('WRITEPATH', realpath(rtrim($paths->writableDirectory, '\\/ ')) . DIRECTORY_SEPARATOR);
+ }
+
+ // The path to the tests directory
+ if (! defined('TESTPATH')) {
+ define('TESTPATH', realpath(rtrim($paths->testsDirectory, '\\/ ')) . DIRECTORY_SEPARATOR);
+ }
+ }
+
+ protected static function loadConstants(): void
+ {
+ require_once APPPATH . 'Config/Constants.php';
+ }
+
+ protected static function loadCommonFunctions(): void
+ {
+ // Require app/Common.php file if exists.
+ if (is_file(APPPATH . 'Common.php')) {
+ require_once APPPATH . 'Common.php';
+ }
+
+ // Require system/Common.php
+ require_once SYSTEMPATH . 'Common.php';
+ }
+
+ /**
+ * The autoloader allows all the pieces to work together in the framework.
+ * We have to load it here, though, so that the config files can use the
+ * path constants.
+ */
+ protected static function loadAutoloader(): void
+ {
+ if (! class_exists(Autoload::class, false)) {
+ require_once SYSTEMPATH . 'Config/AutoloadConfig.php';
+ require_once APPPATH . 'Config/Autoload.php';
+ require_once SYSTEMPATH . 'Modules/Modules.php';
+ require_once APPPATH . 'Config/Modules.php';
+ }
+
+ require_once SYSTEMPATH . 'Autoloader/Autoloader.php';
+ require_once SYSTEMPATH . 'Config/BaseService.php';
+ require_once SYSTEMPATH . 'Config/Services.php';
+ require_once APPPATH . 'Config/Services.php';
+
+ // Initialize and register the loader with the SPL autoloader stack.
+ Services::autoloader()->initialize(new Autoload(), new Modules())->register();
+ }
+
+ protected static function autoloadHelpers(): void
+ {
+ Services::autoloader()->loadHelpers();
+ }
+
+ protected static function setExceptionHandler(): void
+ {
+ Services::exceptions()->initialize();
+ }
+
+ protected static function checkMissingExtensions(): void
+ {
+ if (is_file(COMPOSER_PATH)) {
+ return;
+ }
+
+ // Run this check for manual installations
+ $missingExtensions = [];
+
+ foreach ([
+ 'intl',
+ 'json',
+ 'mbstring',
+ ] as $extension) {
+ if (! extension_loaded($extension)) {
+ $missingExtensions[] = $extension;
+ }
+ }
+
+ if ($missingExtensions === []) {
+ return;
+ }
+
+ $message = sprintf(
+ 'The framework needs the following extension(s) installed and loaded: %s.',
+ implode(', ', $missingExtensions)
+ );
+
+ header('HTTP/1.1 503 Service Unavailable.', true, 503);
+ echo $message;
+
+ exit(EXIT_ERROR);
+ }
+
+ protected static function initializeKint(): void
+ {
+ Services::autoloader()->initializeKint(CI_DEBUG);
+ }
+
+ protected static function loadConfigCache(): FactoriesCache
+ {
+ $factoriesCache = new FactoriesCache();
+ $factoriesCache->load('config');
+
+ return $factoriesCache;
+ }
+
+ /**
+ * The CodeIgniter class contains the core functionality to make
+ * the application run, and does all the dirty work to get
+ * the pieces all working together.
+ */
+ protected static function initializeCodeIgniter(): CodeIgniter
+ {
+ $app = Config\Services::codeigniter();
+ $app->initialize();
+ $context = is_cli() ? 'php-cli' : 'web';
+ $app->setContext($context);
+
+ return $app;
+ }
+
+ /**
+ * Now that everything is set up, it's time to actually fire
+ * up the engines and make this app do its thang.
+ */
+ protected static function runCodeIgniter(CodeIgniter $app): void
+ {
+ $app->run();
+ }
+
+ protected static function saveConfigCache(FactoriesCache $factoriesCache): void
+ {
+ $factoriesCache->save('config');
+ }
+
+ protected static function initializeConsole(): Console
+ {
+ $console = new Console();
+
+ // Show basic information before we do anything else.
+ // @phpstan-ignore-next-line
+ if (is_int($suppress = array_search('--no-header', $_SERVER['argv'], true))) {
+ unset($_SERVER['argv'][$suppress]); // @phpstan-ignore-line
+ $suppress = true;
+ }
+
+ $console->showHeader($suppress);
+
+ return $console;
+ }
+
+ protected static function runCommand(Console $console): int
+ {
+ $exit = $console->run();
+
+ return is_int($exit) ? $exit : EXIT_SUCCESS;
+ }
+}
diff --git a/system/CLI/BaseCommand.php b/system/CLI/BaseCommand.php
new file mode 100644
index 0000000..afeb80e
--- /dev/null
+++ b/system/CLI/BaseCommand.php
@@ -0,0 +1,233 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\CLI;
+
+use Config\Exceptions;
+use Psr\Log\LoggerInterface;
+use ReflectionException;
+use Throwable;
+
+/**
+ * BaseCommand is the base class used in creating CLI commands.
+ *
+ * @property array $arguments
+ * @property Commands $commands
+ * @property string $description
+ * @property string $group
+ * @property LoggerInterface $logger
+ * @property string $name
+ * @property array $options
+ * @property string $usage
+ */
+abstract class BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group;
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * the Command's usage description
+ *
+ * @var string
+ */
+ protected $usage;
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description;
+
+ /**
+ * the Command's options description
+ *
+ * @var array
+ */
+ protected $options = [];
+
+ /**
+ * the Command's Arguments description
+ *
+ * @var array
+ */
+ protected $arguments = [];
+
+ /**
+ * The Logger to use for a command
+ *
+ * @var LoggerInterface
+ */
+ protected $logger;
+
+ /**
+ * Instance of Commands so
+ * commands can call other commands.
+ *
+ * @var Commands
+ */
+ protected $commands;
+
+ public function __construct(LoggerInterface $logger, Commands $commands)
+ {
+ $this->logger = $logger;
+ $this->commands = $commands;
+ }
+
+ /**
+ * Actually execute a command.
+ *
+ * @param array $params
+ *
+ * @return int|void
+ */
+ abstract public function run(array $params);
+
+ /**
+ * Can be used by a command to run other commands.
+ *
+ * @param array $params
+ *
+ * @return int|void
+ *
+ * @throws ReflectionException
+ */
+ protected function call(string $command, array $params = [])
+ {
+ return $this->commands->run($command, $params);
+ }
+
+ /**
+ * A simple method to display an error with line/file, in child commands.
+ *
+ * @return void
+ */
+ protected function showError(Throwable $e)
+ {
+ $exception = $e;
+ $message = $e->getMessage();
+ $config = config(Exceptions::class);
+
+ require $config->errorViewPath . '/cli/error_exception.php';
+ }
+
+ /**
+ * Show Help includes (Usage, Arguments, Description, Options).
+ *
+ * @return void
+ */
+ public function showHelp()
+ {
+ CLI::write(lang('CLI.helpUsage'), 'yellow');
+
+ if (isset($this->usage)) {
+ $usage = $this->usage;
+ } else {
+ $usage = $this->name;
+
+ if ($this->arguments !== []) {
+ $usage .= ' [arguments]';
+ }
+ }
+
+ CLI::write($this->setPad($usage, 0, 0, 2));
+
+ if (isset($this->description)) {
+ CLI::newLine();
+ CLI::write(lang('CLI.helpDescription'), 'yellow');
+ CLI::write($this->setPad($this->description, 0, 0, 2));
+ }
+
+ if ($this->arguments !== []) {
+ CLI::newLine();
+ CLI::write(lang('CLI.helpArguments'), 'yellow');
+ $length = max(array_map(strlen(...), array_keys($this->arguments)));
+
+ foreach ($this->arguments as $argument => $description) {
+ CLI::write(CLI::color($this->setPad($argument, $length, 2, 2), 'green') . $description);
+ }
+ }
+
+ if ($this->options !== []) {
+ CLI::newLine();
+ CLI::write(lang('CLI.helpOptions'), 'yellow');
+ $length = max(array_map(strlen(...), array_keys($this->options)));
+
+ foreach ($this->options as $option => $description) {
+ CLI::write(CLI::color($this->setPad($option, $length, 2, 2), 'green') . $description);
+ }
+ }
+ }
+
+ /**
+ * Pads our string out so that all titles are the same length to nicely line up descriptions.
+ *
+ * @param int $extra How many extra spaces to add at the end
+ */
+ public function setPad(string $item, int $max, int $extra = 2, int $indent = 0): string
+ {
+ $max += $extra + $indent;
+
+ return str_pad(str_repeat(' ', $indent) . $item, $max);
+ }
+
+ /**
+ * Get pad for $key => $value array output
+ *
+ * @param array $array
+ *
+ * @deprecated Use setPad() instead.
+ *
+ * @codeCoverageIgnore
+ */
+ public function getPad(array $array, int $pad): int
+ {
+ $max = 0;
+
+ foreach (array_keys($array) as $key) {
+ $max = max($max, strlen($key));
+ }
+
+ return $max + $pad;
+ }
+
+ /**
+ * Makes it simple to access our protected properties.
+ *
+ * @return array|Commands|LoggerInterface|string|null
+ */
+ public function __get(string $key)
+ {
+ return $this->{$key} ?? null;
+ }
+
+ /**
+ * Makes it simple to check our protected properties.
+ */
+ public function __isset(string $key): bool
+ {
+ return isset($this->{$key});
+ }
+}
diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php
new file mode 100644
index 0000000..bfe1511
--- /dev/null
+++ b/system/CLI/CLI.php
@@ -0,0 +1,1152 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\CLI;
+
+use CodeIgniter\CLI\Exceptions\CLIException;
+use Config\Services;
+use InvalidArgumentException;
+use Throwable;
+
+/**
+ * Set of static methods useful for CLI request handling.
+ *
+ * Portions of this code were initially from the FuelPHP Framework,
+ * version 1.7.x, and used here under the MIT license they were
+ * originally made available under. Reference: http://fuelphp.com
+ *
+ * Some of the code in this class is Windows-specific, and not
+ * possible to test using travis-ci. It has been phpunit-annotated
+ * to prevent messing up code coverage.
+ *
+ * @see \CodeIgniter\CLI\CLITest
+ */
+class CLI
+{
+ /**
+ * Is the readline library on the system?
+ *
+ * @var bool
+ *
+ * @deprecated 4.4.2 Should be protected, and no longer used.
+ * @TODO Fix to camelCase in the next major version.
+ */
+ public static $readline_support = false;
+
+ /**
+ * The message displayed at prompts.
+ *
+ * @var string
+ *
+ * @deprecated 4.4.2 Should be protected.
+ * @TODO Fix to camelCase in the next major version.
+ */
+ public static $wait_msg = 'Press any key to continue...';
+
+ /**
+ * Has the class already been initialized?
+ *
+ * @var bool
+ */
+ protected static $initialized = false;
+
+ /**
+ * Foreground color list
+ *
+ * @var array
+ *
+ * @TODO Fix to camelCase in the next major version.
+ */
+ protected static $foreground_colors = [
+ 'black' => '0;30',
+ 'dark_gray' => '1;30',
+ 'blue' => '0;34',
+ 'dark_blue' => '0;34',
+ 'light_blue' => '1;34',
+ 'green' => '0;32',
+ 'light_green' => '1;32',
+ 'cyan' => '0;36',
+ 'light_cyan' => '1;36',
+ 'red' => '0;31',
+ 'light_red' => '1;31',
+ 'purple' => '0;35',
+ 'light_purple' => '1;35',
+ 'yellow' => '0;33',
+ 'light_yellow' => '1;33',
+ 'light_gray' => '0;37',
+ 'white' => '1;37',
+ ];
+
+ /**
+ * Background color list
+ *
+ * @var array
+ *
+ * @TODO Fix to camelCase in the next major version.
+ */
+ protected static $background_colors = [
+ 'black' => '40',
+ 'red' => '41',
+ 'green' => '42',
+ 'yellow' => '43',
+ 'blue' => '44',
+ 'magenta' => '45',
+ 'cyan' => '46',
+ 'light_gray' => '47',
+ ];
+
+ /**
+ * List of array segments.
+ *
+ * @var array
+ */
+ protected static $segments = [];
+
+ /**
+ * @var array
+ */
+ protected static $options = [];
+
+ /**
+ * Helps track internally whether the last
+ * output was a "write" or a "print" to
+ * keep the output clean and as expected.
+ *
+ * @var string|null
+ */
+ protected static $lastWrite;
+
+ /**
+ * Height of the CLI window
+ *
+ * @var int|null
+ */
+ protected static $height;
+
+ /**
+ * Width of the CLI window
+ *
+ * @var int|null
+ */
+ protected static $width;
+
+ /**
+ * Whether the current stream supports colored output.
+ *
+ * @var bool
+ */
+ protected static $isColored = false;
+
+ /**
+ * Input and Output for CLI.
+ */
+ protected static ?InputOutput $io = null;
+
+ /**
+ * Static "constructor".
+ *
+ * @return void
+ */
+ public static function init()
+ {
+ if (is_cli()) {
+ // Readline is an extension for PHP that makes interactivity with PHP
+ // much more bash-like.
+ // http://www.php.net/manual/en/readline.installation.php
+ static::$readline_support = extension_loaded('readline');
+
+ // clear segments & options to keep testing clean
+ static::$segments = [];
+ static::$options = [];
+
+ // Check our stream resource for color support
+ static::$isColored = static::hasColorSupport(STDOUT);
+
+ static::parseCommandLine();
+
+ static::$initialized = true;
+ } elseif (! defined('STDOUT')) {
+ // If the command is being called from a controller
+ // we need to define STDOUT ourselves
+ // For "! defined('STDOUT')" see: https://github.com/codeigniter4/CodeIgniter4/issues/7047
+ define('STDOUT', 'php://output'); // @codeCoverageIgnore
+ }
+
+ static::resetInputOutput();
+ }
+
+ /**
+ * Get input from the shell, using readline or the standard STDIN
+ *
+ * Named options must be in the following formats:
+ * php index.php user -v --v -name=John --name=John
+ *
+ * @param string|null $prefix You may specify a string with which to prompt the user.
+ */
+ public static function input(?string $prefix = null): string
+ {
+ return static::$io->input($prefix);
+ }
+
+ /**
+ * Asks the user for input.
+ *
+ * Usage:
+ *
+ * // Takes any input
+ * $color = CLI::prompt('What is your favorite color?');
+ *
+ * // Takes any input, but offers default
+ * $color = CLI::prompt('What is your favourite color?', 'white');
+ *
+ * // Will validate options with the in_list rule and accept only if one of the list
+ * $color = CLI::prompt('What is your favourite color?', array('red','blue'));
+ *
+ * // Do not provide options but requires a valid email
+ * $email = CLI::prompt('What is your email?', null, 'required|valid_email');
+ *
+ * @param string $field Output "field" question
+ * @param list|string $options String to a default value, array to a list of options (the first option will be the default value)
+ * @param array|string|null $validation Validation rules
+ *
+ * @return string The user input
+ */
+ public static function prompt(string $field, $options = null, $validation = null): string
+ {
+ $extraOutput = '';
+ $default = '';
+
+ if ($validation && ! is_array($validation) && ! is_string($validation)) {
+ throw new InvalidArgumentException('$rules can only be of type string|array');
+ }
+
+ if (! is_array($validation)) {
+ $validation = $validation ? explode('|', $validation) : [];
+ }
+
+ if (is_string($options)) {
+ $extraOutput = ' [' . static::color($options, 'green') . ']';
+ $default = $options;
+ }
+
+ if (is_array($options) && $options !== []) {
+ $opts = $options;
+ $extraOutputDefault = static::color((string) $opts[0], 'green');
+
+ unset($opts[0]);
+
+ if ($opts === []) {
+ $extraOutput = $extraOutputDefault;
+ } else {
+ $extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $opts) . ']';
+ $validation[] = 'in_list[' . implode(', ', $options) . ']';
+ }
+
+ $default = $options[0];
+ }
+
+ static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': ');
+
+ // Read the input from keyboard.
+ $input = trim(static::$io->input());
+ $input = ($input === '') ? (string) $default : $input;
+
+ if ($validation !== []) {
+ while (! static::validate('"' . trim($field) . '"', $input, $validation)) {
+ $input = static::prompt($field, $options, $validation);
+ }
+ }
+
+ return $input;
+ }
+
+ /**
+ * prompt(), but based on the option's key
+ *
+ * @param array|string $text Output "field" text or an one or two value array where the first value is the text before listing the options
+ * and the second value the text before asking to select one option. Provide empty string to omit
+ * @param array $options A list of options (array(key => description)), the first option will be the default value
+ * @param array|string|null $validation Validation rules
+ *
+ * @return string The selected key of $options
+ */
+ public static function promptByKey($text, array $options, $validation = null): string
+ {
+ if (is_string($text)) {
+ $text = [$text];
+ } elseif (! is_array($text)) {
+ throw new InvalidArgumentException('$text can only be of type string|array');
+ }
+
+ CLI::isZeroOptions($options);
+
+ if ($line = array_shift($text)) {
+ CLI::write($line);
+ }
+
+ CLI::printKeysAndValues($options);
+
+ return static::prompt(PHP_EOL . array_shift($text), array_keys($options), $validation);
+ }
+
+ /**
+ * This method is the same as promptByKey(), but this method supports multiple keys, separated by commas.
+ *
+ * @param string $text Output "field" text or an one or two value array where the first value is the text before listing the options
+ * and the second value the text before asking to select one option. Provide empty string to omit
+ * @param array $options A list of options (array(key => description)), the first option will be the default value
+ *
+ * @return array The selected key(s) and value(s) of $options
+ */
+ public static function promptByMultipleKeys(string $text, array $options): array
+ {
+ CLI::isZeroOptions($options);
+
+ $extraOutputDefault = static::color('0', 'green');
+ $opts = $options;
+ unset($opts[0]);
+
+ if ($opts === []) {
+ $extraOutput = $extraOutputDefault;
+ } else {
+ $optsKey = [];
+
+ foreach (array_keys($opts) as $key) {
+ $optsKey[] = $key;
+ }
+ $extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $optsKey) . ']';
+ $extraOutput = 'You can specify multiple values separated by commas.' . PHP_EOL . $extraOutput;
+ }
+
+ CLI::write($text);
+ CLI::printKeysAndValues($options);
+ CLI::newLine();
+
+ $input = static::prompt($extraOutput);
+ $input = ($input === '') ? '0' : $input; // 0 is default
+
+ // validation
+ while (true) {
+ $pattern = preg_match_all('/^\d+(,\d+)*$/', trim($input));
+
+ // separate input by comma and convert all to an int[]
+ $inputToArray = array_map(static fn ($value) => (int) $value, explode(',', $input));
+ // find max from key of $options
+ $maxOptions = array_key_last($options);
+ // find max from input
+ $maxInput = max($inputToArray);
+
+ // return the prompt again if $input contain(s) non-numeric character, except a comma.
+ // And if max from $options less than max from input,
+ // it means user tried to access null value in $options
+ if (! $pattern || $maxOptions < $maxInput) {
+ static::error('Please select correctly.');
+ CLI::newLine();
+
+ $input = static::prompt($extraOutput);
+ $input = ($input === '') ? '0' : $input;
+ } else {
+ break;
+ }
+ }
+
+ $input = [];
+
+ foreach ($options as $key => $description) {
+ foreach ($inputToArray as $inputKey) {
+ if ($key === $inputKey) {
+ $input[$key] = $description;
+ }
+ }
+ }
+
+ return $input;
+ }
+
+ // --------------------------------------------------------------------
+ // Utility for promptBy...
+ // --------------------------------------------------------------------
+
+ /**
+ * Validation for $options in promptByKey() and promptByMultipleKeys(). Return an error if $options is an empty array.
+ */
+ private static function isZeroOptions(array $options): void
+ {
+ if ($options === []) {
+ throw new InvalidArgumentException('No options to select from were provided');
+ }
+ }
+
+ /**
+ * Print each key and value one by one
+ */
+ private static function printKeysAndValues(array $options): void
+ {
+ // +2 for the square brackets around the key
+ $keyMaxLength = max(array_map(mb_strwidth(...), array_keys($options))) + 2;
+
+ foreach ($options as $key => $description) {
+ $name = str_pad(' [' . $key . '] ', $keyMaxLength + 4, ' ');
+ CLI::write(CLI::color($name, 'green') . CLI::wrap($description, 125, $keyMaxLength + 4));
+ }
+ }
+
+ // --------------------------------------------------------------------
+ // End Utility for promptBy...
+ // --------------------------------------------------------------------
+
+ /**
+ * Validate one prompt "field" at a time
+ *
+ * @param string $field Prompt "field" output
+ * @param string $value Input value
+ * @param array|string $rules Validation rules
+ */
+ protected static function validate(string $field, string $value, $rules): bool
+ {
+ $label = $field;
+ $field = 'temp';
+ $validation = Services::validation(null, false);
+ $validation->setRules([
+ $field => [
+ 'label' => $label,
+ 'rules' => $rules,
+ ],
+ ]);
+ $validation->run([$field => $value]);
+
+ if ($validation->hasError($field)) {
+ static::error($validation->getError($field));
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Outputs a string to the CLI without any surrounding newlines.
+ * Useful for showing repeating elements on a single line.
+ *
+ * @return void
+ */
+ public static function print(string $text = '', ?string $foreground = null, ?string $background = null)
+ {
+ if ($foreground || $background) {
+ $text = static::color($text, $foreground, $background);
+ }
+
+ static::$lastWrite = null;
+
+ static::fwrite(STDOUT, $text);
+ }
+
+ /**
+ * Outputs a string to the cli on its own line.
+ *
+ * @return void
+ */
+ public static function write(string $text = '', ?string $foreground = null, ?string $background = null)
+ {
+ if ($foreground || $background) {
+ $text = static::color($text, $foreground, $background);
+ }
+
+ if (static::$lastWrite !== 'write') {
+ $text = PHP_EOL . $text;
+ static::$lastWrite = 'write';
+ }
+
+ static::fwrite(STDOUT, $text . PHP_EOL);
+ }
+
+ /**
+ * Outputs an error to the CLI using STDERR instead of STDOUT
+ *
+ * @return void
+ */
+ public static function error(string $text, string $foreground = 'light_red', ?string $background = null)
+ {
+ // Check color support for STDERR
+ $stdout = static::$isColored;
+ static::$isColored = static::hasColorSupport(STDERR);
+
+ if ($foreground || $background) {
+ $text = static::color($text, $foreground, $background);
+ }
+
+ static::fwrite(STDERR, $text . PHP_EOL);
+
+ // return STDOUT color support
+ static::$isColored = $stdout;
+ }
+
+ /**
+ * Beeps a certain number of times.
+ *
+ * @param int $num The number of times to beep
+ *
+ * @return void
+ */
+ public static function beep(int $num = 1)
+ {
+ echo str_repeat("\x07", $num);
+ }
+
+ /**
+ * Waits a certain number of seconds, optionally showing a wait message and
+ * waiting for a key press.
+ *
+ * @param int $seconds Number of seconds
+ * @param bool $countdown Show a countdown or not
+ *
+ * @return void
+ */
+ public static function wait(int $seconds, bool $countdown = false)
+ {
+ if ($countdown === true) {
+ $time = $seconds;
+
+ while ($time > 0) {
+ static::fwrite(STDOUT, $time . '... ');
+ sleep(1);
+ $time--;
+ }
+
+ static::write();
+ } elseif ($seconds > 0) {
+ sleep($seconds);
+ } else {
+ static::write(static::$wait_msg);
+ static::$io->input();
+ }
+ }
+
+ /**
+ * if operating system === windows
+ *
+ * @deprecated 4.3.0 Use `is_windows()` instead
+ */
+ public static function isWindows(): bool
+ {
+ return is_windows();
+ }
+
+ /**
+ * Enter a number of empty lines
+ *
+ * @return void
+ */
+ public static function newLine(int $num = 1)
+ {
+ // Do it once or more, write with empty string gives us a new line
+ for ($i = 0; $i < $num; $i++) {
+ static::write();
+ }
+ }
+
+ /**
+ * Clears the screen of output
+ *
+ * @return void
+ */
+ public static function clearScreen()
+ {
+ // Unix systems, and Windows with VT100 Terminal support (i.e. Win10)
+ // can handle CSI sequences. For lower than Win10 we just shove in 40 new lines.
+ is_windows() && ! static::streamSupports('sapi_windows_vt100_support', STDOUT)
+ ? static::newLine(40)
+ : static::fwrite(STDOUT, "\033[H\033[2J");
+ }
+
+ /**
+ * Returns the given text with the correct color codes for a foreground and
+ * optionally a background color.
+ *
+ * @param string $text The text to color
+ * @param string $foreground The foreground color
+ * @param string|null $background The background color
+ * @param string|null $format Other formatting to apply. Currently only 'underline' is understood
+ *
+ * @return string The color coded string
+ */
+ public static function color(string $text, string $foreground, ?string $background = null, ?string $format = null): string
+ {
+ if (! static::$isColored || $text === '') {
+ return $text;
+ }
+
+ if (! array_key_exists($foreground, static::$foreground_colors)) {
+ throw CLIException::forInvalidColor('foreground', $foreground);
+ }
+
+ if ($background !== null && ! array_key_exists($background, static::$background_colors)) {
+ throw CLIException::forInvalidColor('background', $background);
+ }
+
+ $newText = '';
+
+ // Detect if color method was already in use with this text
+ if (str_contains($text, "\033[0m")) {
+ $pattern = '/\\033\\[0;.+?\\033\\[0m/u';
+
+ preg_match_all($pattern, $text, $matches);
+ $coloredStrings = $matches[0];
+
+ // No colored string found. Invalid strings with no `\033[0;??`.
+ if ($coloredStrings === []) {
+ return $newText . self::getColoredText($text, $foreground, $background, $format);
+ }
+
+ $nonColoredText = preg_replace(
+ $pattern,
+ '<<__colored_string__>>',
+ $text
+ );
+ $nonColoredChunks = preg_split(
+ '/<<__colored_string__>>/u',
+ $nonColoredText
+ );
+
+ foreach ($nonColoredChunks as $i => $chunk) {
+ if ($chunk !== '') {
+ $newText .= self::getColoredText($chunk, $foreground, $background, $format);
+ }
+
+ if (isset($coloredStrings[$i])) {
+ $newText .= $coloredStrings[$i];
+ }
+ }
+ } else {
+ $newText .= self::getColoredText($text, $foreground, $background, $format);
+ }
+
+ return $newText;
+ }
+
+ private static function getColoredText(string $text, string $foreground, ?string $background, ?string $format): string
+ {
+ $string = "\033[" . static::$foreground_colors[$foreground] . 'm';
+
+ if ($background !== null) {
+ $string .= "\033[" . static::$background_colors[$background] . 'm';
+ }
+
+ if ($format === 'underline') {
+ $string .= "\033[4m";
+ }
+
+ return $string . $text . "\033[0m";
+ }
+
+ /**
+ * Get the number of characters in string having encoded characters
+ * and ignores styles set by the color() function
+ */
+ public static function strlen(?string $string): int
+ {
+ if ($string === null) {
+ return 0;
+ }
+
+ foreach (static::$foreground_colors as $color) {
+ $string = strtr($string, ["\033[" . $color . 'm' => '']);
+ }
+
+ foreach (static::$background_colors as $color) {
+ $string = strtr($string, ["\033[" . $color . 'm' => '']);
+ }
+
+ $string = strtr($string, ["\033[4m" => '', "\033[0m" => '']);
+
+ return mb_strwidth($string);
+ }
+
+ /**
+ * Checks whether the current stream resource supports or
+ * refers to a valid terminal type device.
+ *
+ * @param resource $resource
+ */
+ public static function streamSupports(string $function, $resource): bool
+ {
+ if (ENVIRONMENT === 'testing') {
+ // In the current setup of the tests we cannot fully check
+ // if the stream supports the function since we are using
+ // filtered streams.
+ return function_exists($function);
+ }
+
+ return function_exists($function) && @$function($resource); // @codeCoverageIgnore
+ }
+
+ /**
+ * Returns true if the stream resource supports colors.
+ *
+ * This is tricky on Windows, because Cygwin, Msys2 etc. emulate pseudo
+ * terminals via named pipes, so we can only check the environment.
+ *
+ * Reference: https://github.com/composer/xdebug-handler/blob/master/src/Process.php
+ *
+ * @param resource $resource
+ */
+ public static function hasColorSupport($resource): bool
+ {
+ // Follow https://no-color.org/
+ if (isset($_SERVER['NO_COLOR']) || getenv('NO_COLOR') !== false) {
+ return false;
+ }
+
+ if (getenv('TERM_PROGRAM') === 'Hyper') {
+ return true;
+ }
+
+ if (is_windows()) {
+ // @codeCoverageIgnoreStart
+ return static::streamSupports('sapi_windows_vt100_support', $resource)
+ || isset($_SERVER['ANSICON'])
+ || getenv('ANSICON') !== false
+ || getenv('ConEmuANSI') === 'ON'
+ || getenv('TERM') === 'xterm';
+ // @codeCoverageIgnoreEnd
+ }
+
+ return static::streamSupports('stream_isatty', $resource);
+ }
+
+ /**
+ * Attempts to determine the width of the viewable CLI window.
+ */
+ public static function getWidth(int $default = 80): int
+ {
+ if (static::$width === null) {
+ static::generateDimensions();
+ }
+
+ return static::$width ?: $default;
+ }
+
+ /**
+ * Attempts to determine the height of the viewable CLI window.
+ */
+ public static function getHeight(int $default = 32): int
+ {
+ if (static::$height === null) {
+ static::generateDimensions();
+ }
+
+ return static::$height ?: $default;
+ }
+
+ /**
+ * Populates the CLI's dimensions.
+ *
+ * @return void
+ */
+ public static function generateDimensions()
+ {
+ try {
+ if (is_windows()) {
+ // Shells such as `Cygwin` and `Git bash` returns incorrect values
+ // when executing `mode CON`, so we use `tput` instead
+ if (getenv('TERM') || (($shell = getenv('SHELL')) && preg_match('/(?:bash|zsh)(?:\.exe)?$/', $shell))) {
+ static::$height = (int) exec('tput lines');
+ static::$width = (int) exec('tput cols');
+ } else {
+ $return = -1;
+ $output = [];
+ exec('mode CON', $output, $return);
+
+ // Look for the next lines ending in ": "
+ // Searching for "Columns:" or "Lines:" will fail on non-English locales
+ if ($return === 0 && $output && preg_match('/:\s*(\d+)\n[^:]+:\s*(\d+)\n/', implode("\n", $output), $matches)) {
+ static::$height = (int) $matches[1];
+ static::$width = (int) $matches[2];
+ }
+ }
+ } elseif (($size = exec('stty size')) && preg_match('/(\d+)\s+(\d+)/', $size, $matches)) {
+ static::$height = (int) $matches[1];
+ static::$width = (int) $matches[2];
+ } else {
+ static::$height = (int) exec('tput lines');
+ static::$width = (int) exec('tput cols');
+ }
+ } catch (Throwable $e) {
+ // Reset the dimensions so that the default values will be returned later.
+ // Then let the developer know of the error.
+ static::$height = null;
+ static::$width = null;
+ log_message('error', (string) $e);
+ }
+ }
+
+ /**
+ * Displays a progress bar on the CLI. You must call it repeatedly
+ * to update it. Set $thisStep = false to erase the progress bar.
+ *
+ * @param bool|int $thisStep
+ *
+ * @return void
+ */
+ public static function showProgress($thisStep = 1, int $totalSteps = 10)
+ {
+ static $inProgress = false;
+
+ // restore cursor position when progress is continuing.
+ if ($inProgress !== false && $inProgress <= $thisStep) {
+ static::fwrite(STDOUT, "\033[1A");
+ }
+ $inProgress = $thisStep;
+
+ if ($thisStep !== false) {
+ // Don't allow div by zero or negative numbers....
+ $thisStep = abs($thisStep);
+ $totalSteps = $totalSteps < 1 ? 1 : $totalSteps;
+
+ $percent = (int) (($thisStep / $totalSteps) * 100);
+ $step = (int) round($percent / 10);
+
+ // Write the progress bar
+ static::fwrite(STDOUT, "[\033[32m" . str_repeat('#', $step) . str_repeat('.', 10 - $step) . "\033[0m]");
+ // Textual representation...
+ static::fwrite(STDOUT, sprintf(' %3d%% Complete', $percent) . PHP_EOL);
+ } else {
+ static::fwrite(STDOUT, "\007");
+ }
+ }
+
+ /**
+ * Takes a string and writes it to the command line, wrapping to a maximum
+ * width. If no maximum width is specified, will wrap to the window's max
+ * width.
+ *
+ * If an int is passed into $pad_left, then all strings after the first
+ * will pad with that many spaces to the left. Useful when printing
+ * short descriptions that need to start on an existing line.
+ */
+ public static function wrap(?string $string = null, int $max = 0, int $padLeft = 0): string
+ {
+ if ($string === null || $string === '') {
+ return '';
+ }
+
+ if ($max === 0) {
+ $max = self::getWidth();
+ }
+
+ if (self::getWidth() < $max) {
+ $max = self::getWidth();
+ }
+
+ $max -= $padLeft;
+
+ $lines = wordwrap($string, $max, PHP_EOL);
+
+ if ($padLeft > 0) {
+ $lines = explode(PHP_EOL, $lines);
+
+ $first = true;
+
+ array_walk($lines, static function (&$line) use ($padLeft, &$first): void {
+ if (! $first) {
+ $line = str_repeat(' ', $padLeft) . $line;
+ } else {
+ $first = false;
+ }
+ });
+
+ $lines = implode(PHP_EOL, $lines);
+ }
+
+ return $lines;
+ }
+
+ // --------------------------------------------------------------------
+ // Command-Line 'URI' support
+ // --------------------------------------------------------------------
+
+ /**
+ * Parses the command line it was called from and collects all
+ * options and valid segments.
+ *
+ * @return void
+ */
+ protected static function parseCommandLine()
+ {
+ $args = $_SERVER['argv'] ?? [];
+ array_shift($args); // scrap invoking program
+ $optionValue = false;
+
+ foreach ($args as $i => $arg) {
+ // If there's no "-" at the beginning, then
+ // this is probably an argument or an option value
+ if (mb_strpos($arg, '-') !== 0) {
+ if ($optionValue) {
+ // We have already included this in the previous
+ // iteration, so reset this flag
+ $optionValue = false;
+ } else {
+ // Yup, it's a segment
+ static::$segments[] = $arg;
+ }
+
+ continue;
+ }
+
+ $arg = ltrim($arg, '-');
+ $value = null;
+
+ if (isset($args[$i + 1]) && mb_strpos($args[$i + 1], '-') !== 0) {
+ $value = $args[$i + 1];
+ $optionValue = true;
+ }
+
+ static::$options[$arg] = $value;
+ }
+ }
+
+ /**
+ * Returns the command line string portions of the arguments, minus
+ * any options, as a string. This is used to pass along to the main
+ * CodeIgniter application.
+ */
+ public static function getURI(): string
+ {
+ return implode('/', static::$segments);
+ }
+
+ /**
+ * Returns an individual segment.
+ *
+ * This ignores any options that might have been dispersed between
+ * valid segments in the command:
+ *
+ * // segment(3) is 'three', not '-f' or 'anOption'
+ * > php spark one two -f anOption three
+ *
+ * **IMPORTANT:** The index here is one-based instead of zero-based.
+ *
+ * @return string|null
+ */
+ public static function getSegment(int $index)
+ {
+ return static::$segments[$index - 1] ?? null;
+ }
+
+ /**
+ * Returns the raw array of segments found.
+ */
+ public static function getSegments(): array
+ {
+ return static::$segments;
+ }
+
+ /**
+ * Gets a single command-line option. Returns TRUE if the option
+ * exists, but doesn't have a value, and is simply acting as a flag.
+ *
+ * @return string|true|null
+ */
+ public static function getOption(string $name)
+ {
+ if (! array_key_exists($name, static::$options)) {
+ return null;
+ }
+
+ // If the option didn't have a value, simply return TRUE
+ // so they know it was set, otherwise return the actual value.
+ $val = static::$options[$name] ?? true;
+
+ return $val;
+ }
+
+ /**
+ * Returns the raw array of options found.
+ */
+ public static function getOptions(): array
+ {
+ return static::$options;
+ }
+
+ /**
+ * Returns the options as a string, suitable for passing along on
+ * the CLI to other commands.
+ *
+ * @param bool $useLongOpts Use '--' for long options?
+ * @param bool $trim Trim final string output?
+ */
+ public static function getOptionString(bool $useLongOpts = false, bool $trim = false): string
+ {
+ if (static::$options === []) {
+ return '';
+ }
+
+ $out = '';
+
+ foreach (static::$options as $name => $value) {
+ if ($useLongOpts && mb_strlen($name) > 1) {
+ $out .= "--{$name} ";
+ } else {
+ $out .= "-{$name} ";
+ }
+
+ if ($value === null) {
+ continue;
+ }
+
+ if (mb_strpos($value, ' ') !== false) {
+ $out .= "\"{$value}\" ";
+ } elseif ($value !== null) {
+ $out .= "{$value} ";
+ }
+ }
+
+ return $trim ? trim($out) : $out;
+ }
+
+ /**
+ * Returns a well formatted table
+ *
+ * @param array $tbody List of rows
+ * @param array $thead List of columns
+ *
+ * @return void
+ */
+ public static function table(array $tbody, array $thead = [])
+ {
+ // All the rows in the table will be here until the end
+ $tableRows = [];
+
+ // We need only indexes and not keys
+ if ($thead !== []) {
+ $tableRows[] = array_values($thead);
+ }
+
+ foreach ($tbody as $tr) {
+ $tableRows[] = array_values($tr);
+ }
+
+ // Yes, it really is necessary to know this count
+ $totalRows = count($tableRows);
+
+ // Store all columns lengths
+ // $all_cols_lengths[row][column] = length
+ $allColsLengths = [];
+
+ // Store maximum lengths by column
+ // $max_cols_lengths[column] = length
+ $maxColsLengths = [];
+
+ // Read row by row and define the longest columns
+ for ($row = 0; $row < $totalRows; $row++) {
+ $column = 0; // Current column index
+
+ foreach ($tableRows[$row] as $col) {
+ // Sets the size of this column in the current row
+ $allColsLengths[$row][$column] = static::strlen((string) $col);
+
+ // If the current column does not have a value among the larger ones
+ // or the value of this is greater than the existing one
+ // then, now, this assumes the maximum length
+ if (! isset($maxColsLengths[$column]) || $allColsLengths[$row][$column] > $maxColsLengths[$column]) {
+ $maxColsLengths[$column] = $allColsLengths[$row][$column];
+ }
+
+ // We can go check the size of the next column...
+ $column++;
+ }
+ }
+
+ // Read row by row and add spaces at the end of the columns
+ // to match the exact column length
+ for ($row = 0; $row < $totalRows; $row++) {
+ $column = 0;
+
+ foreach ($tableRows[$row] as $col) {
+ $diff = $maxColsLengths[$column] - static::strlen((string) $col);
+
+ if ($diff !== 0) {
+ $tableRows[$row][$column] .= str_repeat(' ', $diff);
+ }
+
+ $column++;
+ }
+ }
+
+ $table = '';
+ $cols = '';
+
+ // Joins columns and append the well formatted rows to the table
+ for ($row = 0; $row < $totalRows; $row++) {
+ // Set the table border-top
+ if ($row === 0) {
+ $cols = '+';
+
+ foreach ($tableRows[$row] as $col) {
+ $cols .= str_repeat('-', static::strlen((string) $col) + 2) . '+';
+ }
+ $table .= $cols . PHP_EOL;
+ }
+
+ // Set the columns borders
+ $table .= '| ' . implode(' | ', $tableRows[$row]) . ' |' . PHP_EOL;
+
+ // Set the thead and table borders-bottom
+ if (($row === 0 && $thead !== []) || ($row + 1 === $totalRows)) {
+ $table .= $cols . PHP_EOL;
+ }
+ }
+
+ static::write($table);
+ }
+
+ /**
+ * While the library is intended for use on CLI commands,
+ * commands can be called from controllers and elsewhere
+ * so we need a way to allow them to still work.
+ *
+ * For now, just echo the content, but look into a better
+ * solution down the road.
+ *
+ * @param resource $handle
+ *
+ * @return void
+ */
+ protected static function fwrite($handle, string $string)
+ {
+ static::$io->fwrite($handle, $string);
+ }
+
+ /**
+ * Testing purpose only
+ *
+ * @testTag
+ */
+ public static function setInputOutput(InputOutput $io): void
+ {
+ static::$io = $io;
+ }
+
+ /**
+ * Testing purpose only
+ *
+ * @testTag
+ */
+ public static function resetInputOutput(): void
+ {
+ static::$io = new InputOutput();
+ }
+}
+
+// Ensure the class is initialized. Done outside of code coverage
+CLI::init(); // @codeCoverageIgnore
diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php
new file mode 100644
index 0000000..2115714
--- /dev/null
+++ b/system/CLI/Commands.php
@@ -0,0 +1,195 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\CLI;
+
+use CodeIgniter\Autoloader\FileLocatorInterface;
+use CodeIgniter\Events\Events;
+use CodeIgniter\Log\Logger;
+use ReflectionClass;
+use ReflectionException;
+
+/**
+ * Core functionality for running, listing, etc commands.
+ */
+class Commands
+{
+ /**
+ * The found commands.
+ *
+ * @var array
+ */
+ protected $commands = [];
+
+ /**
+ * Logger instance.
+ *
+ * @var Logger
+ */
+ protected $logger;
+
+ /**
+ * Constructor
+ *
+ * @param Logger|null $logger
+ */
+ public function __construct($logger = null)
+ {
+ $this->logger = $logger ?? service('logger');
+ $this->discoverCommands();
+ }
+
+ /**
+ * Runs a command given
+ *
+ * @return int|void Exit code
+ */
+ public function run(string $command, array $params)
+ {
+ if (! $this->verifyCommand($command, $this->commands)) {
+ return;
+ }
+
+ // The file would have already been loaded during the
+ // createCommandList function...
+ $className = $this->commands[$command]['class'];
+ $class = new $className($this->logger, $this);
+
+ Events::trigger('pre_command');
+
+ $exit = $class->run($params);
+
+ Events::trigger('post_command');
+
+ return $exit;
+ }
+
+ /**
+ * Provide access to the list of commands.
+ *
+ * @return array
+ */
+ public function getCommands()
+ {
+ return $this->commands;
+ }
+
+ /**
+ * Discovers all commands in the framework and within user code,
+ * and collects instances of them to work with.
+ *
+ * @return void
+ */
+ public function discoverCommands()
+ {
+ if ($this->commands !== []) {
+ return;
+ }
+
+ /** @var FileLocatorInterface $locator */
+ $locator = service('locator');
+ $files = $locator->listFiles('Commands/');
+
+ // If no matching command files were found, bail
+ // This should never happen in unit testing.
+ if ($files === []) {
+ return; // @codeCoverageIgnore
+ }
+
+ // Loop over each file checking to see if a command with that
+ // alias exists in the class.
+ foreach ($files as $file) {
+ $className = $locator->findQualifiedNameFromPath($file);
+
+ if ($className === false || ! class_exists($className)) {
+ continue;
+ }
+
+ try {
+ $class = new ReflectionClass($className);
+
+ if (! $class->isInstantiable() || ! $class->isSubclassOf(BaseCommand::class)) {
+ continue;
+ }
+
+ /** @var BaseCommand $class */
+ $class = new $className($this->logger, $this);
+
+ if (isset($class->group) && ! isset($this->commands[$class->name])) {
+ $this->commands[$class->name] = [
+ 'class' => $className,
+ 'file' => $file,
+ 'group' => $class->group,
+ 'description' => $class->description,
+ ];
+ }
+
+ unset($class);
+ } catch (ReflectionException $e) {
+ $this->logger->error($e->getMessage());
+ }
+ }
+
+ asort($this->commands);
+ }
+
+ /**
+ * Verifies if the command being sought is found
+ * in the commands list.
+ */
+ public function verifyCommand(string $command, array $commands): bool
+ {
+ if (isset($commands[$command])) {
+ return true;
+ }
+
+ $message = lang('CLI.commandNotFound', [$command]);
+
+ $alternatives = $this->getCommandAlternatives($command, $commands);
+ if ($alternatives !== []) {
+ if (count($alternatives) === 1) {
+ $message .= "\n\n" . lang('CLI.altCommandSingular') . "\n ";
+ } else {
+ $message .= "\n\n" . lang('CLI.altCommandPlural') . "\n ";
+ }
+
+ $message .= implode("\n ", $alternatives);
+ }
+
+ CLI::error($message);
+ CLI::newLine();
+
+ return false;
+ }
+
+ /**
+ * Finds alternative of `$name` among collection
+ * of commands.
+ */
+ protected function getCommandAlternatives(string $name, array $collection): array
+ {
+ $alternatives = [];
+
+ foreach (array_keys($collection) as $commandName) {
+ $lev = levenshtein($name, $commandName);
+
+ if ($lev <= strlen($commandName) / 3 || str_contains($commandName, $name)) {
+ $alternatives[$commandName] = $lev;
+ }
+ }
+
+ ksort($alternatives, SORT_NATURAL | SORT_FLAG_CASE);
+
+ return array_keys($alternatives);
+ }
+}
diff --git a/system/CLI/Console.php b/system/CLI/Console.php
new file mode 100644
index 0000000..725193d
--- /dev/null
+++ b/system/CLI/Console.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\CLI;
+
+use CodeIgniter\CodeIgniter;
+use Config\App;
+use Config\Services;
+use Exception;
+
+/**
+ * Console
+ *
+ * @see \CodeIgniter\CLI\ConsoleTest
+ */
+class Console
+{
+ /**
+ * Runs the current command discovered on the CLI.
+ *
+ * @return int|void Exit code
+ *
+ * @throws Exception
+ */
+ public function run()
+ {
+ // Create CLIRequest
+ $appConfig = config(App::class);
+ Services::createRequest($appConfig, true);
+ // Load Routes
+ Services::routes()->loadRoutes();
+
+ $runner = Services::commands();
+ $params = array_merge(CLI::getSegments(), CLI::getOptions());
+ $params = $this->parseParamsForHelpOption($params);
+ $command = array_shift($params) ?? 'list';
+
+ return $runner->run($command, $params);
+ }
+
+ /**
+ * Displays basic information about the Console.
+ *
+ * @return void
+ */
+ public function showHeader(bool $suppress = false)
+ {
+ if ($suppress) {
+ return;
+ }
+
+ CLI::write(sprintf(
+ 'CodeIgniter v%s Command Line Tool - Server Time: %s UTC%s',
+ CodeIgniter::CI_VERSION,
+ date('Y-m-d H:i:s'),
+ date('P')
+ ), 'green');
+ CLI::newLine();
+ }
+
+ /**
+ * Introspects the `$params` passed for presence of the
+ * `--help` option.
+ *
+ * If present, it will be found as `['help' => null]`.
+ * We'll remove that as an option from `$params` and
+ * unshift it as argument instead.
+ */
+ private function parseParamsForHelpOption(array $params): array
+ {
+ if (array_key_exists('help', $params)) {
+ unset($params['help']);
+
+ $params = $params === [] ? ['list'] : $params;
+ array_unshift($params, 'help');
+ }
+
+ return $params;
+ }
+}
diff --git a/system/CLI/Exceptions/CLIException.php b/system/CLI/Exceptions/CLIException.php
new file mode 100644
index 0000000..fae92c0
--- /dev/null
+++ b/system/CLI/Exceptions/CLIException.php
@@ -0,0 +1,36 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\CLI\Exceptions;
+
+use CodeIgniter\Exceptions\DebugTraceableTrait;
+use RuntimeException;
+
+/**
+ * CLIException
+ */
+class CLIException extends RuntimeException
+{
+ use DebugTraceableTrait;
+
+ /**
+ * Thrown when `$color` specified for `$type` is not within the
+ * allowed list of colors.
+ *
+ * @return CLIException
+ */
+ public static function forInvalidColor(string $type, string $color)
+ {
+ return new static(lang('CLI.invalidColor', [$type, $color]));
+ }
+}
diff --git a/system/CLI/GeneratorTrait.php b/system/CLI/GeneratorTrait.php
new file mode 100644
index 0000000..6a061a3
--- /dev/null
+++ b/system/CLI/GeneratorTrait.php
@@ -0,0 +1,527 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\CLI;
+
+use Config\Generators;
+use Throwable;
+
+/**
+ * GeneratorTrait contains a collection of methods
+ * to build the commands that generates a file.
+ */
+trait GeneratorTrait
+{
+ /**
+ * Component Name
+ *
+ * @var string
+ */
+ protected $component;
+
+ /**
+ * File directory
+ *
+ * @var string
+ */
+ protected $directory;
+
+ /**
+ * (Optional) View template path
+ *
+ * We use special namespaced paths like:
+ * `CodeIgniter\Commands\Generators\Views\cell.tpl.php`.
+ */
+ protected ?string $templatePath = null;
+
+ /**
+ * View template name for fallback
+ *
+ * @var string
+ */
+ protected $template;
+
+ /**
+ * Language string key for required class names.
+ *
+ * @var string
+ */
+ protected $classNameLang = '';
+
+ /**
+ * Namespace to use for class.
+ * Leave null to use the default namespace.
+ */
+ protected ?string $namespace = null;
+
+ /**
+ * Whether to require class name.
+ *
+ * @internal
+ *
+ * @var bool
+ */
+ private $hasClassName = true;
+
+ /**
+ * Whether to sort class imports.
+ *
+ * @internal
+ *
+ * @var bool
+ */
+ private $sortImports = true;
+
+ /**
+ * Whether the `--suffix` option has any effect.
+ *
+ * @internal
+ *
+ * @var bool
+ */
+ private $enabledSuffixing = true;
+
+ /**
+ * The params array for easy access by other methods.
+ *
+ * @internal
+ *
+ * @var array
+ */
+ private $params = [];
+
+ /**
+ * Execute the command.
+ *
+ * @param array $params
+ *
+ * @deprecated use generateClass() instead
+ */
+ protected function execute(array $params): void
+ {
+ $this->generateClass($params);
+ }
+
+ /**
+ * Generates a class file from an existing template.
+ *
+ * @param array $params
+ */
+ protected function generateClass(array $params): void
+ {
+ $this->params = $params;
+
+ // Get the fully qualified class name from the input.
+ $class = $this->qualifyClassName();
+
+ // Get the file path from class name.
+ $target = $this->buildPath($class);
+
+ // Check if path is empty.
+ if ($target === '') {
+ return;
+ }
+
+ $this->generateFile($target, $this->buildContent($class));
+ }
+
+ /**
+ * Generate a view file from an existing template.
+ *
+ * @param string $view namespaced view name that is generated
+ * @param array $params
+ */
+ protected function generateView(string $view, array $params): void
+ {
+ $this->params = $params;
+
+ $target = $this->buildPath($view);
+
+ // Check if path is empty.
+ if ($target === '') {
+ return;
+ }
+
+ $this->generateFile($target, $this->buildContent($view));
+ }
+
+ /**
+ * Handles writing the file to disk, and all of the safety checks around that.
+ *
+ * @param string $target file path
+ */
+ private function generateFile(string $target, string $content): void
+ {
+ if ($this->getOption('namespace') === 'CodeIgniter') {
+ // @codeCoverageIgnoreStart
+ CLI::write(lang('CLI.generator.usingCINamespace'), 'yellow');
+ CLI::newLine();
+
+ if (
+ CLI::prompt(
+ 'Are you sure you want to continue?',
+ ['y', 'n'],
+ 'required'
+ ) === 'n'
+ ) {
+ CLI::newLine();
+ CLI::write(lang('CLI.generator.cancelOperation'), 'yellow');
+ CLI::newLine();
+
+ return;
+ }
+
+ CLI::newLine();
+ // @codeCoverageIgnoreEnd
+ }
+
+ $isFile = is_file($target);
+
+ // Overwriting files unknowingly is a serious annoyance, So we'll check if
+ // we are duplicating things, If 'force' option is not supplied, we bail.
+ if (! $this->getOption('force') && $isFile) {
+ CLI::error(
+ lang('CLI.generator.fileExist', [clean_path($target)]),
+ 'light_gray',
+ 'red'
+ );
+ CLI::newLine();
+
+ return;
+ }
+
+ // Check if the directory to save the file is existing.
+ $dir = dirname($target);
+
+ if (! is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+
+ helper('filesystem');
+
+ // Build the class based on the details we have, We'll be getting our file
+ // contents from the template, and then we'll do the necessary replacements.
+ if (! write_file($target, $content)) {
+ // @codeCoverageIgnoreStart
+ CLI::error(
+ lang('CLI.generator.fileError', [clean_path($target)]),
+ 'light_gray',
+ 'red'
+ );
+ CLI::newLine();
+
+ return;
+ // @codeCoverageIgnoreEnd
+ }
+
+ if ($this->getOption('force') && $isFile) {
+ CLI::write(
+ lang('CLI.generator.fileOverwrite', [clean_path($target)]),
+ 'yellow'
+ );
+ CLI::newLine();
+
+ return;
+ }
+
+ CLI::write(
+ lang('CLI.generator.fileCreate', [clean_path($target)]),
+ 'green'
+ );
+ CLI::newLine();
+ }
+
+ /**
+ * Prepare options and do the necessary replacements.
+ *
+ * @param string $class namespaced classname or namespaced view.
+ *
+ * @return string generated file content
+ */
+ protected function prepare(string $class): string
+ {
+ return $this->parseTemplate($class);
+ }
+
+ /**
+ * Change file basename before saving.
+ *
+ * Useful for components where the file name has a date.
+ */
+ protected function basename(string $filename): string
+ {
+ return basename($filename);
+ }
+
+ /**
+ * Parses the class name and checks if it is already qualified.
+ */
+ protected function qualifyClassName(): string
+ {
+ $class = $this->normalizeInputClassName();
+
+ // Gets the namespace from input. Don't forget the ending backslash!
+ $namespace = $this->getNamespace() . '\\';
+
+ if (str_starts_with($class, $namespace)) {
+ return $class; // @codeCoverageIgnore
+ }
+
+ $directoryString = ($this->directory !== null) ? $this->directory . '\\' : '';
+
+ return $namespace . $directoryString . str_replace('/', '\\', $class);
+ }
+
+ /**
+ * Normalize input classname.
+ */
+ private function normalizeInputClassName(): string
+ {
+ // Gets the class name from input.
+ $class = $this->params[0] ?? CLI::getSegment(2);
+
+ if ($class === null && $this->hasClassName) {
+ // @codeCoverageIgnoreStart
+ $nameLang = $this->classNameLang !== ''
+ ? $this->classNameLang
+ : 'CLI.generator.className.default';
+ $class = CLI::prompt(lang($nameLang), null, 'required');
+ CLI::newLine();
+ // @codeCoverageIgnoreEnd
+ }
+
+ helper('inflector');
+
+ $component = singular($this->component);
+
+ /**
+ * @see https://regex101.com/r/a5KNCR/2
+ */
+ $pattern = sprintf('/([a-z][a-z0-9_\/\\\\]+)(%s)$/i', $component);
+
+ if (preg_match($pattern, $class, $matches) === 1) {
+ $class = $matches[1] . ucfirst($matches[2]);
+ }
+
+ if (
+ $this->enabledSuffixing && $this->getOption('suffix')
+ && preg_match($pattern, $class) !== 1
+ ) {
+ $class .= ucfirst($component);
+ }
+
+ // Trims input, normalize separators, and ensure that all paths are in Pascalcase.
+ return ltrim(
+ implode(
+ '\\',
+ array_map(
+ pascalize(...),
+ explode('\\', str_replace('/', '\\', trim($class)))
+ )
+ ),
+ '\\/'
+ );
+ }
+
+ /**
+ * Gets the generator view as defined in the `Config\Generators::$views`,
+ * with fallback to `$template` when the defined view does not exist.
+ *
+ * @param array $data
+ */
+ protected function renderTemplate(array $data = []): string
+ {
+ try {
+ $template = $this->templatePath ?? config(Generators::class)->views[$this->name];
+
+ return view($template, $data, ['debug' => false]);
+ } catch (Throwable $e) {
+ log_message('error', (string) $e);
+
+ return view(
+ "CodeIgniter\\Commands\\Generators\\Views\\{$this->template}",
+ $data,
+ ['debug' => false]
+ );
+ }
+ }
+
+ /**
+ * Performs pseudo-variables contained within view file.
+ *
+ * @param string $class namespaced classname or namespaced view.
+ * @param list $search
+ * @param list $replace
+ * @param array $data
+ *
+ * @return string generated file content
+ */
+ protected function parseTemplate(
+ string $class,
+ array $search = [],
+ array $replace = [],
+ array $data = []
+ ): string {
+ // Retrieves the namespace part from the fully qualified class name.
+ $namespace = trim(
+ implode(
+ '\\',
+ array_slice(explode('\\', $class), 0, -1)
+ ),
+ '\\'
+ );
+ $search[] = '<@php';
+ $search[] = '{namespace}';
+ $search[] = '{class}';
+ $replace[] = 'renderTemplate($data));
+ }
+
+ /**
+ * Builds the contents for class being generated, doing all
+ * the replacements necessary, and alphabetically sorts the
+ * imports for a given template.
+ */
+ protected function buildContent(string $class): string
+ {
+ $template = $this->prepare($class);
+
+ if (
+ $this->sortImports
+ && preg_match(
+ '/(?P(?:^use [^;]+;$\n?)+)/m',
+ $template,
+ $match
+ )
+ ) {
+ $imports = explode("\n", trim($match['imports']));
+ sort($imports);
+
+ return str_replace(trim($match['imports']), implode("\n", $imports), $template);
+ }
+
+ return $template;
+ }
+
+ /**
+ * Builds the file path from the class name.
+ *
+ * @param string $class namespaced classname or namespaced view.
+ */
+ protected function buildPath(string $class): string
+ {
+ $namespace = $this->getNamespace();
+
+ // Check if the namespace is actually defined and we are not just typing gibberish.
+ $base = service('autoloader')->getNamespace($namespace);
+
+ if (! $base = reset($base)) {
+ CLI::error(
+ lang('CLI.namespaceNotDefined', [$namespace]),
+ 'light_gray',
+ 'red'
+ );
+ CLI::newLine();
+
+ return '';
+ }
+
+ $realpath = realpath($base);
+ $base = ($realpath !== false) ? $realpath : $base;
+
+ $file = $base . DIRECTORY_SEPARATOR
+ . str_replace(
+ '\\',
+ DIRECTORY_SEPARATOR,
+ trim(str_replace($namespace . '\\', '', $class), '\\')
+ ) . '.php';
+
+ return implode(
+ DIRECTORY_SEPARATOR,
+ array_slice(
+ explode(DIRECTORY_SEPARATOR, $file),
+ 0,
+ -1
+ )
+ ) . DIRECTORY_SEPARATOR . $this->basename($file);
+ }
+
+ /**
+ * Gets the namespace from the command-line option,
+ * or the default namespace if the option is not set.
+ * Can be overridden by directly setting $this->namespace.
+ */
+ protected function getNamespace(): string
+ {
+ return $this->namespace ?? trim(
+ str_replace(
+ '/',
+ '\\',
+ $this->getOption('namespace') ?? APP_NAMESPACE
+ ),
+ '\\'
+ );
+ }
+
+ /**
+ * Allows child generators to modify the internal `$hasClassName` flag.
+ *
+ * @return $this
+ */
+ protected function setHasClassName(bool $hasClassName)
+ {
+ $this->hasClassName = $hasClassName;
+
+ return $this;
+ }
+
+ /**
+ * Allows child generators to modify the internal `$sortImports` flag.
+ *
+ * @return $this
+ */
+ protected function setSortImports(bool $sortImports)
+ {
+ $this->sortImports = $sortImports;
+
+ return $this;
+ }
+
+ /**
+ * Allows child generators to modify the internal `$enabledSuffixing` flag.
+ *
+ * @return $this
+ */
+ protected function setEnabledSuffixing(bool $enabledSuffixing)
+ {
+ $this->enabledSuffixing = $enabledSuffixing;
+
+ return $this;
+ }
+
+ /**
+ * Gets a single command-line option. Returns TRUE if the option exists,
+ * but doesn't have a value, and is simply acting as a flag.
+ */
+ protected function getOption(string $name): bool|string|null
+ {
+ if (! array_key_exists($name, $this->params)) {
+ return CLI::getOption($name);
+ }
+
+ return $this->params[$name] ?? true;
+ }
+}
diff --git a/system/CLI/InputOutput.php b/system/CLI/InputOutput.php
new file mode 100644
index 0000000..b69c19e
--- /dev/null
+++ b/system/CLI/InputOutput.php
@@ -0,0 +1,80 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\CLI;
+
+/**
+ * Input and Output for CLI.
+ */
+class InputOutput
+{
+ /**
+ * Is the readline library on the system?
+ */
+ private readonly bool $readlineSupport;
+
+ public function __construct()
+ {
+ // Readline is an extension for PHP that makes interactivity with PHP
+ // much more bash-like.
+ // http://www.php.net/manual/en/readline.installation.php
+ $this->readlineSupport = extension_loaded('readline');
+ }
+
+ /**
+ * Get input from the shell, using readline or the standard STDIN
+ *
+ * Named options must be in the following formats:
+ * php index.php user -v --v -name=John --name=John
+ *
+ * @param string|null $prefix You may specify a string with which to prompt the user.
+ */
+ public function input(?string $prefix = null): string
+ {
+ // readline() can't be tested.
+ if ($this->readlineSupport && ENVIRONMENT !== 'testing') {
+ return readline($prefix); // @codeCoverageIgnore
+ }
+
+ echo $prefix;
+
+ $input = fgets(fopen('php://stdin', 'rb'));
+
+ if ($input === false) {
+ $input = '';
+ }
+
+ return $input;
+ }
+
+ /**
+ * While the library is intended for use on CLI commands,
+ * commands can be called from controllers and elsewhere
+ * so we need a way to allow them to still work.
+ *
+ * For now, just echo the content, but look into a better
+ * solution down the road.
+ *
+ * @param resource $handle
+ */
+ public function fwrite($handle, string $string): void
+ {
+ if (! is_cli()) {
+ echo $string;
+
+ return;
+ }
+
+ fwrite($handle, $string);
+ }
+}
diff --git a/system/Cache/CacheFactory.php b/system/Cache/CacheFactory.php
new file mode 100644
index 0000000..fb60d73
--- /dev/null
+++ b/system/Cache/CacheFactory.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\Exceptions\CacheException;
+use CodeIgniter\Exceptions\CriticalError;
+use CodeIgniter\Test\Mock\MockCache;
+use Config\Cache;
+
+/**
+ * A factory for loading the desired
+ *
+ * @see \CodeIgniter\Cache\CacheFactoryTest
+ */
+class CacheFactory
+{
+ /**
+ * The class to use when mocking
+ *
+ * @var string
+ */
+ public static $mockClass = MockCache::class;
+
+ /**
+ * The service to inject the mock as
+ *
+ * @var string
+ */
+ public static $mockServiceName = 'cache';
+
+ /**
+ * Attempts to create the desired cache handler, based upon the
+ *
+ * @param non-empty-string|null $handler
+ * @param non-empty-string|null $backup
+ *
+ * @return CacheInterface
+ */
+ public static function getHandler(Cache $config, ?string $handler = null, ?string $backup = null)
+ {
+ if (! isset($config->validHandlers) || $config->validHandlers === []) {
+ throw CacheException::forInvalidHandlers();
+ }
+
+ if (! isset($config->handler) || ! isset($config->backupHandler)) {
+ throw CacheException::forNoBackup();
+ }
+
+ $handler ??= $config->handler;
+ $backup ??= $config->backupHandler;
+
+ if (! array_key_exists($handler, $config->validHandlers) || ! array_key_exists($backup, $config->validHandlers)) {
+ throw CacheException::forHandlerNotFound();
+ }
+
+ $adapter = new $config->validHandlers[$handler]($config);
+
+ if (! $adapter->isSupported()) {
+ $adapter = new $config->validHandlers[$backup]($config);
+
+ if (! $adapter->isSupported()) {
+ // Fall back to the dummy adapter.
+ $adapter = new $config->validHandlers['dummy']();
+ }
+ }
+
+ // If $adapter->initialization throws a CriticalError exception, we will attempt to
+ // use the $backup handler, if that also fails, we resort to the dummy handler.
+ try {
+ $adapter->initialize();
+ } catch (CriticalError $e) {
+ log_message('critical', $e . ' Resorting to using ' . $backup . ' handler.');
+
+ // get the next best cache handler (or dummy if the $backup also fails)
+ $adapter = self::getHandler($config, $backup, 'dummy');
+ }
+
+ return $adapter;
+ }
+}
diff --git a/system/Cache/CacheInterface.php b/system/Cache/CacheInterface.php
new file mode 100644
index 0000000..d76d5aa
--- /dev/null
+++ b/system/Cache/CacheInterface.php
@@ -0,0 +1,110 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Cache;
+
+/**
+ * Cache interface
+ */
+interface CacheInterface
+{
+ /**
+ * Takes care of any handler-specific setup that must be done.
+ *
+ * @return void
+ */
+ public function initialize();
+
+ /**
+ * Attempts to fetch an item from the cache store.
+ *
+ * @param string $key Cache item name
+ *
+ * @return array|bool|float|int|object|string|null
+ */
+ public function get(string $key);
+
+ /**
+ * Saves an item to the cache store.
+ *
+ * @param string $key Cache item name
+ * @param array|bool|float|int|object|string|null $value The data to save
+ * @param int $ttl Time To Live, in seconds (default 60)
+ *
+ * @return bool Success or failure
+ */
+ public function save(string $key, $value, int $ttl = 60);
+
+ /**
+ * Deletes a specific item from the cache store.
+ *
+ * @param string $key Cache item name
+ *
+ * @return bool Success or failure
+ */
+ public function delete(string $key);
+
+ /**
+ * Performs atomic incrementation of a raw stored value.
+ *
+ * @param string $key Cache ID
+ * @param int $offset Step/value to increase by
+ *
+ * @return bool|int
+ */
+ public function increment(string $key, int $offset = 1);
+
+ /**
+ * Performs atomic decrementation of a raw stored value.
+ *
+ * @param string $key Cache ID
+ * @param int $offset Step/value to increase by
+ *
+ * @return bool|int
+ */
+ public function decrement(string $key, int $offset = 1);
+
+ /**
+ * Will delete all items in the entire cache.
+ *
+ * @return bool Success or failure
+ */
+ public function clean();
+
+ /**
+ * Returns information on the entire cache.
+ *
+ * The information returned and the structure of the data
+ * varies depending on the handler.
+ *
+ * @return array|false|object|null
+ */
+ public function getCacheInfo();
+
+ /**
+ * Returns detailed information about the specific item in the cache.
+ *
+ * @param string $key Cache item name.
+ *
+ * @return array|false|null
+ * Returns null if the item does not exist, otherwise array
+ * with at least the 'expire' key for absolute epoch expiry (or null).
+ * Some handlers may return false when an item does not exist, which is deprecated.
+ */
+ public function getMetaData(string $key);
+
+ /**
+ * Determines if the driver is supported on this system.
+ */
+ public function isSupported(): bool;
+}
diff --git a/system/Cache/Exceptions/CacheException.php b/system/Cache/Exceptions/CacheException.php
new file mode 100644
index 0000000..852e630
--- /dev/null
+++ b/system/Cache/Exceptions/CacheException.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\Cache\Exceptions;
+
+use CodeIgniter\Exceptions\DebugTraceableTrait;
+use CodeIgniter\Exceptions\ExceptionInterface;
+use RuntimeException;
+
+/**
+ * CacheException
+ */
+class CacheException extends RuntimeException implements ExceptionInterface
+{
+ use DebugTraceableTrait;
+
+ /**
+ * Thrown when handler has no permission to write cache.
+ *
+ * @return CacheException
+ */
+ public static function forUnableToWrite(string $path)
+ {
+ return new static(lang('Cache.unableToWrite', [$path]));
+ }
+
+ /**
+ * Thrown when an unrecognized handler is used.
+ *
+ * @return CacheException
+ */
+ public static function forInvalidHandlers()
+ {
+ return new static(lang('Cache.invalidHandlers'));
+ }
+
+ /**
+ * Thrown when no backup handler is setup in config.
+ *
+ * @return CacheException
+ */
+ public static function forNoBackup()
+ {
+ return new static(lang('Cache.noBackup'));
+ }
+
+ /**
+ * Thrown when specified handler was not found.
+ *
+ * @return CacheException
+ */
+ public static function forHandlerNotFound()
+ {
+ return new static(lang('Cache.handlerNotFound'));
+ }
+}
diff --git a/system/Cache/FactoriesCache.php b/system/Cache/FactoriesCache.php
new file mode 100644
index 0000000..e4b7488
--- /dev/null
+++ b/system/Cache/FactoriesCache.php
@@ -0,0 +1,67 @@
+
+ *
+ * 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 0000000..b7c1a04
--- /dev/null
+++ b/system/Cache/FactoriesCache/FileVarExportHandler.php
@@ -0,0 +1,46 @@
+
+ *
+ * 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/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php
new file mode 100644
index 0000000..43d316f
--- /dev/null
+++ b/system/Cache/Handlers/BaseHandler.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 CodeIgniter\Cache\Handlers;
+
+use Closure;
+use CodeIgniter\Cache\CacheInterface;
+use Config\Cache;
+use Exception;
+use InvalidArgumentException;
+
+/**
+ * Base class for cache handling
+ *
+ * @see \CodeIgniter\Cache\Handlers\BaseHandlerTest
+ */
+abstract class BaseHandler implements CacheInterface
+{
+ /**
+ * Reserved characters that cannot be used in a key or tag. May be overridden by the config.
+ * From https://github.com/symfony/cache-contracts/blob/c0446463729b89dd4fa62e9aeecc80287323615d/ItemInterface.php#L43
+ *
+ * @deprecated in favor of the Cache config
+ */
+ public const RESERVED_CHARACTERS = '{}()/\@:';
+
+ /**
+ * Maximum key length.
+ */
+ public const MAX_KEY_LENGTH = PHP_INT_MAX;
+
+ /**
+ * Prefix to apply to cache keys.
+ * May not be used by all handlers.
+ *
+ * @var string
+ */
+ protected $prefix;
+
+ /**
+ * Validates a cache key according to PSR-6.
+ * Keys that exceed MAX_KEY_LENGTH are hashed.
+ * From https://github.com/symfony/cache/blob/7b024c6726af21fd4984ac8d1eae2b9f3d90de88/CacheItem.php#L158
+ *
+ * @param string $key The key to validate
+ * @param string $prefix Optional prefix to include in length calculations
+ *
+ * @throws InvalidArgumentException When $key is not valid
+ */
+ public static function validateKey($key, $prefix = ''): string
+ {
+ if (! is_string($key)) {
+ throw new InvalidArgumentException('Cache key must be a string');
+ }
+ if ($key === '') {
+ throw new InvalidArgumentException('Cache key cannot be empty.');
+ }
+
+ $reserved = config(Cache::class)->reservedCharacters ?? self::RESERVED_CHARACTERS;
+ if ($reserved && strpbrk($key, $reserved) !== false) {
+ throw new InvalidArgumentException('Cache key contains reserved characters ' . $reserved);
+ }
+
+ // If the key with prefix exceeds the length then return the hashed version
+ return strlen($prefix . $key) > static::MAX_KEY_LENGTH ? $prefix . md5($key) : $prefix . $key;
+ }
+
+ /**
+ * Get an item from the cache, or execute the given Closure and store the result.
+ *
+ * @param string $key Cache item name
+ * @param int $ttl Time to live
+ * @param Closure(): mixed $callback Callback return value
+ *
+ * @return array|bool|float|int|object|string|null
+ */
+ public function remember(string $key, int $ttl, Closure $callback)
+ {
+ $value = $this->get($key);
+
+ if ($value !== null) {
+ return $value;
+ }
+
+ $this->save($key, $value = $callback(), $ttl);
+
+ return $value;
+ }
+
+ /**
+ * Deletes items from the cache store matching a given pattern.
+ *
+ * @param string $pattern Cache items glob-style pattern
+ *
+ * @return int|never
+ *
+ * @throws Exception
+ */
+ public function deleteMatching(string $pattern)
+ {
+ throw new Exception('The deleteMatching method is not implemented.');
+ }
+}
diff --git a/system/Cache/Handlers/DummyHandler.php b/system/Cache/Handlers/DummyHandler.php
new file mode 100644
index 0000000..10300c7
--- /dev/null
+++ b/system/Cache/Handlers/DummyHandler.php
@@ -0,0 +1,121 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Cache\Handlers;
+
+use Closure;
+
+/**
+ * Dummy cache handler
+ *
+ * @see \CodeIgniter\Cache\Handlers\DummyHandlerTest
+ */
+class DummyHandler extends BaseHandler
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function initialize()
+ {
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function get(string $key)
+ {
+ return null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function remember(string $key, int $ttl, Closure $callback)
+ {
+ return null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function save(string $key, $value, int $ttl = 60)
+ {
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function delete(string $key)
+ {
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return int
+ */
+ public function deleteMatching(string $pattern)
+ {
+ return 0;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function increment(string $key, int $offset = 1)
+ {
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function decrement(string $key, int $offset = 1)
+ {
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function clean()
+ {
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getCacheInfo()
+ {
+ return null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getMetaData(string $key)
+ {
+ return null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isSupported(): bool
+ {
+ return true;
+ }
+}
diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php
new file mode 100644
index 0000000..cf45a65
--- /dev/null
+++ b/system/Cache/Handlers/FileHandler.php
@@ -0,0 +1,425 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Cache\Handlers;
+
+use CodeIgniter\Cache\Exceptions\CacheException;
+use CodeIgniter\I18n\Time;
+use Config\Cache;
+use Throwable;
+
+/**
+ * File system cache handler
+ *
+ * @see \CodeIgniter\Cache\Handlers\FileHandlerTest
+ */
+class FileHandler extends BaseHandler
+{
+ /**
+ * Maximum key length.
+ */
+ public const MAX_KEY_LENGTH = 255;
+
+ /**
+ * Where to store cached files on the disk.
+ *
+ * @var string
+ */
+ protected $path;
+
+ /**
+ * Mode for the stored files.
+ * Must be chmod-safe (octal).
+ *
+ * @var int
+ *
+ * @see https://www.php.net/manual/en/function.chmod.php
+ */
+ protected $mode;
+
+ /**
+ * Note: Use `CacheFactory::getHandler()` to instantiate.
+ *
+ * @throws CacheException
+ */
+ public function __construct(Cache $config)
+ {
+ if (! property_exists($config, 'file')) {
+ $config->file = [
+ 'storePath' => $config->storePath ?? WRITEPATH . 'cache',
+ 'mode' => 0640,
+ ];
+ }
+
+ $this->path = ! empty($config->file['storePath']) ? $config->file['storePath'] : WRITEPATH . 'cache';
+ $this->path = rtrim($this->path, '/') . '/';
+
+ if (! is_really_writable($this->path)) {
+ throw CacheException::forUnableToWrite($this->path);
+ }
+
+ $this->mode = $config->file['mode'] ?? 0640;
+ $this->prefix = $config->prefix;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function initialize()
+ {
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function get(string $key)
+ {
+ $key = static::validateKey($key, $this->prefix);
+ $data = $this->getItem($key);
+
+ return is_array($data) ? $data['data'] : null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function save(string $key, $value, int $ttl = 60)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ $contents = [
+ 'time' => Time::now()->getTimestamp(),
+ 'ttl' => $ttl,
+ 'data' => $value,
+ ];
+
+ if ($this->writeFile($this->path . $key, serialize($contents))) {
+ try {
+ chmod($this->path . $key, $this->mode);
+
+ // @codeCoverageIgnoreStart
+ } catch (Throwable $e) {
+ log_message('debug', 'Failed to set mode on cache file: ' . $e);
+ // @codeCoverageIgnoreEnd
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function delete(string $key)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ return is_file($this->path . $key) && unlink($this->path . $key);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return int
+ */
+ public function deleteMatching(string $pattern)
+ {
+ $deleted = 0;
+
+ foreach (glob($this->path . $pattern, GLOB_NOSORT) as $filename) {
+ if (is_file($filename) && @unlink($filename)) {
+ $deleted++;
+ }
+ }
+
+ return $deleted;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function increment(string $key, int $offset = 1)
+ {
+ $prefixedKey = static::validateKey($key, $this->prefix);
+ $tmp = $this->getItem($prefixedKey);
+
+ if ($tmp === false) {
+ $tmp = ['data' => 0, 'ttl' => 60];
+ }
+
+ ['data' => $value, 'ttl' => $ttl] = $tmp;
+
+ if (! is_int($value)) {
+ return false;
+ }
+
+ $value += $offset;
+
+ return $this->save($key, $value, $ttl) ? $value : false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function decrement(string $key, int $offset = 1)
+ {
+ return $this->increment($key, -$offset);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function clean()
+ {
+ return $this->deleteFiles($this->path, false, true);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getCacheInfo()
+ {
+ return $this->getDirFileInfo($this->path);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getMetaData(string $key)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ if (false === $data = $this->getItem($key)) {
+ return false; // @TODO This will return null in a future release
+ }
+
+ return [
+ 'expire' => $data['ttl'] > 0 ? $data['time'] + $data['ttl'] : null,
+ 'mtime' => filemtime($this->path . $key),
+ 'data' => $data['data'],
+ ];
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isSupported(): bool
+ {
+ return is_writable($this->path);
+ }
+
+ /**
+ * Does the heavy lifting of actually retrieving the file and
+ * verifying it's age.
+ *
+ * @return array{data: mixed, ttl: int, time: int}|false
+ */
+ protected function getItem(string $filename)
+ {
+ if (! is_file($this->path . $filename)) {
+ return false;
+ }
+
+ $data = @unserialize(file_get_contents($this->path . $filename));
+
+ if (! is_array($data)) {
+ return false;
+ }
+
+ if (! isset($data['ttl']) || ! is_int($data['ttl'])) {
+ return false;
+ }
+
+ if (! isset($data['time']) || ! is_int($data['time'])) {
+ return false;
+ }
+
+ if ($data['ttl'] > 0 && Time::now()->getTimestamp() > $data['time'] + $data['ttl']) {
+ @unlink($this->path . $filename);
+
+ return false;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Writes a file to disk, or returns false if not successful.
+ *
+ * @param string $path
+ * @param string $data
+ * @param string $mode
+ *
+ * @return bool
+ */
+ protected function writeFile($path, $data, $mode = 'wb')
+ {
+ if (($fp = @fopen($path, $mode)) === false) {
+ return false;
+ }
+
+ flock($fp, LOCK_EX);
+
+ for ($result = $written = 0, $length = strlen($data); $written < $length; $written += $result) {
+ if (($result = fwrite($fp, substr($data, $written))) === false) {
+ break;
+ }
+ }
+
+ flock($fp, LOCK_UN);
+ fclose($fp);
+
+ return is_int($result);
+ }
+
+ /**
+ * Deletes all files contained in the supplied directory path.
+ * Files must be writable or owned by the system in order to be deleted.
+ * If the second parameter is set to TRUE, any directories contained
+ * within the supplied base directory will be nuked as well.
+ *
+ * @param string $path File path
+ * @param bool $delDir Whether to delete any directories found in the path
+ * @param bool $htdocs Whether to skip deleting .htaccess and index page files
+ * @param int $_level Current directory depth level (default: 0; internal use only)
+ */
+ protected function deleteFiles(string $path, bool $delDir = false, bool $htdocs = false, int $_level = 0): bool
+ {
+ // Trim the trailing slash
+ $path = rtrim($path, '/\\');
+
+ if (! $currentDir = @opendir($path)) {
+ return false;
+ }
+
+ while (false !== ($filename = @readdir($currentDir))) {
+ if ($filename !== '.' && $filename !== '..') {
+ if (is_dir($path . DIRECTORY_SEPARATOR . $filename) && $filename[0] !== '.') {
+ $this->deleteFiles($path . DIRECTORY_SEPARATOR . $filename, $delDir, $htdocs, $_level + 1);
+ } elseif ($htdocs !== true || ! preg_match('/^(\.htaccess|index\.(html|htm|php)|web\.config)$/i', $filename)) {
+ @unlink($path . DIRECTORY_SEPARATOR . $filename);
+ }
+ }
+ }
+
+ closedir($currentDir);
+
+ return ($delDir === true && $_level > 0) ? @rmdir($path) : true;
+ }
+
+ /**
+ * Reads the specified directory and builds an array containing the filenames,
+ * filesize, dates, and permissions
+ *
+ * Any sub-folders contained within the specified path are read as well.
+ *
+ * @param string $sourceDir Path to source
+ * @param bool $topLevelOnly Look only at the top level directory specified?
+ * @param bool $_recursion Internal variable to determine recursion status - do not use in calls
+ *
+ * @return array|false
+ */
+ protected function getDirFileInfo(string $sourceDir, bool $topLevelOnly = true, bool $_recursion = false)
+ {
+ static $_filedata = [];
+ $relativePath = $sourceDir;
+
+ if ($fp = @opendir($sourceDir)) {
+ // reset the array and make sure $source_dir has a trailing slash on the initial call
+ if ($_recursion === false) {
+ $_filedata = [];
+ $sourceDir = rtrim(realpath($sourceDir) ?: $sourceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+ }
+
+ // Used to be foreach (scandir($source_dir, 1) as $file), but scandir() is simply not as fast
+ while (false !== ($file = readdir($fp))) {
+ if (is_dir($sourceDir . $file) && $file[0] !== '.' && $topLevelOnly === false) {
+ $this->getDirFileInfo($sourceDir . $file . DIRECTORY_SEPARATOR, $topLevelOnly, true);
+ } elseif (! is_dir($sourceDir . $file) && $file[0] !== '.') {
+ $_filedata[$file] = $this->getFileInfo($sourceDir . $file);
+ $_filedata[$file]['relative_path'] = $relativePath;
+ }
+ }
+
+ closedir($fp);
+
+ return $_filedata;
+ }
+
+ return false;
+ }
+
+ /**
+ * Given a file and path, returns the name, path, size, date modified
+ * Second parameter allows you to explicitly declare what information you want returned
+ * Options are: name, server_path, size, date, readable, writable, executable, fileperms
+ * Returns FALSE if the file cannot be found.
+ *
+ * @param string $file Path to file
+ * @param array|string $returnedValues Array or comma separated string of information returned
+ *
+ * @return array|false
+ */
+ protected function getFileInfo(string $file, $returnedValues = ['name', 'server_path', 'size', 'date'])
+ {
+ if (! is_file($file)) {
+ return false;
+ }
+
+ if (is_string($returnedValues)) {
+ $returnedValues = explode(',', $returnedValues);
+ }
+
+ $fileInfo = [];
+
+ foreach ($returnedValues as $key) {
+ switch ($key) {
+ case 'name':
+ $fileInfo['name'] = basename($file);
+ break;
+
+ case 'server_path':
+ $fileInfo['server_path'] = $file;
+ break;
+
+ case 'size':
+ $fileInfo['size'] = filesize($file);
+ break;
+
+ case 'date':
+ $fileInfo['date'] = filemtime($file);
+ break;
+
+ case 'readable':
+ $fileInfo['readable'] = is_readable($file);
+ break;
+
+ case 'writable':
+ $fileInfo['writable'] = is_writable($file);
+ break;
+
+ case 'executable':
+ $fileInfo['executable'] = is_executable($file);
+ break;
+
+ case 'fileperms':
+ $fileInfo['fileperms'] = fileperms($file);
+ break;
+ }
+ }
+
+ return $fileInfo;
+ }
+}
diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php
new file mode 100644
index 0000000..e104800
--- /dev/null
+++ b/system/Cache/Handlers/MemcachedHandler.php
@@ -0,0 +1,278 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Cache\Handlers;
+
+use CodeIgniter\Exceptions\CriticalError;
+use CodeIgniter\I18n\Time;
+use Config\Cache;
+use Exception;
+use Memcache;
+use Memcached;
+
+/**
+ * Mamcached cache handler
+ *
+ * @see \CodeIgniter\Cache\Handlers\MemcachedHandlerTest
+ */
+class MemcachedHandler extends BaseHandler
+{
+ /**
+ * The memcached object
+ *
+ * @var Memcache|Memcached
+ */
+ protected $memcached;
+
+ /**
+ * Memcached Configuration
+ *
+ * @var array
+ */
+ protected $config = [
+ 'host' => '127.0.0.1',
+ 'port' => 11211,
+ 'weight' => 1,
+ 'raw' => false,
+ ];
+
+ /**
+ * Note: Use `CacheFactory::getHandler()` to instantiate.
+ */
+ public function __construct(Cache $config)
+ {
+ $this->prefix = $config->prefix;
+
+ $this->config = array_merge($this->config, $config->memcached);
+ }
+
+ /**
+ * Closes the connection to Memcache(d) if present.
+ */
+ public function __destruct()
+ {
+ if ($this->memcached instanceof Memcached) {
+ $this->memcached->quit();
+ } elseif ($this->memcached instanceof Memcache) {
+ $this->memcached->close();
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function initialize()
+ {
+ try {
+ if (class_exists(Memcached::class)) {
+ // Create new instance of Memcached
+ $this->memcached = new Memcached();
+ if ($this->config['raw']) {
+ $this->memcached->setOption(Memcached::OPT_BINARY_PROTOCOL, true);
+ }
+
+ // Add server
+ $this->memcached->addServer(
+ $this->config['host'],
+ $this->config['port'],
+ $this->config['weight']
+ );
+
+ // attempt to get status of servers
+ $stats = $this->memcached->getStats();
+
+ // $stats should be an associate array with a key in the format of host:port.
+ // If it doesn't have the key, we know the server is not working as expected.
+ if (! isset($stats[$this->config['host'] . ':' . $this->config['port']])) {
+ throw new CriticalError('Cache: Memcached connection failed.');
+ }
+ } elseif (class_exists(Memcache::class)) {
+ // Create new instance of Memcache
+ $this->memcached = new Memcache();
+
+ // Check if we can connect to the server
+ $canConnect = $this->memcached->connect(
+ $this->config['host'],
+ $this->config['port']
+ );
+
+ // If we can't connect, throw a CriticalError exception
+ if ($canConnect === false) {
+ throw new CriticalError('Cache: Memcache connection failed.');
+ }
+
+ // Add server, third parameter is persistence and defaults to TRUE.
+ $this->memcached->addServer(
+ $this->config['host'],
+ $this->config['port'],
+ true,
+ $this->config['weight']
+ );
+ } else {
+ throw new CriticalError('Cache: Not support Memcache(d) extension.');
+ }
+ } catch (Exception $e) {
+ throw new CriticalError('Cache: Memcache(d) connection refused (' . $e->getMessage() . ').');
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function get(string $key)
+ {
+ $data = [];
+ $key = static::validateKey($key, $this->prefix);
+
+ if ($this->memcached instanceof Memcached) {
+ $data = $this->memcached->get($key);
+
+ // check for unmatched key
+ if ($this->memcached->getResultCode() === Memcached::RES_NOTFOUND) {
+ return null;
+ }
+ } elseif ($this->memcached instanceof Memcache) {
+ $flags = false;
+ $data = $this->memcached->get($key, $flags);
+
+ // check for unmatched key (i.e. $flags is untouched)
+ if ($flags === false) {
+ return null;
+ }
+ }
+
+ return is_array($data) ? $data[0] : $data;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function save(string $key, $value, int $ttl = 60)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ if (! $this->config['raw']) {
+ $value = [
+ $value,
+ Time::now()->getTimestamp(),
+ $ttl,
+ ];
+ }
+
+ if ($this->memcached instanceof Memcached) {
+ return $this->memcached->set($key, $value, $ttl);
+ }
+
+ if ($this->memcached instanceof Memcache) {
+ return $this->memcached->set($key, $value, 0, $ttl);
+ }
+
+ return false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function delete(string $key)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ return $this->memcached->delete($key);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return never
+ */
+ public function deleteMatching(string $pattern)
+ {
+ throw new Exception('The deleteMatching method is not implemented for Memcached. You must select File, Redis or Predis handlers to use it.');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function increment(string $key, int $offset = 1)
+ {
+ if (! $this->config['raw']) {
+ return false;
+ }
+
+ $key = static::validateKey($key, $this->prefix);
+
+ return $this->memcached->increment($key, $offset, $offset, 60);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function decrement(string $key, int $offset = 1)
+ {
+ if (! $this->config['raw']) {
+ return false;
+ }
+
+ $key = static::validateKey($key, $this->prefix);
+
+ // FIXME: third parameter isn't other handler actions.
+
+ return $this->memcached->decrement($key, $offset, $offset, 60);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function clean()
+ {
+ return $this->memcached->flush();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getCacheInfo()
+ {
+ return $this->memcached->getStats();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getMetaData(string $key)
+ {
+ $key = static::validateKey($key, $this->prefix);
+ $stored = $this->memcached->get($key);
+
+ // if not an array, don't try to count for PHP7.2
+ if (! is_array($stored) || count($stored) !== 3) {
+ return false; // @TODO This will return null in a future release
+ }
+
+ [$data, $time, $limit] = $stored;
+
+ return [
+ 'expire' => $limit > 0 ? $time + $limit : null,
+ 'mtime' => $time,
+ 'data' => $data,
+ ];
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isSupported(): bool
+ {
+ return extension_loaded('memcached') || extension_loaded('memcache');
+ }
+}
diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php
new file mode 100644
index 0000000..59e35aa
--- /dev/null
+++ b/system/Cache/Handlers/PredisHandler.php
@@ -0,0 +1,227 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Cache\Handlers;
+
+use CodeIgniter\Exceptions\CriticalError;
+use CodeIgniter\I18n\Time;
+use Config\Cache;
+use Exception;
+use Predis\Client;
+use Predis\Collection\Iterator\Keyspace;
+
+/**
+ * Predis cache handler
+ *
+ * @see \CodeIgniter\Cache\Handlers\PredisHandlerTest
+ */
+class PredisHandler extends BaseHandler
+{
+ /**
+ * Default config
+ *
+ * @var array
+ */
+ protected $config = [
+ 'scheme' => 'tcp',
+ 'host' => '127.0.0.1',
+ 'password' => null,
+ 'port' => 6379,
+ 'timeout' => 0,
+ ];
+
+ /**
+ * Predis connection
+ *
+ * @var Client
+ */
+ protected $redis;
+
+ /**
+ * Note: Use `CacheFactory::getHandler()` to instantiate.
+ */
+ public function __construct(Cache $config)
+ {
+ $this->prefix = $config->prefix;
+
+ if (isset($config->redis)) {
+ $this->config = array_merge($this->config, $config->redis);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function initialize()
+ {
+ try {
+ $this->redis = new Client($this->config, ['prefix' => $this->prefix]);
+ $this->redis->time();
+ } catch (Exception $e) {
+ throw new CriticalError('Cache: Predis connection refused (' . $e->getMessage() . ').');
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function get(string $key)
+ {
+ $key = static::validateKey($key);
+
+ $data = array_combine(
+ ['__ci_type', '__ci_value'],
+ $this->redis->hmget($key, ['__ci_type', '__ci_value'])
+ );
+
+ if (! isset($data['__ci_type'], $data['__ci_value']) || $data['__ci_value'] === false) {
+ return null;
+ }
+
+ return match ($data['__ci_type']) {
+ 'array', 'object' => unserialize($data['__ci_value']),
+ // Yes, 'double' is returned and NOT 'float'
+ 'boolean', 'integer', 'double', 'string', 'NULL' => settype($data['__ci_value'], $data['__ci_type']) ? $data['__ci_value'] : null,
+ default => null,
+ };
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function save(string $key, $value, int $ttl = 60)
+ {
+ $key = static::validateKey($key);
+
+ switch ($dataType = gettype($value)) {
+ case 'array':
+ case 'object':
+ $value = serialize($value);
+ break;
+
+ case 'boolean':
+ case 'integer':
+ case 'double': // Yes, 'double' is returned and NOT 'float'
+ case 'string':
+ case 'NULL':
+ break;
+
+ case 'resource':
+ default:
+ return false;
+ }
+
+ if (! $this->redis->hmset($key, ['__ci_type' => $dataType, '__ci_value' => $value])) {
+ return false;
+ }
+
+ if ($ttl !== 0) {
+ $this->redis->expireat($key, Time::now()->getTimestamp() + $ttl);
+ }
+
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function delete(string $key)
+ {
+ $key = static::validateKey($key);
+
+ return $this->redis->del($key) === 1;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return int
+ */
+ public function deleteMatching(string $pattern)
+ {
+ $matchedKeys = [];
+
+ foreach (new Keyspace($this->redis, $pattern) as $key) {
+ $matchedKeys[] = $key;
+ }
+
+ return $this->redis->del($matchedKeys);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function increment(string $key, int $offset = 1)
+ {
+ $key = static::validateKey($key);
+
+ return $this->redis->hincrby($key, 'data', $offset);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function decrement(string $key, int $offset = 1)
+ {
+ $key = static::validateKey($key);
+
+ return $this->redis->hincrby($key, 'data', -$offset);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function clean()
+ {
+ return $this->redis->flushdb()->getPayload() === 'OK';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getCacheInfo()
+ {
+ return $this->redis->info();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getMetaData(string $key)
+ {
+ $key = static::validateKey($key);
+
+ $data = array_combine(['__ci_value'], $this->redis->hmget($key, ['__ci_value']));
+
+ if (isset($data['__ci_value']) && $data['__ci_value'] !== false) {
+ $time = Time::now()->getTimestamp();
+ $ttl = $this->redis->ttl($key);
+
+ return [
+ 'expire' => $ttl > 0 ? $time + $ttl : null,
+ 'mtime' => $time,
+ 'data' => $data['__ci_value'],
+ ];
+ }
+
+ return null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isSupported(): bool
+ {
+ return class_exists(Client::class);
+ }
+}
diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php
new file mode 100644
index 0000000..22f4e43
--- /dev/null
+++ b/system/Cache/Handlers/RedisHandler.php
@@ -0,0 +1,258 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Cache\Handlers;
+
+use CodeIgniter\Exceptions\CriticalError;
+use CodeIgniter\I18n\Time;
+use Config\Cache;
+use Redis;
+use RedisException;
+
+/**
+ * Redis cache handler
+ *
+ * @see \CodeIgniter\Cache\Handlers\RedisHandlerTest
+ */
+class RedisHandler extends BaseHandler
+{
+ /**
+ * Default config
+ *
+ * @var array
+ */
+ protected $config = [
+ 'host' => '127.0.0.1',
+ 'password' => null,
+ 'port' => 6379,
+ 'timeout' => 0,
+ 'database' => 0,
+ ];
+
+ /**
+ * Redis connection
+ *
+ * @var Redis|null
+ */
+ protected $redis;
+
+ /**
+ * Note: Use `CacheFactory::getHandler()` to instantiate.
+ */
+ public function __construct(Cache $config)
+ {
+ $this->prefix = $config->prefix;
+
+ $this->config = array_merge($this->config, $config->redis);
+ }
+
+ /**
+ * Closes the connection to Redis if present.
+ */
+ public function __destruct()
+ {
+ if (isset($this->redis)) {
+ $this->redis->close();
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function initialize()
+ {
+ $config = $this->config;
+
+ $this->redis = new Redis();
+
+ try {
+ // Note:: If Redis is your primary cache choice, and it is "offline", every page load will end up been delayed by the timeout duration.
+ // I feel like some sort of temporary flag should be set, to indicate that we think Redis is "offline", allowing us to bypass the timeout for a set period of time.
+
+ if (! $this->redis->connect($config['host'], ($config['host'][0] === '/' ? 0 : $config['port']), $config['timeout'])) {
+ // Note:: I'm unsure if log_message() is necessary, however I'm not 100% comfortable removing it.
+ log_message('error', 'Cache: Redis connection failed. Check your configuration.');
+
+ throw new CriticalError('Cache: Redis connection failed. Check your configuration.');
+ }
+
+ if (isset($config['password']) && ! $this->redis->auth($config['password'])) {
+ log_message('error', 'Cache: Redis authentication failed.');
+
+ throw new CriticalError('Cache: Redis authentication failed.');
+ }
+
+ if (isset($config['database']) && ! $this->redis->select($config['database'])) {
+ log_message('error', 'Cache: Redis select database failed.');
+
+ throw new CriticalError('Cache: Redis select database failed.');
+ }
+ } catch (RedisException $e) {
+ throw new CriticalError('Cache: RedisException occurred with message (' . $e->getMessage() . ').');
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function get(string $key)
+ {
+ $key = static::validateKey($key, $this->prefix);
+ $data = $this->redis->hMGet($key, ['__ci_type', '__ci_value']);
+
+ if (! isset($data['__ci_type'], $data['__ci_value']) || $data['__ci_value'] === false) {
+ return null;
+ }
+
+ return match ($data['__ci_type']) {
+ 'array', 'object' => unserialize($data['__ci_value']),
+ // Yes, 'double' is returned and NOT 'float'
+ 'boolean', 'integer', 'double', 'string', 'NULL' => settype($data['__ci_value'], $data['__ci_type']) ? $data['__ci_value'] : null,
+ default => null,
+ };
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function save(string $key, $value, int $ttl = 60)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ switch ($dataType = gettype($value)) {
+ case 'array':
+ case 'object':
+ $value = serialize($value);
+ break;
+
+ case 'boolean':
+ case 'integer':
+ case 'double': // Yes, 'double' is returned and NOT 'float'
+ case 'string':
+ case 'NULL':
+ break;
+
+ case 'resource':
+ default:
+ return false;
+ }
+
+ if (! $this->redis->hMSet($key, ['__ci_type' => $dataType, '__ci_value' => $value])) {
+ return false;
+ }
+
+ if ($ttl !== 0) {
+ $this->redis->expireAt($key, Time::now()->getTimestamp() + $ttl);
+ }
+
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function delete(string $key)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ return $this->redis->del($key) === 1;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return int
+ */
+ public function deleteMatching(string $pattern)
+ {
+ /** @var list $matchedKeys */
+ $matchedKeys = [];
+ $pattern = static::validateKey($pattern, $this->prefix);
+ $iterator = null;
+
+ do {
+ /** @var false|list|Redis $keys */
+ $keys = $this->redis->scan($iterator, $pattern);
+
+ if (is_array($keys)) {
+ $matchedKeys = [...$matchedKeys, ...$keys];
+ }
+ } while ($iterator > 0);
+
+ return $this->redis->del($matchedKeys);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function increment(string $key, int $offset = 1)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ return $this->redis->hIncrBy($key, '__ci_value', $offset);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function decrement(string $key, int $offset = 1)
+ {
+ return $this->increment($key, -$offset);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function clean()
+ {
+ return $this->redis->flushDB();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getCacheInfo()
+ {
+ return $this->redis->info();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getMetaData(string $key)
+ {
+ $value = $this->get($key);
+
+ if ($value !== null) {
+ $time = Time::now()->getTimestamp();
+ $ttl = $this->redis->ttl(static::validateKey($key, $this->prefix));
+ assert(is_int($ttl));
+
+ return [
+ 'expire' => $ttl > 0 ? $time + $ttl : null,
+ 'mtime' => $time,
+ 'data' => $value,
+ ];
+ }
+
+ return null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isSupported(): bool
+ {
+ return extension_loaded('redis');
+ }
+}
diff --git a/system/Cache/Handlers/WincacheHandler.php b/system/Cache/Handlers/WincacheHandler.php
new file mode 100644
index 0000000..0ddee50
--- /dev/null
+++ b/system/Cache/Handlers/WincacheHandler.php
@@ -0,0 +1,152 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Cache\Handlers;
+
+use CodeIgniter\I18n\Time;
+use Config\Cache;
+use Exception;
+
+/**
+ * Cache handler for WinCache from Microsoft & IIS.
+ *
+ * @codeCoverageIgnore
+ */
+class WincacheHandler extends BaseHandler
+{
+ /**
+ * Note: Use `CacheFactory::getHandler()` to instantiate.
+ */
+ public function __construct(Cache $config)
+ {
+ $this->prefix = $config->prefix;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function initialize()
+ {
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function get(string $key)
+ {
+ $key = static::validateKey($key, $this->prefix);
+ $success = false;
+
+ $data = wincache_ucache_get($key, $success);
+
+ // Success returned by reference from wincache_ucache_get()
+ return $success ? $data : null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function save(string $key, $value, int $ttl = 60)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ return wincache_ucache_set($key, $value, $ttl);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function delete(string $key)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ return wincache_ucache_delete($key);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return never
+ */
+ public function deleteMatching(string $pattern)
+ {
+ throw new Exception('The deleteMatching method is not implemented for Wincache. You must select File, Redis or Predis handlers to use it.');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function increment(string $key, int $offset = 1)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ return wincache_ucache_inc($key, $offset);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function decrement(string $key, int $offset = 1)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ return wincache_ucache_dec($key, $offset);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function clean()
+ {
+ return wincache_ucache_clear();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getCacheInfo()
+ {
+ return wincache_ucache_info(true);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getMetaData(string $key)
+ {
+ $key = static::validateKey($key, $this->prefix);
+
+ if ($stored = wincache_ucache_info(false, $key)) {
+ $age = $stored['ucache_entries'][1]['age_seconds'];
+ $ttl = $stored['ucache_entries'][1]['ttl_seconds'];
+ $hitcount = $stored['ucache_entries'][1]['hitcount'];
+
+ return [
+ 'expire' => $ttl > 0 ? Time::now()->getTimestamp() + $ttl : null,
+ 'hitcount' => $hitcount,
+ 'age' => $age,
+ 'ttl' => $ttl,
+ ];
+ }
+
+ return false; // @TODO This will return null in a future release
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isSupported(): bool
+ {
+ return extension_loaded('wincache') && ini_get('wincache.ucenabled');
+ }
+}
diff --git a/system/Cache/ResponseCache.php b/system/Cache/ResponseCache.php
new file mode 100644
index 0000000..3889318
--- /dev/null
+++ b/system/Cache/ResponseCache.php
@@ -0,0 +1,157 @@
+
+ *
+ * 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\Header;
+use CodeIgniter\HTTP\IncomingRequest;
+use CodeIgniter\HTTP\ResponseInterface;
+use Config\Cache as CacheConfig;
+use Exception;
+
+/**
+ * Web Page Caching
+ *
+ * @see \CodeIgniter\Cache\ResponseCacheTest
+ */
+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|list
+ */
+ private $cacheQueryString = false;
+
+ /**
+ * Cache time to live.
+ *
+ * @var int seconds
+ */
+ private int $ttl = 0;
+
+ public function __construct(CacheConfig $config, private readonly CacheInterface $cache)
+ {
+ $this->cacheQueryString = $config->cacheQueryString;
+ }
+
+ /**
+ * @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($request->getMethod() . ':' . $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 $name => $value) {
+ if ($value instanceof Header) {
+ $headers[$name] = $value->getValueLine();
+ } else {
+ foreach ($value as $header) {
+ $headers[$name][] = $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
new file mode 100644
index 0000000..bcfb7bd
--- /dev/null
+++ b/system/CodeIgniter.php
@@ -0,0 +1,1157 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+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\Filters\Filters;
+use CodeIgniter\HTTP\CLIRequest;
+use CodeIgniter\HTTP\DownloadResponse;
+use CodeIgniter\HTTP\Exceptions\RedirectException;
+use CodeIgniter\HTTP\IncomingRequest;
+use CodeIgniter\HTTP\Method;
+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 as DeprecatedRedirectException;
+use CodeIgniter\Router\RouteCollectionInterface;
+use CodeIgniter\Router\Router;
+use Config\App;
+use Config\Cache;
+use Config\Feature;
+use Config\Kint as KintConfig;
+use Config\Services;
+use Exception;
+use Kint;
+use Kint\Renderer\CliRenderer;
+use Kint\Renderer\RichRenderer;
+use Locale;
+use LogicException;
+use Throwable;
+
+/**
+ * This class is the core of the framework, and will analyse the
+ * request, route it to a controller, and send back the response.
+ * Of course, there are variations to that flow, but this is the brains.
+ *
+ * @see \CodeIgniter\CodeIgniterTest
+ */
+class CodeIgniter
+{
+ /**
+ * The current version of CodeIgniter Framework
+ */
+ public const CI_VERSION = '4.5.4';
+
+ /**
+ * App startup time.
+ *
+ * @var float|null
+ */
+ protected $startTime;
+
+ /**
+ * Total app execution time
+ *
+ * @var float
+ */
+ protected $totalTime;
+
+ /**
+ * Main application configuration
+ *
+ * @var App
+ */
+ protected $config;
+
+ /**
+ * Timer instance.
+ *
+ * @var Timer
+ */
+ protected $benchmark;
+
+ /**
+ * Current request.
+ *
+ * @var CLIRequest|IncomingRequest|null
+ */
+ protected $request;
+
+ /**
+ * Current response.
+ *
+ * @var ResponseInterface
+ */
+ protected $response;
+
+ /**
+ * Router to use.
+ *
+ * @var Router
+ */
+ protected $router;
+
+ /**
+ * Controller to use.
+ *
+ * @var (Closure(mixed...): ResponseInterface|string)|string|null
+ */
+ protected $controller;
+
+ /**
+ * Controller method to invoke.
+ *
+ * @var string
+ */
+ protected $method;
+
+ /**
+ * Output handler to use.
+ *
+ * @var string
+ */
+ protected $output;
+
+ /**
+ * Cache expiration time
+ *
+ * @var int seconds
+ *
+ * @deprecated 4.4.0 Moved to ResponseCache::$ttl. No longer used.
+ */
+ protected static $cacheTTL = 0;
+
+ /**
+ * Context
+ * web: Invoked by HTTP request
+ * php-cli: Invoked by CLI via `php public/index.php`
+ *
+ * @phpstan-var 'php-cli'|'web'
+ */
+ protected ?string $context = null;
+
+ /**
+ * Whether to enable Control Filters.
+ */
+ protected bool $enableFilters = true;
+
+ /**
+ * Whether to return Response object or send response.
+ *
+ * @deprecated 4.4.0 No longer used.
+ */
+ protected bool $returnResponse = false;
+
+ /**
+ * Application output buffering level
+ */
+ protected int $bufferLevel;
+
+ /**
+ * Web Page Caching
+ */
+ protected ResponseCache $pageCache;
+
+ /**
+ * Constructor.
+ */
+ public function __construct(App $config)
+ {
+ $this->startTime = microtime(true);
+ $this->config = $config;
+
+ $this->pageCache = Services::responsecache();
+ }
+
+ /**
+ * Handles some basic app and environment setup.
+ *
+ * @return void
+ */
+ public function initialize()
+ {
+ // Set default locale on the server
+ Locale::setDefault($this->config->defaultLocale ?? 'en');
+
+ // Set default timezone on the server
+ date_default_timezone_set($this->config->appTimezone ?? 'UTC');
+ }
+
+ /**
+ * Checks system for missing required PHP extensions.
+ *
+ * @return void
+ *
+ * @throws FrameworkException
+ *
+ * @codeCoverageIgnore
+ *
+ * @deprecated 4.5.0 Moved to system/bootstrap.php.
+ */
+ protected function resolvePlatformExtensions()
+ {
+ $requiredExtensions = [
+ 'intl',
+ 'json',
+ 'mbstring',
+ ];
+
+ $missingExtensions = [];
+
+ foreach ($requiredExtensions as $extension) {
+ if (! extension_loaded($extension)) {
+ $missingExtensions[] = $extension;
+ }
+ }
+
+ if ($missingExtensions !== []) {
+ throw FrameworkException::forMissingExtension(implode(', ', $missingExtensions));
+ }
+ }
+
+ /**
+ * Initializes Kint
+ *
+ * @return void
+ *
+ * @deprecated 4.5.0 Moved to Autoloader.
+ */
+ protected function initializeKint()
+ {
+ if (CI_DEBUG) {
+ $this->autoloadKint();
+ $this->configureKint();
+ } elseif (class_exists(Kint::class)) {
+ // In case that Kint is already loaded via Composer.
+ Kint::$enabled_mode = false;
+ // @codeCoverageIgnore
+ }
+
+ helper('kint');
+ }
+
+ /**
+ * @deprecated 4.5.0 Moved to Autoloader.
+ */
+ private function autoloadKint(): void
+ {
+ // If we have KINT_DIR it means it's already loaded via composer
+ if (! defined('KINT_DIR')) {
+ spl_autoload_register(function ($class): void {
+ $class = explode('\\', $class);
+
+ if (array_shift($class) !== 'Kint') {
+ return;
+ }
+
+ $file = SYSTEMPATH . 'ThirdParty/Kint/' . implode('/', $class) . '.php';
+
+ if (is_file($file)) {
+ require_once $file;
+ }
+ });
+
+ require_once SYSTEMPATH . 'ThirdParty/Kint/init.php';
+ }
+ }
+
+ /**
+ * @deprecated 4.5.0 Moved to Autoloader.
+ */
+ private function configureKint(): void
+ {
+ $config = new KintConfig();
+
+ Kint::$depth_limit = $config->maxDepth;
+ Kint::$display_called_from = $config->displayCalledFrom;
+ Kint::$expanded = $config->expanded;
+
+ if (isset($config->plugins) && is_array($config->plugins)) {
+ Kint::$plugins = $config->plugins;
+ }
+
+ $csp = Services::csp();
+ if ($csp->enabled()) {
+ RichRenderer::$js_nonce = $csp->getScriptNonce();
+ RichRenderer::$css_nonce = $csp->getStyleNonce();
+ }
+
+ RichRenderer::$theme = $config->richTheme;
+ RichRenderer::$folder = $config->richFolder;
+ RichRenderer::$sort = $config->richSort;
+ if (isset($config->richObjectPlugins) && is_array($config->richObjectPlugins)) {
+ RichRenderer::$value_plugins = $config->richObjectPlugins;
+ }
+ if (isset($config->richTabPlugins) && is_array($config->richTabPlugins)) {
+ RichRenderer::$tab_plugins = $config->richTabPlugins;
+ }
+
+ CliRenderer::$cli_colors = $config->cliColors;
+ CliRenderer::$force_utf8 = $config->cliForceUTF8;
+ CliRenderer::$detect_width = $config->cliDetectWidth;
+ CliRenderer::$min_terminal_width = $config->cliMinWidth;
+ }
+
+ /**
+ * Launch the application!
+ *
+ * This is "the loop" if you will. The main entry point into the script
+ * that gets the required class instances, fires off the filters,
+ * tries to route the response, loads the controller and generally
+ * makes all the pieces work together.
+ *
+ * @param bool $returnResponse Used for testing purposes only.
+ *
+ * @return ResponseInterface|void
+ */
+ public function run(?RouteCollectionInterface $routes = null, bool $returnResponse = false)
+ {
+ if ($this->context === null) {
+ throw new LogicException(
+ 'Context must be set before run() is called. If you are upgrading from 4.1.x, '
+ . 'you need to merge `public/index.php` and `spark` file from `vendor/codeigniter4/framework`.'
+ );
+ }
+
+ $this->pageCache->setTtl(0);
+ $this->bufferLevel = ob_get_level();
+
+ $this->startBenchmark();
+
+ $this->getRequestObject();
+ $this->getResponseObject();
+
+ Events::trigger('pre_system');
+
+ $this->benchmark->stop('bootstrap');
+
+ $this->benchmark->start('required_before_filters');
+ // Start up the filters
+ $filters = Services::filters();
+ // Run required before filters
+ $possibleResponse = $this->runRequiredBeforeFilters($filters);
+
+ // If a ResponseInterface instance is returned then send it back to the client and stop
+ if ($possibleResponse instanceof ResponseInterface) {
+ $this->response = $possibleResponse;
+ } else {
+ try {
+ $this->response = $this->handleRequest($routes, config(Cache::class), $returnResponse);
+ } catch (DeprecatedRedirectException|ResponsableInterface $e) {
+ $this->outputBufferingEnd();
+ if ($e instanceof DeprecatedRedirectException) {
+ $e = new RedirectException($e->getMessage(), $e->getCode(), $e);
+ }
+
+ $this->response = $e->getResponse();
+ } catch (PageNotFoundException $e) {
+ $this->response = $this->display404errors($e);
+ } catch (Throwable $e) {
+ $this->outputBufferingEnd();
+
+ throw $e;
+ }
+ }
+
+ $this->runRequiredAfterFilters($filters);
+
+ // Is there a post-system event?
+ Events::trigger('post_system');
+
+ if ($returnResponse) {
+ return $this->response;
+ }
+
+ $this->sendResponse();
+ }
+
+ /**
+ * Run required before filters.
+ */
+ private function runRequiredBeforeFilters(Filters $filters): ?ResponseInterface
+ {
+ $possibleResponse = $filters->runRequired('before');
+ $this->benchmark->stop('required_before_filters');
+
+ // If a ResponseInterface instance is returned then send it back to the client and stop
+ if ($possibleResponse instanceof ResponseInterface) {
+ return $possibleResponse;
+ }
+
+ return null;
+ }
+
+ /**
+ * Run required after filters.
+ */
+ private function runRequiredAfterFilters(Filters $filters): void
+ {
+ $filters->setResponse($this->response);
+
+ // Run required after filters
+ $this->benchmark->start('required_after_filters');
+ $response = $filters->runRequired('after');
+ $this->benchmark->stop('required_after_filters');
+
+ if ($response instanceof ResponseInterface) {
+ $this->response = $response;
+ }
+ }
+
+ /**
+ * Invoked via php-cli command?
+ */
+ private function isPhpCli(): bool
+ {
+ return $this->context === 'php-cli';
+ }
+
+ /**
+ * Web access?
+ */
+ private function isWeb(): bool
+ {
+ return $this->context === 'web';
+ }
+
+ /**
+ * Disables Controller Filters.
+ */
+ public function disableFilters(): void
+ {
+ $this->enableFilters = false;
+ }
+
+ /**
+ * Handles the main request logic and fires the controller.
+ *
+ * @return ResponseInterface
+ *
+ * @throws PageNotFoundException
+ * @throws RedirectException
+ *
+ * @deprecated $returnResponse is deprecated.
+ */
+ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cacheConfig, bool $returnResponse = false)
+ {
+ if ($this->request instanceof IncomingRequest && $this->request->getMethod() === 'CLI') {
+ return $this->response->setStatusCode(405)->setBody('Method Not Allowed');
+ }
+
+ $routeFilters = $this->tryToRouteIt($routes);
+
+ // $uri is URL-encoded.
+ $uri = $this->request->getPath();
+
+ if ($this->enableFilters) {
+ /** @var Filters $filters */
+ $filters = service('filters');
+
+ // If any filters were specified within the routes file,
+ // we need to ensure it's active for the current request
+ if ($routeFilters !== null) {
+ $filters->enableFilters($routeFilters, 'before');
+
+ $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false;
+ if (! $oldFilterOrder) {
+ $routeFilters = array_reverse($routeFilters);
+ }
+
+ $filters->enableFilters($routeFilters, 'after');
+ }
+
+ // Run "before" filters
+ $this->benchmark->start('before_filters');
+ $possibleResponse = $filters->run($uri, 'before');
+ $this->benchmark->stop('before_filters');
+
+ // If a ResponseInterface instance is returned then send it back to the client and stop
+ if ($possibleResponse instanceof ResponseInterface) {
+ $this->outputBufferingEnd();
+
+ return $possibleResponse;
+ }
+
+ if ($possibleResponse instanceof IncomingRequest || $possibleResponse instanceof CLIRequest) {
+ $this->request = $possibleResponse;
+ }
+ }
+
+ $returned = $this->startController();
+
+ // Closure controller has run in startController().
+ if (! is_callable($this->controller)) {
+ $controller = $this->createController();
+
+ if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) {
+ throw PageNotFoundException::forMethodNotFound($this->method);
+ }
+
+ // Is there a "post_controller_constructor" event?
+ Events::trigger('post_controller_constructor');
+
+ $returned = $this->runController($controller);
+ } else {
+ $this->benchmark->stop('controller_constructor');
+ $this->benchmark->stop('controller');
+ }
+
+ // If $returned is a string, then the controller output something,
+ // probably a view, instead of echoing it directly. Send it along
+ // so it can be used with the output.
+ $this->gatherOutput($cacheConfig, $returned);
+
+ if ($this->enableFilters) {
+ /** @var Filters $filters */
+ $filters = service('filters');
+ $filters->setResponse($this->response);
+
+ // Run "after" filters
+ $this->benchmark->start('after_filters');
+ $response = $filters->run($uri, 'after');
+ $this->benchmark->stop('after_filters');
+
+ if ($response instanceof ResponseInterface) {
+ $this->response = $response;
+ }
+ }
+
+ // Skip unnecessary processing for special Responses.
+ if (
+ ! $this->response instanceof DownloadResponse
+ && ! $this->response instanceof RedirectResponse
+ ) {
+ // Save our current URI as the previous URI in the session
+ // for safer, more accurate use with `previous_url()` helper function.
+ $this->storePreviousURL(current_url(true));
+ }
+
+ unset($uri);
+
+ return $this->response;
+ }
+
+ /**
+ * You can load different configurations depending on your
+ * current environment. Setting the environment also influences
+ * things like logging and error reporting.
+ *
+ * This can be set to anything, but default usage is:
+ *
+ * development
+ * testing
+ * production
+ *
+ * @codeCoverageIgnore
+ *
+ * @return void
+ *
+ * @deprecated 4.4.0 No longer used. Moved to index.php and spark.
+ */
+ protected function detectEnvironment()
+ {
+ // Make sure ENVIRONMENT isn't already set by other means.
+ if (! defined('ENVIRONMENT')) {
+ define('ENVIRONMENT', env('CI_ENVIRONMENT', 'production'));
+ }
+ }
+
+ /**
+ * Load any custom boot files based upon the current environment.
+ *
+ * If no boot file exists, we shouldn't continue because something
+ * is wrong. At the very least, they should have error reporting setup.
+ *
+ * @return void
+ *
+ * @deprecated 4.5.0 Moved to system/bootstrap.php.
+ */
+ protected function bootstrapEnvironment()
+ {
+ if (is_file(APPPATH . 'Config/Boot/' . ENVIRONMENT . '.php')) {
+ require_once APPPATH . 'Config/Boot/' . ENVIRONMENT . '.php';
+ } else {
+ // @codeCoverageIgnoreStart
+ header('HTTP/1.1 503 Service Unavailable.', true, 503);
+ echo 'The application environment is not set correctly.';
+
+ exit(EXIT_ERROR); // EXIT_ERROR
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ /**
+ * Start the Benchmark
+ *
+ * The timer is used to display total script execution both in the
+ * debug toolbar, and potentially on the displayed page.
+ *
+ * @return void
+ */
+ protected function startBenchmark()
+ {
+ if ($this->startTime === null) {
+ $this->startTime = microtime(true);
+ }
+
+ $this->benchmark = Services::timer();
+ $this->benchmark->start('total_execution', $this->startTime);
+ $this->benchmark->start('bootstrap');
+ }
+
+ /**
+ * Sets a Request object to be used for this request.
+ * Used when running certain tests.
+ *
+ * @param CLIRequest|IncomingRequest $request
+ *
+ * @return $this
+ *
+ * @internal Used for testing purposes only.
+ * @testTag
+ */
+ public function setRequest($request)
+ {
+ $this->request = $request;
+
+ return $this;
+ }
+
+ /**
+ * Get our Request object, (either IncomingRequest or CLIRequest).
+ *
+ * @return void
+ */
+ protected function getRequestObject()
+ {
+ if ($this->request instanceof Request) {
+ $this->spoofRequestMethod();
+
+ return;
+ }
+
+ if ($this->isPhpCli()) {
+ Services::createRequest($this->config, true);
+ } else {
+ Services::createRequest($this->config);
+ }
+
+ $this->request = service('request');
+
+ $this->spoofRequestMethod();
+ }
+
+ /**
+ * Get our Response object, and set some default values, including
+ * the HTTP protocol version and a default successful response.
+ *
+ * @return void
+ */
+ protected function getResponseObject()
+ {
+ $this->response = Services::response($this->config);
+
+ if ($this->isWeb()) {
+ $this->response->setProtocolVersion($this->request->getProtocolVersion());
+ }
+
+ // Assume success until proven otherwise.
+ $this->response->setStatusCode(200);
+ }
+
+ /**
+ * Force Secure Site Access? If the config value 'forceGlobalSecureRequests'
+ * is true, will enforce that all requests to this site are made through
+ * HTTPS. Will redirect the user to the current page with HTTPS, as well
+ * as set the HTTP Strict Transport Security header for those browsers
+ * that support it.
+ *
+ * @param int $duration How long the Strict Transport Security
+ * should be enforced for this URL.
+ *
+ * @return void
+ *
+ * @deprecated 4.5.0 No longer used. Moved to ForceHTTPS filter.
+ */
+ protected function forceSecureAccess($duration = 31_536_000)
+ {
+ if ($this->config->forceGlobalSecureRequests !== true) {
+ return;
+ }
+
+ force_https($duration, $this->request, $this->response);
+ }
+
+ /**
+ * Determines if a response has been cached for the given URI.
+ *
+ * @return false|ResponseInterface
+ *
+ * @throws Exception
+ *
+ * @deprecated 4.5.0 PageCache required filter is used. No longer used.
+ * @deprecated 4.4.2 The parameter $config is deprecated. No longer used.
+ */
+ public function displayCache(Cache $config)
+ {
+ $cachedResponse = $this->pageCache->get($this->request, $this->response);
+ if ($cachedResponse instanceof ResponseInterface) {
+ $this->response = $cachedResponse;
+
+ $this->totalTime = $this->benchmark->getElapsedTime('total_execution');
+ $output = $this->displayPerformanceMetrics($cachedResponse->getBody());
+ $this->response->setBody($output);
+
+ return $this->response;
+ }
+
+ return false;
+ }
+
+ /**
+ * Tells the app that the final output should be cached.
+ *
+ * @deprecated 4.4.0 Moved to ResponseCache::setTtl(). No longer used.
+ *
+ * @return void
+ */
+ public static function cache(int $time)
+ {
+ static::$cacheTTL = $time;
+ }
+
+ /**
+ * Caches the full response from the current request. Used for
+ * full-page caching for very high performance.
+ *
+ * @return bool
+ *
+ * @deprecated 4.4.0 No longer used.
+ */
+ public function cachePage(Cache $config)
+ {
+ $headers = [];
+
+ foreach ($this->response->headers() as $header) {
+ $headers[$header->getName()] = $header->getValueLine();
+ }
+
+ return cache()->save($this->generateCacheName($config), serialize(['headers' => $headers, 'output' => $this->output]), static::$cacheTTL);
+ }
+
+ /**
+ * Returns an array with our basic performance stats collected.
+ */
+ public function getPerformanceStats(): array
+ {
+ // After filter debug toolbar requires 'total_execution'.
+ $this->totalTime = $this->benchmark->getElapsedTime('total_execution');
+
+ return [
+ 'startTime' => $this->startTime,
+ 'totalTime' => $this->totalTime,
+ ];
+ }
+
+ /**
+ * Generates the cache name to use for our full-page caching.
+ *
+ * @deprecated 4.4.0 No longer used.
+ */
+ protected function generateCacheName(Cache $config): string
+ {
+ if ($this->request instanceof CLIRequest) {
+ return md5($this->request->getPath());
+ }
+
+ $uri = clone $this->request->getUri();
+
+ $query = $config->cacheQueryString
+ ? $uri->getQuery(is_array($config->cacheQueryString) ? ['only' => $config->cacheQueryString] : [])
+ : '';
+
+ return md5((string) $uri->setFragment('')->setQuery($query));
+ }
+
+ /**
+ * Replaces the elapsed_time and memory_usage tag.
+ *
+ * @deprecated 4.5.0 PerformanceMetrics required filter is used. No longer used.
+ */
+ public function displayPerformanceMetrics(string $output): string
+ {
+ return str_replace(
+ ['{elapsed_time}', '{memory_usage}'],
+ [(string) $this->totalTime, number_format(memory_get_peak_usage() / 1024 / 1024, 3)],
+ $output
+ );
+ }
+
+ /**
+ * Try to Route It - As it sounds like, works with the router to
+ * match a route against the current URI. If the route is a
+ * "redirect route", will also handle the redirect.
+ *
+ * @param RouteCollectionInterface|null $routes A collection interface to use in place
+ * of the config file.
+ *
+ * @return list|string|null Route filters, that is, the filters specified in the routes file
+ *
+ * @throws RedirectException
+ */
+ protected function tryToRouteIt(?RouteCollectionInterface $routes = null)
+ {
+ $this->benchmark->start('routing');
+
+ if ($routes === null) {
+ $routes = service('routes')->loadRoutes();
+ }
+
+ // $routes is defined in Config/Routes.php
+ $this->router = Services::router($routes, $this->request);
+
+ // $uri is URL-encoded.
+ $uri = $this->request->getPath();
+
+ $this->outputBufferingStart();
+
+ $this->controller = $this->router->handle($uri);
+ $this->method = $this->router->methodName();
+
+ // If a {locale} segment was matched in the final route,
+ // then we need to set the correct locale on our Request.
+ if ($this->router->hasLocale()) {
+ $this->request->setLocale($this->router->getLocale());
+ }
+
+ $this->benchmark->stop('routing');
+
+ return $this->router->getFilters();
+ }
+
+ /**
+ * Determines the path to use for us to try to route to, based
+ * on the CLI/IncomingRequest path.
+ *
+ * @return string
+ *
+ * @deprecated 4.5.0 No longer used.
+ */
+ protected function determinePath()
+ {
+ return $this->request->getPath();
+ }
+
+ /**
+ * Now that everything has been setup, this method attempts to run the
+ * controller method and make the script go. If it's not able to, will
+ * show the appropriate Page Not Found error.
+ *
+ * @return ResponseInterface|string|void
+ */
+ protected function startController()
+ {
+ $this->benchmark->start('controller');
+ $this->benchmark->start('controller_constructor');
+
+ // Is it routed to a Closure?
+ if (is_object($this->controller) && ($this->controller::class === 'Closure')) {
+ $controller = $this->controller;
+
+ return $controller(...$this->router->params());
+ }
+
+ // No controller specified - we don't know what to do now.
+ if (! isset($this->controller)) {
+ throw PageNotFoundException::forEmptyController();
+ }
+
+ // Try to autoload the class
+ if (
+ ! class_exists($this->controller, true)
+ || ($this->method[0] === '_' && $this->method !== '__invoke')
+ ) {
+ throw PageNotFoundException::forControllerNotFound($this->controller, $this->method);
+ }
+ }
+
+ /**
+ * Instantiates the controller class.
+ *
+ * @return Controller
+ */
+ protected function createController()
+ {
+ assert(is_string($this->controller));
+
+ $class = new $this->controller();
+ $class->initController($this->request, $this->response, Services::logger());
+
+ $this->benchmark->stop('controller_constructor');
+
+ return $class;
+ }
+
+ /**
+ * Runs the controller, allowing for _remap methods to function.
+ *
+ * CI4 supports three types of requests:
+ * 1. Web: URI segments become parameters, sent to Controllers via Routes,
+ * output controlled by Headers to browser
+ * 2. PHP CLI: accessed by CLI via php public/index.php, arguments become URI segments,
+ * sent to Controllers via Routes, output varies
+ *
+ * @param Controller $class
+ *
+ * @return false|ResponseInterface|string|void
+ */
+ protected function runController($class)
+ {
+ // This is a Web request or PHP CLI request
+ $params = $this->router->params();
+
+ // The controller method param types may not be string.
+ // So cannot set `declare(strict_types=1)` in this file.
+ $output = method_exists($class, '_remap')
+ ? $class->_remap($this->method, ...$params)
+ : $class->{$this->method}(...$params);
+
+ $this->benchmark->stop('controller');
+
+ return $output;
+ }
+
+ /**
+ * Displays a 404 Page Not Found error. If set, will try to
+ * call the 404Override controller/method that was set in routing config.
+ *
+ * @return ResponseInterface|void
+ */
+ protected function display404errors(PageNotFoundException $e)
+ {
+ $this->response->setStatusCode($e->getCode());
+
+ // Is there a 404 Override available?
+ if ($override = $this->router->get404Override()) {
+ $returned = null;
+
+ if ($override instanceof Closure) {
+ echo $override($e->getMessage());
+ } elseif (is_array($override)) {
+ $this->benchmark->start('controller');
+ $this->benchmark->start('controller_constructor');
+
+ $this->controller = $override[0];
+ $this->method = $override[1];
+
+ $controller = $this->createController();
+
+ $returned = $controller->{$this->method}($e->getMessage());
+
+ $this->benchmark->stop('controller');
+ }
+
+ unset($override);
+
+ $cacheConfig = config(Cache::class);
+ $this->gatherOutput($cacheConfig, $returned);
+
+ return $this->response;
+ }
+
+ $this->outputBufferingEnd();
+
+ // Throws new PageNotFoundException and remove exception message on production.
+ throw PageNotFoundException::forPageNotFound(
+ (ENVIRONMENT !== 'production' || ! $this->isWeb()) ? $e->getMessage() : null
+ );
+ }
+
+ /**
+ * Gathers the script output from the buffer, replaces some execution
+ * time tag in the output and displays the debug toolbar, if required.
+ *
+ * @param Cache|null $cacheConfig Deprecated. No longer used.
+ * @param ResponseInterface|string|null $returned
+ *
+ * @deprecated $cacheConfig is deprecated.
+ *
+ * @return void
+ */
+ protected function gatherOutput(?Cache $cacheConfig = null, $returned = null)
+ {
+ $this->output = $this->outputBufferingEnd();
+
+ if ($returned instanceof DownloadResponse) {
+ $this->response = $returned;
+
+ return;
+ }
+ // If the controller returned a response object,
+ // we need to grab the body from it so it can
+ // be added to anything else that might have been
+ // echoed already.
+ // We also need to save the instance locally
+ // so that any status code changes, etc, take place.
+ if ($returned instanceof ResponseInterface) {
+ $this->response = $returned;
+ $returned = $returned->getBody();
+ }
+
+ if (is_string($returned)) {
+ $this->output .= $returned;
+ }
+
+ $this->response->setBody($this->output);
+ }
+
+ /**
+ * If we have a session object to use, store the current URI
+ * as the previous URI. This is called just prior to sending the
+ * response to the client, and will make it available next request.
+ *
+ * This helps provider safer, more reliable previous_url() detection.
+ *
+ * @param string|URI $uri
+ *
+ * @return void
+ */
+ public function storePreviousURL($uri)
+ {
+ // Ignore CLI requests
+ if (! $this->isWeb()) {
+ return;
+ }
+ // Ignore AJAX requests
+ if (method_exists($this->request, 'isAJAX') && $this->request->isAJAX()) {
+ return;
+ }
+
+ // Ignore unroutable responses
+ if ($this->response instanceof DownloadResponse || $this->response instanceof RedirectResponse) {
+ return;
+ }
+
+ // Ignore non-HTML responses
+ if (! str_contains($this->response->getHeaderLine('Content-Type'), 'text/html')) {
+ return;
+ }
+
+ // This is mainly needed during testing...
+ if (is_string($uri)) {
+ $uri = new URI($uri);
+ }
+
+ if (isset($_SESSION)) {
+ session()->set('_ci_previous_url', URI::createURIString(
+ $uri->getScheme(),
+ $uri->getAuthority(),
+ $uri->getPath(),
+ $uri->getQuery(),
+ $uri->getFragment()
+ ));
+ }
+ }
+
+ /**
+ * Modifies the Request Object to use a different method if a POST
+ * variable called _method is found.
+ *
+ * @return void
+ */
+ public function spoofRequestMethod()
+ {
+ // Only works with POSTED forms
+ if ($this->request->getMethod() !== Method::POST) {
+ return;
+ }
+
+ $method = $this->request->getPost('_method');
+
+ if ($method === null) {
+ return;
+ }
+
+ // Only allows PUT, PATCH, DELETE
+ if (in_array($method, [Method::PUT, Method::PATCH, Method::DELETE], true)) {
+ $this->request = $this->request->setMethod($method);
+ }
+ }
+
+ /**
+ * Sends the output of this request back to the client.
+ * This is what they've been waiting for!
+ *
+ * @return void
+ */
+ protected function sendResponse()
+ {
+ $this->response->send();
+ }
+
+ /**
+ * Exits the application, setting the exit code for CLI-based applications
+ * that might be watching.
+ *
+ * Made into a separate method so that it can be mocked during testing
+ * without actually stopping script execution.
+ *
+ * @param int $code
+ *
+ * @deprecated 4.4.0 No longer Used. Moved to index.php.
+ *
+ * @return void
+ */
+ protected function callExit($code)
+ {
+ exit($code); // @codeCoverageIgnore
+ }
+
+ /**
+ * Sets the app context.
+ *
+ * @phpstan-param 'php-cli'|'web' $context
+ *
+ * @return $this
+ */
+ public function setContext(string $context)
+ {
+ $this->context = $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/Cache/ClearCache.php b/system/Commands/Cache/ClearCache.php
new file mode 100644
index 0000000..e1180c2
--- /dev/null
+++ b/system/Commands/Cache/ClearCache.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\Commands\Cache;
+
+use CodeIgniter\Cache\CacheFactory;
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use Config\Cache;
+
+/**
+ * Clears current cache.
+ */
+class ClearCache extends BaseCommand
+{
+ /**
+ * Command grouping.
+ *
+ * @var string
+ */
+ protected $group = 'Cache';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'cache:clear';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Clears the current system caches.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'cache:clear []';
+
+ /**
+ * the Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'driver' => 'The cache driver to use',
+ ];
+
+ /**
+ * Clears the cache
+ */
+ public function run(array $params)
+ {
+ $config = config(Cache::class);
+ $handler = $params[0] ?? $config->handler;
+
+ if (! array_key_exists($handler, $config->validHandlers)) {
+ CLI::error($handler . ' is not a valid cache handler.');
+
+ return;
+ }
+
+ $config->handler = $handler;
+ $cache = CacheFactory::getHandler($config);
+
+ if (! $cache->clean()) {
+ // @codeCoverageIgnoreStart
+ CLI::error('Error while clearing the cache.');
+
+ return;
+ // @codeCoverageIgnoreEnd
+ }
+
+ CLI::write(CLI::color('Cache cleared.', 'green'));
+ }
+}
diff --git a/system/Commands/Cache/InfoCache.php b/system/Commands/Cache/InfoCache.php
new file mode 100644
index 0000000..abeabd7
--- /dev/null
+++ b/system/Commands/Cache/InfoCache.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\Commands\Cache;
+
+use CodeIgniter\Cache\CacheFactory;
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\I18n\Time;
+use Config\Cache;
+
+/**
+ * Shows information on the cache.
+ */
+class InfoCache extends BaseCommand
+{
+ /**
+ * Command grouping.
+ *
+ * @var string
+ */
+ protected $group = 'Cache';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'cache:info';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Shows file cache information in the current system.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'cache:info';
+
+ /**
+ * Clears the cache
+ */
+ public function run(array $params)
+ {
+ $config = config(Cache::class);
+ helper('number');
+
+ if ($config->handler !== 'file') {
+ CLI::error('This command only supports the file cache handler.');
+
+ return;
+ }
+
+ $cache = CacheFactory::getHandler($config);
+ $caches = $cache->getCacheInfo();
+ $tbody = [];
+
+ foreach ($caches as $key => $field) {
+ $tbody[] = [
+ $key,
+ clean_path($field['server_path']),
+ number_to_size($field['size']),
+ Time::createFromTimestamp($field['date']),
+ ];
+ }
+
+ $thead = [
+ CLI::color('Name', 'green'),
+ CLI::color('Server Path', 'green'),
+ CLI::color('Size', 'green'),
+ CLI::color('Date', 'green'),
+ ];
+
+ CLI::table($tbody, $thead);
+ }
+}
diff --git a/system/Commands/Database/CreateDatabase.php b/system/Commands/Database/CreateDatabase.php
new file mode 100644
index 0000000..0c8d201
--- /dev/null
+++ b/system/Commands/Database/CreateDatabase.php
@@ -0,0 +1,154 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Database;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\Config\Factories;
+use CodeIgniter\Database\SQLite3\Connection;
+use Config\Database;
+use Throwable;
+
+/**
+ * Creates a new database.
+ */
+class CreateDatabase extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'Database';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'db:create';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Create a new database schema.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'db:create [options]';
+
+ /**
+ * The Command's arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'db_name' => 'The database name to use',
+ ];
+
+ /**
+ * The Command's options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--ext' => 'File extension of the database file for SQLite3. Can be `db` or `sqlite`. Defaults to `db`.',
+ ];
+
+ /**
+ * Creates a new database.
+ */
+ public function run(array $params)
+ {
+ $name = array_shift($params);
+
+ if (empty($name)) {
+ $name = CLI::prompt('Database name', null, 'required'); // @codeCoverageIgnore
+ }
+
+ try {
+ $config = config(Database::class);
+
+ // Set to an empty database to prevent connection errors.
+ $group = ENVIRONMENT === 'testing' ? 'tests' : $config->defaultGroup;
+
+ $config->{$group}['database'] = '';
+
+ $db = Database::connect();
+
+ // Special SQLite3 handling
+ if ($db instanceof Connection) {
+ $ext = $params['ext'] ?? CLI::getOption('ext') ?? 'db';
+
+ if (! in_array($ext, ['db', 'sqlite'], true)) {
+ $ext = CLI::prompt('Please choose a valid file extension', ['db', 'sqlite']); // @codeCoverageIgnore
+ }
+
+ if ($name !== ':memory:') {
+ $name = str_replace(['.db', '.sqlite'], '', $name) . ".{$ext}";
+ }
+
+ $config->{$group}['DBDriver'] = 'SQLite3';
+ $config->{$group}['database'] = $name;
+
+ if ($name !== ':memory:') {
+ $dbName = ! str_contains($name, DIRECTORY_SEPARATOR) ? WRITEPATH . $name : $name;
+
+ if (is_file($dbName)) {
+ CLI::error("Database \"{$dbName}\" already exists.", 'light_gray', 'red');
+ CLI::newLine();
+
+ return;
+ }
+
+ unset($dbName);
+ }
+
+ // Connect to new SQLite3 to create new database
+ $db = Database::connect(null, false);
+ $db->connect();
+
+ if (! is_file($db->getDatabase()) && $name !== ':memory:') {
+ // @codeCoverageIgnoreStart
+ CLI::error('Database creation failed.', 'light_gray', 'red');
+ CLI::newLine();
+
+ return;
+ // @codeCoverageIgnoreEnd
+ }
+ } elseif (! Database::forge()->createDatabase($name)) {
+ // @codeCoverageIgnoreStart
+ CLI::error('Database creation failed.', 'light_gray', 'red');
+ CLI::newLine();
+
+ return;
+ // @codeCoverageIgnoreEnd
+ }
+
+ CLI::write("Database \"{$name}\" successfully created.", 'green');
+ CLI::newLine();
+ } catch (Throwable $e) {
+ $this->showError($e);
+ } finally {
+ Factories::reset('config');
+ Database::connect(null, false);
+ }
+ }
+}
diff --git a/system/Commands/Database/Migrate.php b/system/Commands/Database/Migrate.php
new file mode 100644
index 0000000..b422e4a
--- /dev/null
+++ b/system/Commands/Database/Migrate.php
@@ -0,0 +1,103 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Database;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use Throwable;
+
+/**
+ * Runs all new migrations.
+ */
+class Migrate extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'Database';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'migrate';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Locates and runs all new migrations against the database.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'migrate [options]';
+
+ /**
+ * the Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '-n' => 'Set migration namespace',
+ '-g' => 'Set database group',
+ '--all' => 'Set for all namespaces, will ignore (-n) option',
+ ];
+
+ /**
+ * Ensures that all migrations have been run.
+ */
+ public function run(array $params)
+ {
+ $runner = service('migrations');
+ $runner->clearCliMessages();
+
+ CLI::write(lang('Migrations.latest'), 'yellow');
+
+ $namespace = $params['n'] ?? CLI::getOption('n');
+ $group = $params['g'] ?? CLI::getOption('g');
+
+ try {
+ if (array_key_exists('all', $params) || CLI::getOption('all')) {
+ $runner->setNamespace(null);
+ } elseif ($namespace) {
+ $runner->setNamespace($namespace);
+ }
+
+ if (! $runner->latest($group)) {
+ CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore
+ }
+
+ $messages = $runner->getCliMessages();
+
+ foreach ($messages as $message) {
+ CLI::write($message);
+ }
+
+ CLI::write(lang('Migrations.migrated'), 'green');
+
+ // @codeCoverageIgnoreStart
+ } catch (Throwable $e) {
+ $this->showError($e);
+ // @codeCoverageIgnoreEnd
+ }
+ }
+}
diff --git a/system/Commands/Database/MigrateRefresh.php b/system/Commands/Database/MigrateRefresh.php
new file mode 100644
index 0000000..e5e8a6d
--- /dev/null
+++ b/system/Commands/Database/MigrateRefresh.php
@@ -0,0 +1,89 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Database;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+
+/**
+ * Does a rollback followed by a latest to refresh the current state
+ * of the database.
+ */
+class MigrateRefresh extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'Database';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'migrate:refresh';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Does a rollback followed by a latest to refresh the current state of the database.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'migrate:refresh [options]';
+
+ /**
+ * the Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '-n' => 'Set migration namespace',
+ '-g' => 'Set database group',
+ '--all' => 'Set latest for all namespace, will ignore (-n) option',
+ '-f' => 'Force command - this option allows you to bypass the confirmation question when running this command in a production environment',
+ ];
+
+ /**
+ * Does a rollback followed by a latest to refresh the current state
+ * of the database.
+ */
+ public function run(array $params)
+ {
+ $params['b'] = 0;
+
+ if (ENVIRONMENT === 'production') {
+ // @codeCoverageIgnoreStart
+ $force = array_key_exists('f', $params) || CLI::getOption('f');
+
+ if (! $force && CLI::prompt(lang('Migrations.refreshConfirm'), ['y', 'n']) === 'n') {
+ return;
+ }
+
+ $params['f'] = null;
+ // @codeCoverageIgnoreEnd
+ }
+
+ $this->call('migrate:rollback', $params);
+ $this->call('migrate', $params);
+ }
+}
diff --git a/system/Commands/Database/MigrateRollback.php b/system/Commands/Database/MigrateRollback.php
new file mode 100644
index 0000000..e1c788e
--- /dev/null
+++ b/system/Commands/Database/MigrateRollback.php
@@ -0,0 +1,119 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Database;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\Database\MigrationRunner;
+use Throwable;
+
+/**
+ * Runs all of the migrations in reverse order, until they have
+ * all been unapplied.
+ */
+class MigrateRollback extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'Database';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'migrate:rollback';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Runs the "down" method for all migrations in the last batch.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'migrate:rollback [options]';
+
+ /**
+ * the Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '-b' => 'Specify a batch to roll back to; e.g. "3" to return to batch #3',
+ '-f' => 'Force command - this option allows you to bypass the confirmation question when running this command in a production environment',
+ ];
+
+ /**
+ * Runs all of the migrations in reverse order, until they have
+ * all been unapplied.
+ */
+ public function run(array $params)
+ {
+ if (ENVIRONMENT === 'production') {
+ // @codeCoverageIgnoreStart
+ $force = array_key_exists('f', $params) || CLI::getOption('f');
+
+ if (! $force && CLI::prompt(lang('Migrations.rollBackConfirm'), ['y', 'n']) === 'n') {
+ return;
+ }
+ // @codeCoverageIgnoreEnd
+ }
+
+ /** @var MigrationRunner $runner */
+ $runner = service('migrations');
+
+ try {
+ $batch = $params['b'] ?? CLI::getOption('b') ?? $runner->getLastBatch() - 1;
+
+ if (is_string($batch)) {
+ if (! ctype_digit($batch)) {
+ CLI::error('Invalid batch number: ' . $batch, 'light_gray', 'red');
+ CLI::newLine();
+
+ return EXIT_ERROR;
+ }
+
+ $batch = (int) $batch;
+ }
+
+ CLI::write(lang('Migrations.rollingBack') . ' ' . $batch, 'yellow');
+
+ if (! $runner->regress($batch)) {
+ CLI::error(lang('Migrations.generalFault'), 'light_gray', 'red'); // @codeCoverageIgnore
+ }
+
+ $messages = $runner->getCliMessages();
+
+ foreach ($messages as $message) {
+ CLI::write($message);
+ }
+
+ CLI::write('Done rolling back migrations.', 'green');
+
+ // @codeCoverageIgnoreStart
+ } catch (Throwable $e) {
+ $this->showError($e);
+ // @codeCoverageIgnoreEnd
+ }
+ }
+}
diff --git a/system/Commands/Database/MigrateStatus.php b/system/Commands/Database/MigrateStatus.php
new file mode 100644
index 0000000..9506c2e
--- /dev/null
+++ b/system/Commands/Database/MigrateStatus.php
@@ -0,0 +1,168 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Database;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+
+/**
+ * Displays a list of all migrations and whether they've been run or not.
+ *
+ * @see \CodeIgniter\Commands\Database\MigrateStatusTest
+ */
+class MigrateStatus extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'Database';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'migrate:status';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Displays a list of all migrations and whether they\'ve been run or not.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'migrate:status [options]';
+
+ /**
+ * the Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '-g' => 'Set database group',
+ ];
+
+ /**
+ * Namespaces to ignore when looking for migrations.
+ *
+ * @var list
+ */
+ protected $ignoredNamespaces = [
+ 'CodeIgniter',
+ 'Config',
+ 'Kint',
+ 'Laminas\ZendFrameworkBridge',
+ 'Laminas\Escaper',
+ 'Psr\Log',
+ ];
+
+ /**
+ * Displays a list of all migrations and whether they've been run or not.
+ *
+ * @param array $params
+ */
+ public function run(array $params)
+ {
+ $runner = service('migrations');
+ $paramGroup = $params['g'] ?? CLI::getOption('g');
+
+ // Get all namespaces
+ $namespaces = service('autoloader')->getNamespace();
+
+ // Collection of migration status
+ $status = [];
+
+ foreach (array_keys($namespaces) as $namespace) {
+ if (ENVIRONMENT !== 'testing') {
+ // Make Tests\\Support discoverable for testing
+ $this->ignoredNamespaces[] = 'Tests\Support'; // @codeCoverageIgnore
+ }
+
+ if (in_array($namespace, $this->ignoredNamespaces, true)) {
+ continue;
+ }
+
+ if (APP_NAMESPACE !== 'App' && $namespace === 'App') {
+ continue; // @codeCoverageIgnore
+ }
+
+ $migrations = $runner->findNamespaceMigrations($namespace);
+
+ if (empty($migrations)) {
+ continue;
+ }
+
+ $runner->setNamespace($namespace);
+ $history = $runner->getHistory((string) $paramGroup);
+ ksort($migrations);
+
+ foreach ($migrations as $uid => $migration) {
+ $migrations[$uid]->name = mb_substr($migration->name, (int) mb_strpos($migration->name, $uid . '_'));
+
+ $date = '---';
+ $group = '---';
+ $batch = '---';
+
+ foreach ($history as $row) {
+ // @codeCoverageIgnoreStart
+ if ($runner->getObjectUid($row) !== $migration->uid) {
+ continue;
+ }
+
+ $date = date('Y-m-d H:i:s', (int) $row->time);
+ $group = $row->group;
+ $batch = $row->batch;
+ // @codeCoverageIgnoreEnd
+ }
+
+ $status[] = [
+ $namespace,
+ $migration->version,
+ $migration->name,
+ $group,
+ $date,
+ $batch,
+ ];
+ }
+ }
+
+ if ($status === []) {
+ // @codeCoverageIgnoreStart
+ CLI::error(lang('Migrations.noneFound'), 'light_gray', 'red');
+ CLI::newLine();
+
+ return;
+ // @codeCoverageIgnoreEnd
+ }
+
+ $headers = [
+ CLI::color(lang('Migrations.namespace'), 'yellow'),
+ CLI::color(lang('Migrations.version'), 'yellow'),
+ CLI::color(lang('Migrations.filename'), 'yellow'),
+ CLI::color(lang('Migrations.group'), 'yellow'),
+ CLI::color(str_replace(': ', '', lang('Migrations.on')), 'yellow'),
+ CLI::color(lang('Migrations.batch'), 'yellow'),
+ ];
+
+ CLI::table($status, $headers);
+ }
+}
diff --git a/system/Commands/Database/Seed.php b/system/Commands/Database/Seed.php
new file mode 100644
index 0000000..fc1c0f1
--- /dev/null
+++ b/system/Commands/Database/Seed.php
@@ -0,0 +1,84 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Database;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\Database\Seeder;
+use Config\Database;
+use Throwable;
+
+/**
+ * Runs the specified Seeder file to populate the database
+ * with some data.
+ */
+class Seed extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'Database';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'db:seed';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Runs the specified seeder to populate known data into the database.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'db:seed ';
+
+ /**
+ * the Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'seeder_name' => 'The seeder name to run',
+ ];
+
+ /**
+ * Passes to Seeder to populate the database.
+ */
+ public function run(array $params)
+ {
+ $seeder = new Seeder(new Database());
+ $seedName = array_shift($params);
+
+ if (empty($seedName)) {
+ $seedName = CLI::prompt(lang('Migrations.migSeeder'), null, 'required'); // @codeCoverageIgnore
+ }
+
+ try {
+ $seeder->call($seedName);
+ } catch (Throwable $e) {
+ $this->showError($e);
+ }
+ }
+}
diff --git a/system/Commands/Database/ShowTableInfo.php b/system/Commands/Database/ShowTableInfo.php
new file mode 100644
index 0000000..147b6eb
--- /dev/null
+++ b/system/Commands/Database/ShowTableInfo.php
@@ -0,0 +1,345 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Database;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\Database\BaseConnection;
+use Config\Database;
+use InvalidArgumentException;
+
+/**
+ * Get table data if it exists in the database.
+ *
+ * @see \CodeIgniter\Commands\Database\ShowTableInfoTest
+ */
+class ShowTableInfo extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'Database';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'db:table';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Retrieves information on the selected table.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = <<<'EOL'
+ db:table [] [options]
+
+ Examples:
+ db:table --show
+ db:table --metadata
+ db:table my_table --metadata
+ db:table my_table
+ db:table my_table --limit-rows 5 --limit-field-value 10 --desc
+ EOL;
+
+ /**
+ * The Command's arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'table_name' => 'The table name to show info',
+ ];
+
+ /**
+ * The Command's options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--show' => 'Lists the names of all database tables.',
+ '--metadata' => 'Retrieves list containing field information.',
+ '--desc' => 'Sorts the table rows in DESC order.',
+ '--limit-rows' => 'Limits the number of rows. Default: 10.',
+ '--limit-field-value' => 'Limits the length of field values. Default: 15.',
+ '--dbgroup' => 'Database group to show.',
+ ];
+
+ /**
+ * @var list> Table Data.
+ */
+ private array $tbody;
+
+ private ?BaseConnection $db = null;
+
+ /**
+ * @var bool Sort the table rows in DESC order or not.
+ */
+ private bool $sortDesc = false;
+
+ private string $DBPrefix;
+
+ public function run(array $params)
+ {
+ $dbGroup = $params['dbgroup'] ?? CLI::getOption('dbgroup');
+
+ try {
+ $this->db = Database::connect($dbGroup);
+ } catch (InvalidArgumentException $e) {
+ CLI::error($e->getMessage());
+
+ return EXIT_ERROR;
+ }
+
+ $this->DBPrefix = $this->db->getPrefix();
+
+ $this->showDBConfig();
+
+ $tables = $this->db->listTables();
+
+ if (array_key_exists('desc', $params)) {
+ $this->sortDesc = true;
+ }
+
+ if ($tables === []) {
+ CLI::error('Database has no tables!', 'light_gray', 'red');
+ CLI::newLine();
+
+ return EXIT_ERROR;
+ }
+
+ if (array_key_exists('show', $params)) {
+ $this->showAllTables($tables);
+
+ return EXIT_ERROR;
+ }
+
+ $tableName = $params[0] ?? null;
+ $limitRows = (int) ($params['limit-rows'] ?? 10);
+ $limitFieldValue = (int) ($params['limit-field-value'] ?? 15);
+
+ while (! in_array($tableName, $tables, true)) {
+ $tableNameNo = CLI::promptByKey(
+ ['Here is the list of your database tables:', 'Which table do you want to see?'],
+ $tables,
+ 'required'
+ );
+ CLI::newLine();
+
+ $tableName = $tables[$tableNameNo] ?? null;
+ }
+
+ if (array_key_exists('metadata', $params)) {
+ $this->showFieldMetaData($tableName);
+
+ return EXIT_SUCCESS;
+ }
+
+ $this->showDataOfTable($tableName, $limitRows, $limitFieldValue);
+
+ return EXIT_SUCCESS;
+ }
+
+ private function showDBConfig(): void
+ {
+ $data = [[
+ 'hostname' => $this->db->hostname,
+ 'database' => $this->db->getDatabase(),
+ 'username' => $this->db->username,
+ 'DBDriver' => $this->db->getPlatform(),
+ 'DBPrefix' => $this->DBPrefix,
+ 'port' => $this->db->port,
+ ]];
+ CLI::table(
+ $data,
+ ['hostname', 'database', 'username', 'DBDriver', 'DBPrefix', 'port']
+ );
+ }
+
+ private function removeDBPrefix(): void
+ {
+ $this->db->setPrefix('');
+ }
+
+ private function restoreDBPrefix(): void
+ {
+ $this->db->setPrefix($this->DBPrefix);
+ }
+
+ /**
+ * Show Data of Table
+ *
+ * @return void
+ */
+ private function showDataOfTable(string $tableName, int $limitRows, int $limitFieldValue)
+ {
+ CLI::write("Data of Table \"{$tableName}\":", 'black', 'yellow');
+ CLI::newLine();
+
+ $this->removeDBPrefix();
+ $thead = $this->db->getFieldNames($tableName);
+ $this->restoreDBPrefix();
+
+ // If there is a field named `id`, sort by it.
+ $sortField = null;
+ if (in_array('id', $thead, true)) {
+ $sortField = 'id';
+ }
+
+ $this->tbody = $this->makeTableRows($tableName, $limitRows, $limitFieldValue, $sortField);
+ CLI::table($this->tbody, $thead);
+ }
+
+ /**
+ * Show All Tables
+ *
+ * @param list $tables
+ *
+ * @return void
+ */
+ private function showAllTables(array $tables)
+ {
+ CLI::write('The following is a list of the names of all database tables:', 'black', 'yellow');
+ CLI::newLine();
+
+ $thead = ['ID', 'Table Name', 'Num of Rows', 'Num of Fields'];
+ $this->tbody = $this->makeTbodyForShowAllTables($tables);
+
+ CLI::table($this->tbody, $thead);
+ CLI::newLine();
+ }
+
+ /**
+ * Make body for table
+ *
+ * @param list $tables
+ *
+ * @return list>
+ */
+ private function makeTbodyForShowAllTables(array $tables): array
+ {
+ $this->removeDBPrefix();
+
+ foreach ($tables as $id => $tableName) {
+ $table = $this->db->protectIdentifiers($tableName);
+ $db = $this->db->query("SELECT * FROM {$table}");
+
+ $this->tbody[] = [
+ $id + 1,
+ $tableName,
+ $db->getNumRows(),
+ $db->getFieldCount(),
+ ];
+ }
+
+ $this->restoreDBPrefix();
+
+ if ($this->sortDesc) {
+ krsort($this->tbody);
+ }
+
+ return $this->tbody;
+ }
+
+ /**
+ * Make table rows
+ *
+ * @return list>
+ */
+ private function makeTableRows(
+ string $tableName,
+ int $limitRows,
+ int $limitFieldValue,
+ ?string $sortField = null
+ ): array {
+ $this->tbody = [];
+
+ $this->removeDBPrefix();
+ $builder = $this->db->table($tableName);
+ $builder->limit($limitRows);
+ if ($sortField !== null) {
+ $builder->orderBy($sortField, $this->sortDesc ? 'DESC' : 'ASC');
+ }
+ $rows = $builder->get()->getResultArray();
+ $this->restoreDBPrefix();
+
+ foreach ($rows as $row) {
+ $row = array_map(
+ static fn ($item): string => mb_strlen((string) $item) > $limitFieldValue
+ ? mb_substr((string) $item, 0, $limitFieldValue) . '...'
+ : (string) $item,
+ $row
+ );
+ $this->tbody[] = $row;
+ }
+
+ if ($sortField === null && $this->sortDesc) {
+ krsort($this->tbody);
+ }
+
+ return $this->tbody;
+ }
+
+ private function showFieldMetaData(string $tableName): void
+ {
+ CLI::write("List of Metadata Information in Table \"{$tableName}\":", 'black', 'yellow');
+ CLI::newLine();
+
+ $thead = ['Field Name', 'Type', 'Max Length', 'Nullable', 'Default', 'Primary Key'];
+
+ $this->removeDBPrefix();
+ $fields = $this->db->getFieldData($tableName);
+ $this->restoreDBPrefix();
+
+ foreach ($fields as $row) {
+ $this->tbody[] = [
+ $row->name,
+ $row->type,
+ $row->max_length,
+ isset($row->nullable) ? $this->setYesOrNo($row->nullable) : 'n/a',
+ $row->default,
+ isset($row->primary_key) ? $this->setYesOrNo($row->primary_key) : 'n/a',
+ ];
+ }
+
+ if ($this->sortDesc) {
+ krsort($this->tbody);
+ }
+
+ CLI::table($this->tbody, $thead);
+ }
+
+ /**
+ * @param bool|int|string|null $fieldValue
+ */
+ private function setYesOrNo($fieldValue): string
+ {
+ if ((bool) $fieldValue) {
+ return CLI::color('Yes', 'green');
+ }
+
+ return CLI::color('No', 'red');
+ }
+}
diff --git a/system/Commands/Encryption/GenerateKey.php b/system/Commands/Encryption/GenerateKey.php
new file mode 100644
index 0000000..21a582a
--- /dev/null
+++ b/system/Commands/Encryption/GenerateKey.php
@@ -0,0 +1,205 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Encryption;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\Config\DotEnv;
+use CodeIgniter\Encryption\Encryption;
+
+/**
+ * Generates a new encryption key.
+ */
+class GenerateKey extends BaseCommand
+{
+ /**
+ * The Command's group.
+ *
+ * @var string
+ */
+ protected $group = 'Encryption';
+
+ /**
+ * The Command's name.
+ *
+ * @var string
+ */
+ protected $name = 'key:generate';
+
+ /**
+ * The Command's usage.
+ *
+ * @var string
+ */
+ protected $usage = 'key:generate [options]';
+
+ /**
+ * The Command's short description.
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new encryption key and writes it in an `.env` file.';
+
+ /**
+ * The command's options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--force' => 'Force overwrite existing key in `.env` file.',
+ '--length' => 'The length of the random string that should be returned in bytes. Defaults to 32.',
+ '--prefix' => 'Prefix to prepend to encoded key (either hex2bin or base64). Defaults to hex2bin.',
+ '--show' => 'Shows the generated key in the terminal instead of storing in the `.env` file.',
+ ];
+
+ /**
+ * Actually execute the command.
+ */
+ public function run(array $params)
+ {
+ $prefix = $params['prefix'] ?? CLI::getOption('prefix');
+
+ if (in_array($prefix, [null, true], true)) {
+ $prefix = 'hex2bin';
+ } elseif (! in_array($prefix, ['hex2bin', 'base64'], true)) {
+ $prefix = CLI::prompt('Please provide a valid prefix to use.', ['hex2bin', 'base64'], 'required'); // @codeCoverageIgnore
+ }
+
+ $length = $params['length'] ?? CLI::getOption('length');
+
+ if (in_array($length, [null, true], true)) {
+ $length = 32;
+ }
+
+ $encodedKey = $this->generateRandomKey($prefix, $length);
+
+ if (array_key_exists('show', $params) || (bool) CLI::getOption('show')) {
+ CLI::write($encodedKey, 'yellow');
+ CLI::newLine();
+
+ return;
+ }
+
+ if (! $this->setNewEncryptionKey($encodedKey, $params)) {
+ CLI::write('Error in setting new encryption key to .env file.', 'light_gray', 'red');
+ CLI::newLine();
+
+ return;
+ }
+
+ // force DotEnv to reload the new env vars
+ putenv('encryption.key');
+ unset($_ENV['encryption.key'], $_SERVER['encryption.key']);
+ $dotenv = new DotEnv(ROOTPATH);
+ $dotenv->load();
+
+ CLI::write('Application\'s new encryption key was successfully set.', 'green');
+ CLI::newLine();
+ }
+
+ /**
+ * Generates a key and encodes it.
+ */
+ protected function generateRandomKey(string $prefix, int $length): string
+ {
+ $key = Encryption::createKey($length);
+
+ if ($prefix === 'hex2bin') {
+ return 'hex2bin:' . bin2hex($key);
+ }
+
+ return 'base64:' . base64_encode($key);
+ }
+
+ /**
+ * Sets the new encryption key in your .env file.
+ *
+ * @param array $params
+ */
+ protected function setNewEncryptionKey(string $key, array $params): bool
+ {
+ $currentKey = env('encryption.key', '');
+
+ if ($currentKey !== '' && ! $this->confirmOverwrite($params)) {
+ // Not yet testable since it requires keyboard input
+ return false; // @codeCoverageIgnore
+ }
+
+ return $this->writeNewEncryptionKeyToFile($currentKey, $key);
+ }
+
+ /**
+ * Checks whether to overwrite existing encryption key.
+ *
+ * @param array $params
+ */
+ protected function confirmOverwrite(array $params): bool
+ {
+ return (array_key_exists('force', $params) || CLI::getOption('force')) || CLI::prompt('Overwrite existing key?', ['n', 'y']) === 'y';
+ }
+
+ /**
+ * Writes the new encryption key to .env file.
+ */
+ protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool
+ {
+ $baseEnv = ROOTPATH . 'env';
+ $envFile = ROOTPATH . '.env';
+
+ if (! is_file($envFile)) {
+ if (! is_file($baseEnv)) {
+ CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow');
+ CLI::write('Here\'s your new key instead: ' . CLI::color($newKey, 'yellow'));
+ CLI::newLine();
+
+ return false;
+ }
+
+ copy($baseEnv, $envFile);
+ }
+
+ $oldFileContents = (string) file_get_contents($envFile);
+ $replacementKey = "\nencryption.key = {$newKey}";
+
+ if (! str_contains($oldFileContents, 'encryption.key')) {
+ return file_put_contents($envFile, $replacementKey, FILE_APPEND) !== false;
+ }
+
+ $newFileContents = preg_replace($this->keyPattern($oldKey), $replacementKey, $oldFileContents);
+
+ if ($newFileContents === $oldFileContents) {
+ $newFileContents = preg_replace(
+ '/^[#\s]*encryption.key[=\s]*(?:hex2bin\:[a-f0-9]{64}|base64\:(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?)$/m',
+ $replacementKey,
+ $oldFileContents
+ );
+ }
+
+ return file_put_contents($envFile, $newFileContents) !== false;
+ }
+
+ /**
+ * Get the regex of the current encryption key.
+ */
+ protected function keyPattern(string $oldKey): string
+ {
+ $escaped = preg_quote($oldKey, '/');
+
+ if ($escaped !== '') {
+ $escaped = "[{$escaped}]*";
+ }
+
+ return "/^[#\\s]*encryption.key[=\\s]*{$escaped}$/m";
+ }
+}
diff --git a/system/Commands/Generators/CellGenerator.php b/system/Commands/Generators/CellGenerator.php
new file mode 100644
index 0000000..57d79ac
--- /dev/null
+++ b/system/Commands/Generators/CellGenerator.php
@@ -0,0 +1,107 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\GeneratorTrait;
+use Config\Generators;
+
+/**
+ * Generates a skeleton Cell and its view.
+ */
+class CellGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:cell';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new Controlled Cell file and its view.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:cell [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The Controlled Cell class name.',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->component = 'Cell';
+ $this->directory = 'Cells';
+
+ $params = array_merge($params, ['suffix' => null]);
+
+ $this->templatePath = config(Generators::class)->views[$this->name]['class'];
+ $this->template = 'cell.tpl.php';
+ $this->classNameLang = 'CLI.generator.className.cell';
+
+ $this->generateClass($params);
+
+ $this->templatePath = config(Generators::class)->views[$this->name]['view'];
+ $this->template = 'cell_view.tpl.php';
+ $this->classNameLang = 'CLI.generator.viewName.cell';
+
+ $className = $this->qualifyClassName();
+ $viewName = decamelize(class_basename($className));
+ $viewName = preg_replace(
+ '/([a-z][a-z0-9_\/\\\\]+)(_cell)$/i',
+ '$1',
+ $viewName
+ ) ?? $viewName;
+ $namespace = substr($className, 0, strrpos($className, '\\') + 1);
+
+ $this->generateView($namespace . $viewName, $params);
+
+ return 0;
+ }
+}
diff --git a/system/Commands/Generators/CommandGenerator.php b/system/Commands/Generators/CommandGenerator.php
new file mode 100644
index 0000000..8c2ebcf
--- /dev/null
+++ b/system/Commands/Generators/CommandGenerator.php
@@ -0,0 +1,121 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\CLI\GeneratorTrait;
+
+/**
+ * Generates a skeleton command file.
+ */
+class CommandGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:command';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new spark command.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:command [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The command class name.',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--command' => 'The command name. Default: "command:name"',
+ '--type' => 'The command type. Options [basic, generator]. Default: "basic".',
+ '--group' => 'The command group. Default: [basic -> "App", generator -> "Generators"].',
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--suffix' => 'Append the component title to the class name (e.g. User => UserCommand).',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->component = 'Command';
+ $this->directory = 'Commands';
+ $this->template = 'command.tpl.php';
+
+ $this->classNameLang = 'CLI.generator.className.command';
+ $this->generateClass($params);
+ }
+
+ /**
+ * Prepare options and do the necessary replacements.
+ */
+ protected function prepare(string $class): string
+ {
+ $command = $this->getOption('command');
+ $group = $this->getOption('group');
+ $type = $this->getOption('type');
+
+ $command = is_string($command) ? $command : 'command:name';
+ $type = is_string($type) ? $type : 'basic';
+
+ if (! in_array($type, ['basic', 'generator'], true)) {
+ // @codeCoverageIgnoreStart
+ $type = CLI::prompt(lang('CLI.generator.commandType'), ['basic', 'generator'], 'required');
+ CLI::newLine();
+ // @codeCoverageIgnoreEnd
+ }
+
+ if (! is_string($group)) {
+ $group = $type === 'generator' ? 'Generators' : 'App';
+ }
+
+ return $this->parseTemplate(
+ $class,
+ ['{group}', '{command}'],
+ [$group, $command],
+ ['type' => $type]
+ );
+ }
+}
diff --git a/system/Commands/Generators/ConfigGenerator.php b/system/Commands/Generators/ConfigGenerator.php
new file mode 100644
index 0000000..7b1d5f2
--- /dev/null
+++ b/system/Commands/Generators/ConfigGenerator.php
@@ -0,0 +1,100 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\GeneratorTrait;
+
+/**
+ * Generates a skeleton config file.
+ */
+class ConfigGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:config';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new config file.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:config [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The config class name.',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--suffix' => 'Append the component title to the class name (e.g. User => UserConfig).',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->component = 'Config';
+ $this->directory = 'Config';
+ $this->template = 'config.tpl.php';
+
+ $this->classNameLang = 'CLI.generator.className.config';
+ $this->generateClass($params);
+ }
+
+ /**
+ * Prepare options and do the necessary replacements.
+ */
+ protected function prepare(string $class): string
+ {
+ $namespace = $this->getOption('namespace') ?? APP_NAMESPACE;
+
+ if ($namespace === APP_NAMESPACE) {
+ $class = substr($class, strlen($namespace . '\\'));
+ }
+
+ return $this->parseTemplate($class);
+ }
+}
diff --git a/system/Commands/Generators/ControllerGenerator.php b/system/Commands/Generators/ControllerGenerator.php
new file mode 100644
index 0000000..c6f54ca
--- /dev/null
+++ b/system/Commands/Generators/ControllerGenerator.php
@@ -0,0 +1,136 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\CLI\GeneratorTrait;
+use CodeIgniter\Controller;
+use CodeIgniter\RESTful\ResourceController;
+use CodeIgniter\RESTful\ResourcePresenter;
+
+/**
+ * Generates a skeleton controller file.
+ */
+class ControllerGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:controller';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new controller file.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:controller [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The controller class name.',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--bare' => 'Extends from CodeIgniter\Controller instead of BaseController.',
+ '--restful' => 'Extends from a RESTful resource, Options: [controller, presenter]. Default: "controller".',
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--suffix' => 'Append the component title to the class name (e.g. User => UserController).',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->component = 'Controller';
+ $this->directory = 'Controllers';
+ $this->template = 'controller.tpl.php';
+
+ $this->classNameLang = 'CLI.generator.className.controller';
+ $this->generateClass($params);
+ }
+
+ /**
+ * Prepare options and do the necessary replacements.
+ */
+ protected function prepare(string $class): string
+ {
+ $bare = $this->getOption('bare');
+ $rest = $this->getOption('restful');
+
+ $useStatement = trim(APP_NAMESPACE, '\\') . '\Controllers\BaseController';
+ $extends = 'BaseController';
+
+ // Gets the appropriate parent class to extend.
+ if ($bare || $rest) {
+ if ($bare) {
+ $useStatement = Controller::class;
+ $extends = 'Controller';
+ } elseif ($rest) {
+ $rest = is_string($rest) ? $rest : 'controller';
+
+ if (! in_array($rest, ['controller', 'presenter'], true)) {
+ // @codeCoverageIgnoreStart
+ $rest = CLI::prompt(lang('CLI.generator.parentClass'), ['controller', 'presenter'], 'required');
+ CLI::newLine();
+ // @codeCoverageIgnoreEnd
+ }
+
+ if ($rest === 'controller') {
+ $useStatement = ResourceController::class;
+ $extends = 'ResourceController';
+ } elseif ($rest === 'presenter') {
+ $useStatement = ResourcePresenter::class;
+ $extends = 'ResourcePresenter';
+ }
+ }
+ }
+
+ return $this->parseTemplate(
+ $class,
+ ['{useStatement}', '{extends}'],
+ [$useStatement, $extends],
+ ['type' => $rest]
+ );
+ }
+}
diff --git a/system/Commands/Generators/EntityGenerator.php b/system/Commands/Generators/EntityGenerator.php
new file mode 100644
index 0000000..09c3505
--- /dev/null
+++ b/system/Commands/Generators/EntityGenerator.php
@@ -0,0 +1,86 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\GeneratorTrait;
+
+/**
+ * Generates a skeleton Entity file.
+ */
+class EntityGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:entity';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new entity file.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:entity [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The entity class name.',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--suffix' => 'Append the component title to the class name (e.g. User => UserEntity).',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->component = 'Entity';
+ $this->directory = 'Entities';
+ $this->template = 'entity.tpl.php';
+
+ $this->classNameLang = 'CLI.generator.className.entity';
+ $this->generateClass($params);
+ }
+}
diff --git a/system/Commands/Generators/FilterGenerator.php b/system/Commands/Generators/FilterGenerator.php
new file mode 100644
index 0000000..c723da3
--- /dev/null
+++ b/system/Commands/Generators/FilterGenerator.php
@@ -0,0 +1,86 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\GeneratorTrait;
+
+/**
+ * Generates a skeleton Filter file.
+ */
+class FilterGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:filter';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new filter file.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:filter [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The filter class name.',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--suffix' => 'Append the component title to the class name (e.g. User => UserFilter).',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->component = 'Filter';
+ $this->directory = 'Filters';
+ $this->template = 'filter.tpl.php';
+
+ $this->classNameLang = 'CLI.generator.className.filter';
+ $this->generateClass($params);
+ }
+}
diff --git a/system/Commands/Generators/MigrationGenerator.php b/system/Commands/Generators/MigrationGenerator.php
new file mode 100644
index 0000000..b7d7d58
--- /dev/null
+++ b/system/Commands/Generators/MigrationGenerator.php
@@ -0,0 +1,131 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\CLI\GeneratorTrait;
+use Config\Database;
+use Config\Migrations;
+use Config\Session as SessionConfig;
+
+/**
+ * Generates a skeleton migration file.
+ */
+class MigrationGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:migration';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new migration file.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:migration [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The migration class name.',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--session' => 'Generates the migration file for database sessions.',
+ '--table' => 'Table name to use for database sessions. Default: "ci_sessions".',
+ '--dbgroup' => 'Database group to use for database sessions. Default: "default".',
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--suffix' => 'Append the component title to the class name (e.g. User => UserMigration).',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->component = 'Migration';
+ $this->directory = 'Database\Migrations';
+ $this->template = 'migration.tpl.php';
+
+ if (array_key_exists('session', $params) || CLI::getOption('session')) {
+ $table = $params['table'] ?? CLI::getOption('table') ?? 'ci_sessions';
+ $params[0] = "_create_{$table}_table";
+ }
+
+ $this->classNameLang = 'CLI.generator.className.migration';
+ $this->generateClass($params);
+ }
+
+ /**
+ * Prepare options and do the necessary replacements.
+ */
+ protected function prepare(string $class): string
+ {
+ $data = [];
+ $data['session'] = false;
+
+ if ($this->getOption('session')) {
+ $table = $this->getOption('table');
+ $DBGroup = $this->getOption('dbgroup');
+
+ $data['session'] = true;
+ $data['table'] = is_string($table) ? $table : 'ci_sessions';
+ $data['DBGroup'] = is_string($DBGroup) ? $DBGroup : 'default';
+ $data['DBDriver'] = config(Database::class)->{$data['DBGroup']}['DBDriver'];
+
+ /** @var SessionConfig|null $session */
+ $session = config(SessionConfig::class);
+
+ $data['matchIP'] = $session->matchIP;
+ }
+
+ return $this->parseTemplate($class, [], [], $data);
+ }
+
+ /**
+ * Change file basename before saving.
+ */
+ protected function basename(string $filename): string
+ {
+ return gmdate(config(Migrations::class)->timestampFormat) . basename($filename);
+ }
+}
diff --git a/system/Commands/Generators/ModelGenerator.php b/system/Commands/Generators/ModelGenerator.php
new file mode 100644
index 0000000..5450bda
--- /dev/null
+++ b/system/Commands/Generators/ModelGenerator.php
@@ -0,0 +1,135 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\CLI\GeneratorTrait;
+
+/**
+ * Generates a skeleton Model file.
+ */
+class ModelGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:model';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new model file.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:model [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The model class name.',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--table' => 'Supply a table name. Default: "the lowercased plural of the class name".',
+ '--dbgroup' => 'Database group to use. Default: "default".',
+ '--return' => 'Return type, Options: [array, object, entity]. Default: "array".',
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--suffix' => 'Append the component title to the class name (e.g. User => UserModel).',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->component = 'Model';
+ $this->directory = 'Models';
+ $this->template = 'model.tpl.php';
+
+ $this->classNameLang = 'CLI.generator.className.model';
+ $this->generateClass($params);
+ }
+
+ /**
+ * Prepare options and do the necessary replacements.
+ */
+ protected function prepare(string $class): string
+ {
+ $table = $this->getOption('table');
+ $dbGroup = $this->getOption('dbgroup');
+ $return = $this->getOption('return');
+
+ $baseClass = class_basename($class);
+
+ if (preg_match('/^(\S+)Model$/i', $baseClass, $match) === 1) {
+ $baseClass = $match[1];
+ }
+
+ $table = is_string($table) ? $table : plural(strtolower($baseClass));
+ $return = is_string($return) ? $return : 'array';
+
+ if (! in_array($return, ['array', 'object', 'entity'], true)) {
+ // @codeCoverageIgnoreStart
+ $return = CLI::prompt(lang('CLI.generator.returnType'), ['array', 'object', 'entity'], 'required');
+ CLI::newLine();
+ // @codeCoverageIgnoreEnd
+ }
+
+ if ($return === 'entity') {
+ $return = str_replace('Models', 'Entities', $class);
+
+ if (preg_match('/^(\S+)Model$/i', $return, $match) === 1) {
+ $return = $match[1];
+
+ if ($this->getOption('suffix')) {
+ $return .= 'Entity';
+ }
+ }
+
+ $return = '\\' . trim($return, '\\') . '::class';
+ $this->call('make:entity', array_merge([$baseClass], $this->params));
+ } else {
+ $return = "'{$return}'";
+ }
+
+ return $this->parseTemplate($class, ['{dbGroup}', '{table}', '{return}'], [$dbGroup, $table, $return], compact('dbGroup'));
+ }
+}
diff --git a/system/Commands/Generators/ScaffoldGenerator.php b/system/Commands/Generators/ScaffoldGenerator.php
new file mode 100644
index 0000000..3b1ef79
--- /dev/null
+++ b/system/Commands/Generators/ScaffoldGenerator.php
@@ -0,0 +1,123 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\CLI\GeneratorTrait;
+
+/**
+ * Generates a complete set of scaffold files.
+ */
+class ScaffoldGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:scaffold';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a complete set of scaffold files.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:scaffold [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The class name',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--bare' => 'Add the "--bare" option to controller component.',
+ '--restful' => 'Add the "--restful" option to controller component.',
+ '--table' => 'Add the "--table" option to the model component.',
+ '--dbgroup' => 'Add the "--dbgroup" option to model component.',
+ '--return' => 'Add the "--return" option to the model component.',
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--suffix' => 'Append the component title to the class name.',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->params = $params;
+
+ $options = [];
+
+ if ($this->getOption('namespace')) {
+ $options['namespace'] = $this->getOption('namespace');
+ }
+
+ if ($this->getOption('suffix')) {
+ $options['suffix'] = null;
+ }
+
+ if ($this->getOption('force')) {
+ $options['force'] = null;
+ }
+
+ $controllerOpts = [];
+
+ if ($this->getOption('bare')) {
+ $controllerOpts['bare'] = null;
+ } elseif ($this->getOption('restful')) {
+ $controllerOpts['restful'] = $this->getOption('restful');
+ }
+
+ $modelOpts = [
+ 'table' => $this->getOption('table'),
+ 'dbgroup' => $this->getOption('dbgroup'),
+ 'return' => $this->getOption('return'),
+ ];
+
+ $class = $params[0] ?? CLI::getSegment(2);
+
+ // Call those commands!
+ $this->call('make:controller', array_merge([$class], $controllerOpts, $options));
+ $this->call('make:model', array_merge([$class], $modelOpts, $options));
+ $this->call('make:migration', array_merge([$class], $options));
+ $this->call('make:seeder', array_merge([$class], $options));
+ }
+}
diff --git a/system/Commands/Generators/SeederGenerator.php b/system/Commands/Generators/SeederGenerator.php
new file mode 100644
index 0000000..0549ad8
--- /dev/null
+++ b/system/Commands/Generators/SeederGenerator.php
@@ -0,0 +1,86 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\GeneratorTrait;
+
+/**
+ * Generates a skeleton seeder file.
+ */
+class SeederGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:seeder';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new seeder file.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:seeder [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The seeder class name.',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--suffix' => 'Append the component title to the class name (e.g. User => UserSeeder).',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->component = 'Seeder';
+ $this->directory = 'Database\Seeds';
+ $this->template = 'seeder.tpl.php';
+
+ $this->classNameLang = 'CLI.generator.className.seeder';
+ $this->generateClass($params);
+ }
+}
diff --git a/system/Commands/Generators/TestGenerator.php b/system/Commands/Generators/TestGenerator.php
new file mode 100644
index 0000000..35019c7
--- /dev/null
+++ b/system/Commands/Generators/TestGenerator.php
@@ -0,0 +1,192 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\CLI\GeneratorTrait;
+
+/**
+ * Generates a skeleton command file.
+ */
+class TestGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:test';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new test file.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:test [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The test class name.',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--namespace' => 'Set root namespace. Default: "Tests".',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->component = 'Test';
+ $this->template = 'test.tpl.php';
+
+ $this->classNameLang = 'CLI.generator.className.test';
+
+ $autoload = service('autoloader');
+ $autoload->addNamespace('CodeIgniter', TESTPATH . 'system');
+ $autoload->addNamespace('Tests', ROOTPATH . 'tests');
+
+ $this->generateClass($params);
+ }
+
+ /**
+ * Gets the namespace from input or the default namespace.
+ */
+ protected function getNamespace(): string
+ {
+ if ($this->namespace !== null) {
+ return $this->namespace;
+ }
+
+ if ($this->getOption('namespace') !== null) {
+ return trim(
+ str_replace(
+ '/',
+ '\\',
+ $this->getOption('namespace')
+ ),
+ '\\'
+ );
+ }
+
+ $class = $this->normalizeInputClassName();
+ $classPaths = explode('\\', $class);
+
+ $namespaces = service('autoloader')->getNamespace();
+
+ while ($classPaths !== []) {
+ array_pop($classPaths);
+ $namespace = implode('\\', $classPaths);
+
+ foreach (array_keys($namespaces) as $prefix) {
+ if ($prefix === $namespace) {
+ // The input classname is FQCN, and use the namespace.
+ return $namespace;
+ }
+ }
+ }
+
+ return 'Tests';
+ }
+
+ /**
+ * Builds the test file path from the class name.
+ *
+ * @param string $class namespaced classname.
+ */
+ protected function buildPath(string $class): string
+ {
+ $namespace = $this->getNamespace();
+
+ $base = $this->searchTestFilePath($namespace);
+
+ if ($base === null) {
+ CLI::error(
+ lang('CLI.namespaceNotDefined', [$namespace]),
+ 'light_gray',
+ 'red'
+ );
+ CLI::newLine();
+
+ return '';
+ }
+
+ $realpath = realpath($base);
+ $base = ($realpath !== false) ? $realpath : $base;
+
+ $file = $base . DIRECTORY_SEPARATOR
+ . str_replace(
+ '\\',
+ DIRECTORY_SEPARATOR,
+ trim(str_replace($namespace . '\\', '', $class), '\\')
+ ) . '.php';
+
+ return implode(
+ DIRECTORY_SEPARATOR,
+ array_slice(
+ explode(DIRECTORY_SEPARATOR, $file),
+ 0,
+ -1
+ )
+ ) . DIRECTORY_SEPARATOR . $this->basename($file);
+ }
+
+ /**
+ * Returns test file path for the namespace.
+ */
+ private function searchTestFilePath(string $namespace): ?string
+ {
+ $bases = service('autoloader')->getNamespace($namespace);
+
+ $base = null;
+
+ foreach ($bases as $candidate) {
+ if (str_contains($candidate, '/tests/')) {
+ $base = $candidate;
+
+ break;
+ }
+ }
+
+ return $base;
+ }
+}
diff --git a/system/Commands/Generators/ValidationGenerator.php b/system/Commands/Generators/ValidationGenerator.php
new file mode 100644
index 0000000..d9e3d49
--- /dev/null
+++ b/system/Commands/Generators/ValidationGenerator.php
@@ -0,0 +1,86 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Generators;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\GeneratorTrait;
+
+/**
+ * Generates a skeleton Validation file.
+ */
+class ValidationGenerator extends BaseCommand
+{
+ use GeneratorTrait;
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = 'Generators';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = 'make:validation';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = 'Generates a new validation file.';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = 'make:validation [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'name' => 'The validation class name.',
+ ];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--suffix' => 'Append the component title to the class name (e.g. User => UserValidation).',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $this->component = 'Validation';
+ $this->directory = 'Validation';
+ $this->template = 'validation.tpl.php';
+
+ $this->classNameLang = 'CLI.generator.className.validation';
+ $this->generateClass($params);
+ }
+}
diff --git a/system/Commands/Generators/Views/cell.tpl.php b/system/Commands/Generators/Views/cell.tpl.php
new file mode 100644
index 0000000..f20c078
--- /dev/null
+++ b/system/Commands/Generators/Views/cell.tpl.php
@@ -0,0 +1,10 @@
+<@php
+
+namespace {namespace};
+
+use CodeIgniter\View\Cells\Cell;
+
+class {class} extends Cell
+{
+ //
+}
diff --git a/system/Commands/Generators/Views/cell_view.tpl.php b/system/Commands/Generators/Views/cell_view.tpl.php
new file mode 100644
index 0000000..9866dc2
--- /dev/null
+++ b/system/Commands/Generators/Views/cell_view.tpl.php
@@ -0,0 +1,3 @@
+
+
+
diff --git a/system/Commands/Generators/Views/command.tpl.php b/system/Commands/Generators/Views/command.tpl.php
new file mode 100644
index 0000000..bfbdcc4
--- /dev/null
+++ b/system/Commands/Generators/Views/command.tpl.php
@@ -0,0 +1,76 @@
+<@php
+
+namespace {namespace};
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+
+use CodeIgniter\CLI\GeneratorTrait;
+
+
+class {class} extends BaseCommand
+{
+
+ use GeneratorTrait;
+
+
+ /**
+ * The Command's Group
+ *
+ * @var string
+ */
+ protected $group = '{group}';
+
+ /**
+ * The Command's Name
+ *
+ * @var string
+ */
+ protected $name = '{command}';
+
+ /**
+ * The Command's Description
+ *
+ * @var string
+ */
+ protected $description = '';
+
+ /**
+ * The Command's Usage
+ *
+ * @var string
+ */
+ protected $usage = '{command} [arguments] [options]';
+
+ /**
+ * The Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [];
+
+ /**
+ * The Command's Options
+ *
+ * @var array
+ */
+ protected $options = [];
+
+ /**
+ * Actually execute a command.
+ *
+ * @param array $params
+ */
+ public function run(array $params)
+ {
+
+ $this->component = 'Command';
+ $this->directory = 'Commands';
+ $this->template = 'command.tpl.php';
+
+ $this->execute($params);
+
+ //
+
+ }
+}
diff --git a/system/Commands/Generators/Views/config.tpl.php b/system/Commands/Generators/Views/config.tpl.php
new file mode 100644
index 0000000..31c60cb
--- /dev/null
+++ b/system/Commands/Generators/Views/config.tpl.php
@@ -0,0 +1,10 @@
+<@php
+
+namespace {namespace};
+
+use CodeIgniter\Config\BaseConfig;
+
+class {class} extends BaseConfig
+{
+ //
+}
diff --git a/system/Commands/Generators/Views/controller.tpl.php b/system/Commands/Generators/Views/controller.tpl.php
new file mode 100644
index 0000000..514de05
--- /dev/null
+++ b/system/Commands/Generators/Views/controller.tpl.php
@@ -0,0 +1,186 @@
+<@php
+
+namespace {namespace};
+
+use {useStatement};
+use CodeIgniter\HTTP\ResponseInterface;
+
+class {class} extends {extends}
+{
+
+ /**
+ * Return an array of resource objects, themselves in array format.
+ *
+ * @return ResponseInterface
+ */
+ public function index()
+ {
+ //
+ }
+
+ /**
+ * Return the properties of a resource object.
+ *
+ * @param int|string|null $id
+ *
+ * @return ResponseInterface
+ */
+ public function show($id = null)
+ {
+ //
+ }
+
+ /**
+ * Return a new resource object, with default properties.
+ *
+ * @return ResponseInterface
+ */
+ public function new()
+ {
+ //
+ }
+
+ /**
+ * Create a new resource object, from "posted" parameters.
+ *
+ * @return ResponseInterface
+ */
+ public function create()
+ {
+ //
+ }
+
+ /**
+ * Return the editable properties of a resource object.
+ *
+ * @param int|string|null $id
+ *
+ * @return ResponseInterface
+ */
+ public function edit($id = null)
+ {
+ //
+ }
+
+ /**
+ * Add or update a model resource, from "posted" properties.
+ *
+ * @param int|string|null $id
+ *
+ * @return ResponseInterface
+ */
+ public function update($id = null)
+ {
+ //
+ }
+
+ /**
+ * Delete the designated resource object from the model.
+ *
+ * @param int|string|null $id
+ *
+ * @return ResponseInterface
+ */
+ public function delete($id = null)
+ {
+ //
+ }
+
+ /**
+ * Present a view of resource objects.
+ *
+ * @return ResponseInterface
+ */
+ public function index()
+ {
+ //
+ }
+
+ /**
+ * Present a view to present a specific resource object.
+ *
+ * @param int|string|null $id
+ *
+ * @return ResponseInterface
+ */
+ public function show($id = null)
+ {
+ //
+ }
+
+ /**
+ * Present a view to present a new single resource object.
+ *
+ * @return ResponseInterface
+ */
+ public function new()
+ {
+ //
+ }
+
+ /**
+ * Process the creation/insertion of a new resource object.
+ * This should be a POST.
+ *
+ * @return ResponseInterface
+ */
+ public function create()
+ {
+ //
+ }
+
+ /**
+ * Present a view to edit the properties of a specific resource object.
+ *
+ * @param int|string|null $id
+ *
+ * @return ResponseInterface
+ */
+ public function edit($id = null)
+ {
+ //
+ }
+
+ /**
+ * Process the updating, full or partial, of a specific resource object.
+ * This should be a POST.
+ *
+ * @param int|string|null $id
+ *
+ * @return ResponseInterface
+ */
+ public function update($id = null)
+ {
+ //
+ }
+
+ /**
+ * Present a view to confirm the deletion of a specific resource object.
+ *
+ * @param int|string|null $id
+ *
+ * @return ResponseInterface
+ */
+ public function remove($id = null)
+ {
+ //
+ }
+
+ /**
+ * Process the deletion of a specific resource object.
+ *
+ * @param int|string|null $id
+ *
+ * @return ResponseInterface
+ */
+ public function delete($id = null)
+ {
+ //
+ }
+
+ public function index()
+ {
+ //
+ }
+
+}
diff --git a/system/Commands/Generators/Views/entity.tpl.php b/system/Commands/Generators/Views/entity.tpl.php
new file mode 100644
index 0000000..c74c776
--- /dev/null
+++ b/system/Commands/Generators/Views/entity.tpl.php
@@ -0,0 +1,12 @@
+<@php
+
+namespace {namespace};
+
+use CodeIgniter\Entity\Entity;
+
+class {class} extends Entity
+{
+ protected $datamap = [];
+ protected $dates = ['created_at', 'updated_at', 'deleted_at'];
+ protected $casts = [];
+}
diff --git a/system/Commands/Generators/Views/filter.tpl.php b/system/Commands/Generators/Views/filter.tpl.php
new file mode 100644
index 0000000..767ac0b
--- /dev/null
+++ b/system/Commands/Generators/Views/filter.tpl.php
@@ -0,0 +1,47 @@
+<@php
+
+namespace {namespace};
+
+use CodeIgniter\Filters\FilterInterface;
+use CodeIgniter\HTTP\RequestInterface;
+use CodeIgniter\HTTP\ResponseInterface;
+
+class {class} implements FilterInterface
+{
+ /**
+ * Do whatever processing this filter needs to do.
+ * By default it should not return anything during
+ * normal execution. However, when an abnormal state
+ * is found, it should return an instance of
+ * CodeIgniter\HTTP\Response. If it does, script
+ * execution will end and that Response will be
+ * sent back to the client, allowing for error pages,
+ * redirects, etc.
+ *
+ * @param RequestInterface $request
+ * @param array|null $arguments
+ *
+ * @return RequestInterface|ResponseInterface|string|void
+ */
+ public function before(RequestInterface $request, $arguments = null)
+ {
+ //
+ }
+
+ /**
+ * Allows After filters to inspect and modify the response
+ * object as needed. This method does not allow any way
+ * to stop execution of other after filters, short of
+ * throwing an Exception or Error.
+ *
+ * @param RequestInterface $request
+ * @param ResponseInterface $response
+ * @param array|null $arguments
+ *
+ * @return ResponseInterface|void
+ */
+ public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
+ {
+ //
+ }
+}
diff --git a/system/Commands/Generators/Views/migration.tpl.php b/system/Commands/Generators/Views/migration.tpl.php
new file mode 100644
index 0000000..321895e
--- /dev/null
+++ b/system/Commands/Generators/Views/migration.tpl.php
@@ -0,0 +1,50 @@
+<@php
+
+namespace {namespace};
+
+use CodeIgniter\Database\Migration;
+
+class {class} extends Migration
+{
+
+ protected $DBGroup = '= $DBGroup ?>';
+
+ public function up()
+ {
+ $this->forge->addField([
+ 'id' => ['type' => 'VARCHAR', 'constraint' => 128, 'null' => false],
+
+ 'ip_address' => ['type' => 'VARCHAR', 'constraint' => 45, 'null' => false],
+ 'timestamp timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL',
+ 'data' => ['type' => 'BLOB', 'null' => false],
+
+ 'ip_address inet NOT NULL',
+ 'timestamp timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL',
+ "data bytea DEFAULT '' NOT NULL",
+
+ ]);
+
+ $this->forge->addKey(['id', 'ip_address'], true);
+
+ $this->forge->addKey('id', true);
+
+ $this->forge->addKey('timestamp');
+ $this->forge->createTable('= $table ?>', true);
+ }
+
+ public function down()
+ {
+ $this->forge->dropTable('= $table ?>', true);
+ }
+
+ public function up()
+ {
+ //
+ }
+
+ public function down()
+ {
+ //
+ }
+
+}
diff --git a/system/Commands/Generators/Views/model.tpl.php b/system/Commands/Generators/Views/model.tpl.php
new file mode 100644
index 0000000..954404f
--- /dev/null
+++ b/system/Commands/Generators/Views/model.tpl.php
@@ -0,0 +1,49 @@
+<@php
+
+namespace {namespace};
+
+use CodeIgniter\Model;
+
+class {class} extends Model
+{
+
+ protected $DBGroup = '{dbGroup}';
+
+ protected $table = '{table}';
+ protected $primaryKey = 'id';
+ protected $useAutoIncrement = true;
+ protected $returnType = {return};
+ protected $useSoftDeletes = false;
+ protected $protectFields = true;
+ protected $allowedFields = [];
+
+ protected bool $allowEmptyInserts = false;
+ protected bool $updateOnlyChanged = true;
+
+ protected array $casts = [];
+ protected array $castHandlers = [];
+
+ // Dates
+ protected $useTimestamps = false;
+ protected $dateFormat = 'datetime';
+ protected $createdField = 'created_at';
+ protected $updatedField = 'updated_at';
+ protected $deletedField = 'deleted_at';
+
+ // Validation
+ protected $validationRules = [];
+ protected $validationMessages = [];
+ protected $skipValidation = false;
+ protected $cleanValidationRules = true;
+
+ // Callbacks
+ protected $allowCallbacks = true;
+ protected $beforeInsert = [];
+ protected $afterInsert = [];
+ protected $beforeUpdate = [];
+ protected $afterUpdate = [];
+ protected $beforeFind = [];
+ protected $afterFind = [];
+ protected $beforeDelete = [];
+ protected $afterDelete = [];
+}
diff --git a/system/Commands/Generators/Views/seeder.tpl.php b/system/Commands/Generators/Views/seeder.tpl.php
new file mode 100644
index 0000000..6f21628
--- /dev/null
+++ b/system/Commands/Generators/Views/seeder.tpl.php
@@ -0,0 +1,13 @@
+<@php
+
+namespace {namespace};
+
+use CodeIgniter\Database\Seeder;
+
+class {class} extends Seeder
+{
+ public function run()
+ {
+ //
+ }
+}
diff --git a/system/Commands/Generators/Views/test.tpl.php b/system/Commands/Generators/Views/test.tpl.php
new file mode 100644
index 0000000..f67348d
--- /dev/null
+++ b/system/Commands/Generators/Views/test.tpl.php
@@ -0,0 +1,18 @@
+<@php
+
+namespace {namespace};
+
+use CodeIgniter\Test\CIUnitTestCase;
+
+class {class} extends CIUnitTestCase
+{
+ protected function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ public function testExample(): void
+ {
+ //
+ }
+}
diff --git a/system/Commands/Generators/Views/validation.tpl.php b/system/Commands/Generators/Views/validation.tpl.php
new file mode 100644
index 0000000..e040a74
--- /dev/null
+++ b/system/Commands/Generators/Views/validation.tpl.php
@@ -0,0 +1,11 @@
+<@php
+
+namespace {namespace};
+
+class {class}
+{
+ // public function custom_rule(): bool
+ // {
+ // return true;
+ // }
+}
diff --git a/system/Commands/Help.php b/system/Commands/Help.php
new file mode 100644
index 0000000..76913e8
--- /dev/null
+++ b/system/Commands/Help.php
@@ -0,0 +1,87 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands;
+
+use CodeIgniter\CLI\BaseCommand;
+
+/**
+ * CI Help command for the spark script.
+ *
+ * Lists the basic usage information for the spark script,
+ * and provides a way to list help for other commands.
+ */
+class Help extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'CodeIgniter';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'help';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Displays basic usage information.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'help []';
+
+ /**
+ * the Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'command_name' => 'The command name [default: "help"]',
+ ];
+
+ /**
+ * the Command's Options
+ *
+ * @var array
+ */
+ protected $options = [];
+
+ /**
+ * Displays the help for spark commands.
+ */
+ public function run(array $params)
+ {
+ $command = array_shift($params);
+ $command ??= 'help';
+ $commands = $this->commands->getCommands();
+
+ if (! $this->commands->verifyCommand($command, $commands)) {
+ return;
+ }
+
+ $class = new $commands[$command]['class']($this->logger, $this->commands);
+ $class->showHelp();
+ }
+}
diff --git a/system/Commands/Housekeeping/ClearDebugbar.php b/system/Commands/Housekeeping/ClearDebugbar.php
new file mode 100644
index 0000000..dd49b24
--- /dev/null
+++ b/system/Commands/Housekeeping/ClearDebugbar.php
@@ -0,0 +1,72 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Housekeeping;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+
+/**
+ * ClearDebugbar Command
+ */
+class ClearDebugbar extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'Housekeeping';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'debugbar:clear';
+
+ /**
+ * The Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'debugbar:clear';
+
+ /**
+ * The Command's short description.
+ *
+ * @var string
+ */
+ protected $description = 'Clears all debugbar JSON files.';
+
+ /**
+ * Actually runs the command.
+ */
+ public function run(array $params)
+ {
+ helper('filesystem');
+
+ if (! delete_files(WRITEPATH . 'debugbar', false, true)) {
+ // @codeCoverageIgnoreStart
+ CLI::error('Error deleting the debugbar JSON files.');
+ CLI::newLine();
+
+ return;
+ // @codeCoverageIgnoreEnd
+ }
+
+ CLI::write('Debugbar cleared.', 'green');
+ CLI::newLine();
+ }
+}
diff --git a/system/Commands/Housekeeping/ClearLogs.php b/system/Commands/Housekeeping/ClearLogs.php
new file mode 100644
index 0000000..ec4b700
--- /dev/null
+++ b/system/Commands/Housekeeping/ClearLogs.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\Commands\Housekeeping;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+
+/**
+ * ClearLogs command.
+ */
+class ClearLogs extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'Housekeeping';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'logs:clear';
+
+ /**
+ * The Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Clears all log files.';
+
+ /**
+ * The Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'logs:clear [option';
+
+ /**
+ * The Command's options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--force' => 'Force delete of all logs files without prompting.',
+ ];
+
+ /**
+ * Actually execute a command.
+ */
+ public function run(array $params)
+ {
+ $force = array_key_exists('force', $params) || CLI::getOption('force');
+
+ if (! $force && CLI::prompt('Are you sure you want to delete the logs?', ['n', 'y']) === 'n') {
+ // @codeCoverageIgnoreStart
+ CLI::error('Deleting logs aborted.', 'light_gray', 'red');
+ CLI::error('If you want, use the "-force" option to force delete all log files.', 'light_gray', 'red');
+ CLI::newLine();
+
+ return;
+ // @codeCoverageIgnoreEnd
+ }
+
+ helper('filesystem');
+
+ if (! delete_files(WRITEPATH . 'logs', false, true)) {
+ // @codeCoverageIgnoreStart
+ CLI::error('Error in deleting the logs files.', 'light_gray', 'red');
+ CLI::newLine();
+
+ return;
+ // @codeCoverageIgnoreEnd
+ }
+
+ CLI::write('Logs cleared.', 'green');
+ CLI::newLine();
+ }
+}
diff --git a/system/Commands/ListCommands.php b/system/Commands/ListCommands.php
new file mode 100644
index 0000000..3411b10
--- /dev/null
+++ b/system/Commands/ListCommands.php
@@ -0,0 +1,140 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+
+/**
+ * CI Help command for the spark script.
+ *
+ * Lists the basic usage information for the spark script,
+ * and provides a way to list help for other commands.
+ */
+class ListCommands extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'CodeIgniter';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'list';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Lists the available commands.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'list';
+
+ /**
+ * the Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [];
+
+ /**
+ * the Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--simple' => 'Prints a list of the commands with no other info',
+ ];
+
+ /**
+ * Displays the help for the spark cli script itself.
+ */
+ public function run(array $params)
+ {
+ $commands = $this->commands->getCommands();
+ ksort($commands);
+
+ // Check for 'simple' format
+ return array_key_exists('simple', $params) || CLI::getOption('simple')
+ ? $this->listSimple($commands)
+ : $this->listFull($commands);
+ }
+
+ /**
+ * Lists the commands with accompanying info.
+ *
+ * @return void
+ */
+ protected function listFull(array $commands)
+ {
+ // Sort into buckets by group
+ $groups = [];
+
+ foreach ($commands as $title => $command) {
+ if (! isset($groups[$command['group']])) {
+ $groups[$command['group']] = [];
+ }
+
+ $groups[$command['group']][$title] = $command;
+ }
+
+ $length = max(array_map(strlen(...), array_keys($commands)));
+
+ ksort($groups);
+
+ // Display it all...
+ foreach ($groups as $group => $commands) {
+ CLI::write($group, 'yellow');
+
+ foreach ($commands as $name => $command) {
+ $name = $this->setPad($name, $length, 2, 2);
+ $output = CLI::color($name, 'green');
+
+ if (isset($command['description'])) {
+ $output .= CLI::wrap($command['description'], 125, strlen($name));
+ }
+
+ CLI::write($output);
+ }
+
+ if ($group !== array_key_last($groups)) {
+ CLI::newLine();
+ }
+ }
+ }
+
+ /**
+ * Lists the commands only.
+ *
+ * @return void
+ */
+ protected function listSimple(array $commands)
+ {
+ foreach (array_keys($commands) as $title) {
+ CLI::write($title);
+ }
+ }
+}
diff --git a/system/Commands/Server/Serve.php b/system/Commands/Server/Serve.php
new file mode 100644
index 0000000..82e5899
--- /dev/null
+++ b/system/Commands/Server/Serve.php
@@ -0,0 +1,119 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Server;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+
+/**
+ * Launch the PHP development server
+ *
+ * Not testable, as it throws phpunit for a loop :-/
+ *
+ * @codeCoverageIgnore
+ */
+class Serve extends BaseCommand
+{
+ /**
+ * Group
+ *
+ * @var string
+ */
+ protected $group = 'CodeIgniter';
+
+ /**
+ * Name
+ *
+ * @var string
+ */
+ protected $name = 'serve';
+
+ /**
+ * Description
+ *
+ * @var string
+ */
+ protected $description = 'Launches the CodeIgniter PHP-Development Server.';
+
+ /**
+ * Usage
+ *
+ * @var string
+ */
+ protected $usage = 'serve';
+
+ /**
+ * Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [];
+
+ /**
+ * The current port offset.
+ *
+ * @var int
+ */
+ protected $portOffset = 0;
+
+ /**
+ * The max number of ports to attempt to serve from
+ *
+ * @var int
+ */
+ protected $tries = 10;
+
+ /**
+ * Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '--php' => 'The PHP Binary [default: "PHP_BINARY"]',
+ '--host' => 'The HTTP Host [default: "localhost"]',
+ '--port' => 'The HTTP Host Port [default: "8080"]',
+ ];
+
+ /**
+ * Run the server
+ */
+ public function run(array $params)
+ {
+ // Collect any user-supplied options and apply them.
+ $php = escapeshellarg(CLI::getOption('php') ?? PHP_BINARY);
+ $host = CLI::getOption('host') ?? 'localhost';
+ $port = (int) (CLI::getOption('port') ?? 8080) + $this->portOffset;
+
+ // Get the party started.
+ CLI::write('CodeIgniter development server started on http://' . $host . ':' . $port, 'green');
+ CLI::write('Press Control-C to stop.');
+
+ // Set the Front Controller path as Document Root.
+ $docroot = escapeshellarg(FCPATH);
+
+ // Mimic Apache's mod_rewrite functionality with user settings.
+ $rewrite = escapeshellarg(SYSTEMPATH . 'rewrite.php');
+
+ // Call PHP's built-in webserver, making sure to set our
+ // base path to the public folder, and to use the rewrite file
+ // to ensure our environment is set and it simulates basic mod_rewrite.
+ passthru($php . ' -S ' . $host . ':' . $port . ' -t ' . $docroot . ' ' . $rewrite, $status);
+
+ if ($status && $this->portOffset < $this->tries) {
+ $this->portOffset++;
+
+ $this->run($params);
+ }
+ }
+}
diff --git a/system/Commands/Translation/LocalizationFinder.php b/system/Commands/Translation/LocalizationFinder.php
new file mode 100644
index 0000000..e7d4eff
--- /dev/null
+++ b/system/Commands/Translation/LocalizationFinder.php
@@ -0,0 +1,389 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Translation;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\Helpers\Array\ArrayHelper;
+use Config\App;
+use Locale;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use SplFileInfo;
+
+/**
+ * @see \CodeIgniter\Commands\Translation\LocalizationFinderTest
+ */
+class LocalizationFinder extends BaseCommand
+{
+ protected $group = 'Translation';
+ protected $name = 'lang:find';
+ protected $description = 'Find and save available phrases to translate.';
+ protected $usage = 'lang:find [options]';
+ protected $arguments = [];
+ protected $options = [
+ '--locale' => 'Specify locale (en, ru, etc.) to save files.',
+ '--dir' => 'Directory to search for translations relative to APPPATH.',
+ '--show-new' => 'Show only new translations in table. Does not write to files.',
+ '--verbose' => 'Output detailed information.',
+ ];
+
+ /**
+ * Flag for output detailed information
+ */
+ private bool $verbose = false;
+
+ /**
+ * Flag for showing only translations, without saving
+ */
+ private bool $showNew = false;
+
+ private string $languagePath;
+
+ public function run(array $params)
+ {
+ $this->verbose = array_key_exists('verbose', $params);
+ $this->showNew = array_key_exists('show-new', $params);
+ $optionLocale = $params['locale'] ?? null;
+ $optionDir = $params['dir'] ?? null;
+ $currentLocale = Locale::getDefault();
+ $currentDir = APPPATH;
+ $this->languagePath = $currentDir . 'Language';
+
+ if (ENVIRONMENT === 'testing') {
+ $currentDir = SUPPORTPATH . 'Services' . DIRECTORY_SEPARATOR;
+ $this->languagePath = SUPPORTPATH . 'Language';
+ }
+
+ if (is_string($optionLocale)) {
+ if (! in_array($optionLocale, config(App::class)->supportedLocales, true)) {
+ CLI::error(
+ 'Error: "' . $optionLocale . '" is not supported. Supported locales: '
+ . implode(', ', config(App::class)->supportedLocales)
+ );
+
+ return EXIT_USER_INPUT;
+ }
+
+ $currentLocale = $optionLocale;
+ }
+
+ if (is_string($optionDir)) {
+ $tempCurrentDir = realpath($currentDir . $optionDir);
+
+ if ($tempCurrentDir === false) {
+ CLI::error('Error: Directory must be located in "' . $currentDir . '"');
+
+ return EXIT_USER_INPUT;
+ }
+
+ if ($this->isSubDirectory($tempCurrentDir, $this->languagePath)) {
+ CLI::error('Error: Directory "' . $this->languagePath . '" restricted to scan.');
+
+ return EXIT_USER_INPUT;
+ }
+
+ $currentDir = $tempCurrentDir;
+ }
+
+ $this->process($currentDir, $currentLocale);
+
+ CLI::write('All operations done!');
+
+ return EXIT_SUCCESS;
+ }
+
+ private function process(string $currentDir, string $currentLocale): void
+ {
+ $tableRows = [];
+ $countNewKeys = 0;
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($currentDir));
+ $files = iterator_to_array($iterator, true);
+ ksort($files);
+
+ [
+ 'foundLanguageKeys' => $foundLanguageKeys,
+ 'badLanguageKeys' => $badLanguageKeys,
+ 'countFiles' => $countFiles
+ ] = $this->findLanguageKeysInFiles($files);
+
+ ksort($foundLanguageKeys);
+
+ $languageDiff = [];
+ $languageFoundGroups = array_unique(array_keys($foundLanguageKeys));
+
+ foreach ($languageFoundGroups as $langFileName) {
+ $languageStoredKeys = [];
+ $languageFilePath = $this->languagePath . DIRECTORY_SEPARATOR . $currentLocale . DIRECTORY_SEPARATOR . $langFileName . '.php';
+
+ if (is_file($languageFilePath)) {
+ // Load old localization
+ $languageStoredKeys = require $languageFilePath;
+ }
+
+ $languageDiff = ArrayHelper::recursiveDiff($foundLanguageKeys[$langFileName], $languageStoredKeys);
+ $countNewKeys += ArrayHelper::recursiveCount($languageDiff);
+
+ if ($this->showNew) {
+ $tableRows = array_merge($this->arrayToTableRows($langFileName, $languageDiff), $tableRows);
+ } else {
+ $newLanguageKeys = array_replace_recursive($foundLanguageKeys[$langFileName], $languageStoredKeys);
+
+ if ($languageDiff !== []) {
+ if (file_put_contents($languageFilePath, $this->templateFile($newLanguageKeys)) === false) {
+ $this->writeIsVerbose('Lang file ' . $langFileName . ' (error write).', 'red');
+ } else {
+ $this->writeIsVerbose('Lang file "' . $langFileName . '" successful updated!', 'green');
+ }
+ }
+ }
+ }
+
+ if ($this->showNew && $tableRows !== []) {
+ sort($tableRows);
+ CLI::table($tableRows, ['File', 'Key']);
+ }
+
+ if (! $this->showNew && $countNewKeys > 0) {
+ CLI::write('Note: You need to run your linting tool to fix coding standards issues.', 'white', 'red');
+ }
+
+ $this->writeIsVerbose('Files found: ' . $countFiles);
+ $this->writeIsVerbose('New translates found: ' . $countNewKeys);
+ $this->writeIsVerbose('Bad translates found: ' . count($badLanguageKeys));
+
+ if ($this->verbose && $badLanguageKeys !== []) {
+ $tableBadRows = [];
+
+ foreach ($badLanguageKeys as $value) {
+ $tableBadRows[] = [$value[1], $value[0]];
+ }
+
+ ArrayHelper::sortValuesByNatural($tableBadRows, 0);
+
+ CLI::table($tableBadRows, ['Bad Key', 'Filepath']);
+ }
+ }
+
+ /**
+ * @param SplFileInfo|string $file
+ *
+ * @return array
+ */
+ private function findTranslationsInFile($file): array
+ {
+ $foundLanguageKeys = [];
+ $badLanguageKeys = [];
+
+ if (is_string($file) && is_file($file)) {
+ $file = new SplFileInfo($file);
+ }
+
+ $fileContent = file_get_contents($file->getRealPath());
+ preg_match_all('/lang\(\'([._a-z0-9\-]+)\'\)/ui', $fileContent, $matches);
+
+ if ($matches[1] === []) {
+ return compact('foundLanguageKeys', 'badLanguageKeys');
+ }
+
+ foreach ($matches[1] as $phraseKey) {
+ $phraseKeys = explode('.', $phraseKey);
+
+ // Language key not have Filename or Lang key
+ if (count($phraseKeys) < 2) {
+ $badLanguageKeys[] = [mb_substr($file->getRealPath(), mb_strlen(ROOTPATH)), $phraseKey];
+
+ continue;
+ }
+
+ $languageFileName = array_shift($phraseKeys);
+ $isEmptyNestedArray = ($languageFileName !== '' && $phraseKeys[0] === '')
+ || ($languageFileName === '' && $phraseKeys[0] !== '')
+ || ($languageFileName === '' && $phraseKeys[0] === '');
+
+ if ($isEmptyNestedArray) {
+ $badLanguageKeys[] = [mb_substr($file->getRealPath(), mb_strlen(ROOTPATH)), $phraseKey];
+
+ continue;
+ }
+
+ if (count($phraseKeys) === 1) {
+ $foundLanguageKeys[$languageFileName][$phraseKeys[0]] = $phraseKey;
+ } else {
+ $childKeys = $this->buildMultiArray($phraseKeys, $phraseKey);
+
+ $foundLanguageKeys[$languageFileName] = array_replace_recursive($foundLanguageKeys[$languageFileName] ?? [], $childKeys);
+ }
+ }
+
+ return compact('foundLanguageKeys', 'badLanguageKeys');
+ }
+
+ private function isIgnoredFile(SplFileInfo $file): bool
+ {
+ if ($file->isDir() || $this->isSubDirectory($file->getRealPath(), $this->languagePath)) {
+ return true;
+ }
+
+ return $file->getExtension() !== 'php';
+ }
+
+ private function templateFile(array $language = []): string
+ {
+ if ($language !== []) {
+ $languageArrayString = var_export($language, true);
+
+ $code = <<replaceArraySyntax($code);
+ }
+
+ return <<<'PHP'
+ $token) {
+ if (is_array($token)) {
+ [$tokenId, $tokenValue] = $token;
+
+ // Replace "array ("
+ if (
+ $tokenId === T_ARRAY
+ && $tokens[$i + 1][0] === T_WHITESPACE
+ && $tokens[$i + 2] === '('
+ ) {
+ $newTokens[$i][1] = '[';
+ $newTokens[$i + 1][1] = '';
+ $newTokens[$i + 2] = '';
+ }
+
+ // Replace indent
+ if ($tokenId === T_WHITESPACE && preg_match('/\n([ ]+)/u', $tokenValue, $matches)) {
+ $newTokens[$i][1] = "\n{$matches[1]}{$matches[1]}";
+ }
+ } // Replace ")"
+ elseif ($token === ')') {
+ $newTokens[$i] = ']';
+ }
+ }
+
+ $output = '';
+
+ foreach ($newTokens as $token) {
+ $output .= $token[1] ?? $token;
+ }
+
+ return $output;
+ }
+
+ /**
+ * Create multidimensional array from another keys
+ */
+ private function buildMultiArray(array $fromKeys, string $lastArrayValue = ''): array
+ {
+ $newArray = [];
+ $lastIndex = array_pop($fromKeys);
+ $current = &$newArray;
+
+ foreach ($fromKeys as $value) {
+ $current[$value] = [];
+ $current = &$current[$value];
+ }
+
+ $current[$lastIndex] = $lastArrayValue;
+
+ return $newArray;
+ }
+
+ /**
+ * Convert multi arrays to specific CLI table rows (flat array)
+ */
+ private function arrayToTableRows(string $langFileName, array $array): array
+ {
+ $rows = [];
+
+ foreach ($array as $value) {
+ if (is_array($value)) {
+ $rows = array_merge($rows, $this->arrayToTableRows($langFileName, $value));
+
+ continue;
+ }
+
+ if (is_string($value)) {
+ $rows[] = [$langFileName, $value];
+ }
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Show details in the console if the flag is set
+ */
+ private function writeIsVerbose(string $text = '', ?string $foreground = null, ?string $background = null): void
+ {
+ if ($this->verbose) {
+ CLI::write($text, $foreground, $background);
+ }
+ }
+
+ private function isSubDirectory(string $directory, string $rootDirectory): bool
+ {
+ return 0 === strncmp($directory, $rootDirectory, strlen($directory));
+ }
+
+ /**
+ * @param list $files
+ *
+ * @return array
+ * @phpstan-return array{'foundLanguageKeys': array>, 'badLanguageKeys': array>, 'countFiles': int}
+ */
+ private function findLanguageKeysInFiles(array $files): array
+ {
+ $foundLanguageKeys = [];
+ $badLanguageKeys = [];
+ $countFiles = 0;
+
+ foreach ($files as $file) {
+ if ($this->isIgnoredFile($file)) {
+ continue;
+ }
+
+ $this->writeIsVerbose('File found: ' . mb_substr($file->getRealPath(), mb_strlen(APPPATH)));
+ $countFiles++;
+
+ $findInFile = $this->findTranslationsInFile($file);
+
+ $foundLanguageKeys = array_replace_recursive($findInFile['foundLanguageKeys'], $foundLanguageKeys);
+ $badLanguageKeys = array_merge($findInFile['badLanguageKeys'], $badLanguageKeys);
+ }
+
+ return compact('foundLanguageKeys', 'badLanguageKeys', 'countFiles');
+ }
+}
diff --git a/system/Commands/Utilities/ConfigCheck.php b/system/Commands/Utilities/ConfigCheck.php
new file mode 100644
index 0000000..7d6dc33
--- /dev/null
+++ b/system/Commands/Utilities/ConfigCheck.php
@@ -0,0 +1,156 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities;
+
+use CodeIgniter\Cache\FactoriesCache;
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\Config\BaseConfig;
+use Config\Optimize;
+use Kint\Kint;
+
+/**
+ * Check the Config values.
+ *
+ * @see \CodeIgniter\Commands\Utilities\ConfigCheckTest
+ */
+final class ConfigCheck extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'CodeIgniter';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'config:check';
+
+ /**
+ * The Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Check your Config values.';
+
+ /**
+ * The Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'config:check ';
+
+ /**
+ * The Command's arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'classname' => 'The config classname to check. Short classname or FQCN.',
+ ];
+
+ /**
+ * The Command's options
+ *
+ * @var array
+ */
+ protected $options = [];
+
+ /**
+ * {@inheritDoc}
+ */
+ public function run(array $params)
+ {
+ if (! isset($params[0])) {
+ CLI::error('You must specify a Config classname.');
+ CLI::write(' Usage: ' . $this->usage);
+ CLI::write('Example: config:check App');
+ CLI::write(' config:check \'CodeIgniter\Shield\Config\Auth\'');
+
+ return EXIT_ERROR;
+ }
+
+ /** @var class-string $class */
+ $class = $params[0];
+
+ // Load Config cache if it is enabled.
+ $configCacheEnabled = class_exists(Optimize::class)
+ && (new Optimize())->configCacheEnabled;
+ if ($configCacheEnabled) {
+ $factoriesCache = new FactoriesCache();
+ $factoriesCache->load('config');
+ }
+
+ $config = config($class);
+
+ if ($config === null) {
+ CLI::error('No such Config class: ' . $class);
+
+ return EXIT_ERROR;
+ }
+
+ if (defined('KINT_DIR') && Kint::$enabled_mode !== false) {
+ CLI::write($this->getKintD($config));
+ } else {
+ CLI::write(
+ CLI::color($this->getVarDump($config), 'cyan')
+ );
+ }
+
+ CLI::newLine();
+ $state = CLI::color($configCacheEnabled ? 'Enabled' : 'Disabled', 'green');
+ CLI::write('Config Caching: ' . $state);
+
+ return EXIT_SUCCESS;
+ }
+
+ /**
+ * Gets object dump by Kint d()
+ */
+ private function getKintD(object $config): string
+ {
+ ob_start();
+ d($config);
+ $output = ob_get_clean();
+
+ $output = trim($output);
+
+ $lines = explode("\n", $output);
+ array_splice($lines, 0, 3);
+ array_splice($lines, -3);
+
+ return implode("\n", $lines);
+ }
+
+ /**
+ * Gets object dump by var_dump()
+ */
+ private function getVarDump(object $config): string
+ {
+ ob_start();
+ var_dump($config);
+ $output = ob_get_clean();
+
+ return preg_replace(
+ '!.*system/Commands/Utilities/ConfigCheck.php.*\n!u',
+ '',
+ $output
+ );
+ }
+}
diff --git a/system/Commands/Utilities/Environment.php b/system/Commands/Utilities/Environment.php
new file mode 100644
index 0000000..17a08d2
--- /dev/null
+++ b/system/Commands/Utilities/Environment.php
@@ -0,0 +1,157 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\Config\DotEnv;
+
+/**
+ * Command to display the current environment,
+ * or set a new one in the `.env` file.
+ */
+final class Environment extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'CodeIgniter';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'env';
+
+ /**
+ * The Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Retrieves the current environment, or set a new one.';
+
+ /**
+ * The Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'env []';
+
+ /**
+ * The Command's arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'environment' => '[Optional] The new environment to set. If none is provided, this will print the current environment.',
+ ];
+
+ /**
+ * The Command's options
+ *
+ * @var array
+ */
+ protected $options = [];
+
+ /**
+ * Allowed values for environment. `testing` is excluded
+ * since spark won't work on it.
+ *
+ * @var array
+ */
+ private static array $knownTypes = [
+ 'production',
+ 'development',
+ ];
+
+ /**
+ * {@inheritDoc}
+ */
+ public function run(array $params)
+ {
+ if ($params === []) {
+ CLI::write(sprintf('Your environment is currently set as %s.', CLI::color($_SERVER['CI_ENVIRONMENT'] ?? ENVIRONMENT, 'green')));
+ CLI::newLine();
+
+ return;
+ }
+
+ $env = strtolower(array_shift($params));
+
+ if ($env === 'testing') {
+ CLI::error('The "testing" environment is reserved for PHPUnit testing.', 'light_gray', 'red');
+ CLI::error('You will not be able to run spark under a "testing" environment.', 'light_gray', 'red');
+ CLI::newLine();
+
+ return;
+ }
+
+ if (! in_array($env, self::$knownTypes, true)) {
+ CLI::error(sprintf('Invalid environment type "%s". Expected one of "%s".', $env, implode('" and "', self::$knownTypes)), 'light_gray', 'red');
+ CLI::newLine();
+
+ return;
+ }
+
+ if (! $this->writeNewEnvironmentToEnvFile($env)) {
+ CLI::error('Error in writing new environment to .env file.', 'light_gray', 'red');
+ CLI::newLine();
+
+ return;
+ }
+
+ // force DotEnv to reload the new environment
+ // however we cannot redefine the ENVIRONMENT constant
+ putenv('CI_ENVIRONMENT');
+ unset($_ENV['CI_ENVIRONMENT'], $_SERVER['CI_ENVIRONMENT']);
+ (new DotEnv(ROOTPATH))->load();
+
+ CLI::write(sprintf('Environment is successfully changed to "%s".', $env), 'green');
+ CLI::write('The ENVIRONMENT constant will be changed in the next script execution.');
+ CLI::newLine();
+ }
+
+ /**
+ * @see https://regex101.com/r/4sSORp/1 for the regex in action
+ */
+ private function writeNewEnvironmentToEnvFile(string $newEnv): bool
+ {
+ $baseEnv = ROOTPATH . 'env';
+ $envFile = ROOTPATH . '.env';
+
+ if (! is_file($envFile)) {
+ if (! is_file($baseEnv)) {
+ CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow');
+ CLI::write('It is impossible to write the new environment type.', 'yellow');
+ CLI::newLine();
+
+ return false;
+ }
+
+ copy($baseEnv, $envFile);
+ }
+
+ $pattern = preg_quote($_SERVER['CI_ENVIRONMENT'] ?? ENVIRONMENT, '/');
+ $pattern = sprintf('/^[#\s]*CI_ENVIRONMENT[=\s]+%s$/m', $pattern);
+
+ return file_put_contents(
+ $envFile,
+ preg_replace($pattern, "\nCI_ENVIRONMENT = {$newEnv}", file_get_contents($envFile), -1, $count)
+ ) !== false && $count > 0;
+ }
+}
diff --git a/system/Commands/Utilities/FilterCheck.php b/system/Commands/Utilities/FilterCheck.php
new file mode 100644
index 0000000..56fcdb0
--- /dev/null
+++ b/system/Commands/Utilities/FilterCheck.php
@@ -0,0 +1,155 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\Commands\Utilities\Routes\FilterCollector;
+
+/**
+ * Check filters for a route.
+ */
+class FilterCheck extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'CodeIgniter';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'filter:check';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Check filters for a route.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'filter:check ';
+
+ /**
+ * the Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'method' => 'The HTTP method. GET, POST, PUT, etc.',
+ 'route' => 'The route (URI path) to check filters.',
+ ];
+
+ /**
+ * the Command's Options
+ *
+ * @var array
+ */
+ protected $options = [];
+
+ /**
+ * @return int exit code
+ */
+ public function run(array $params)
+ {
+ $tbody = [];
+ if (! isset($params[0], $params[1])) {
+ CLI::error('You must specify a HTTP verb and a route.');
+ CLI::write(' Usage: ' . $this->usage);
+ CLI::write('Example: filter:check GET /');
+ CLI::write(' filter:check PUT products/1');
+
+ return EXIT_ERROR;
+ }
+
+ $method = $params[0];
+ $route = $params[1];
+
+ // Load Routes
+ service('routes')->loadRoutes();
+
+ $filterCollector = new FilterCollector();
+
+ $filters = $filterCollector->get($method, $route);
+
+ // PageNotFoundException
+ if ($filters['before'] === ['']) {
+ CLI::error(
+ "Can't find a route: " .
+ CLI::color(
+ '"' . strtoupper($method) . ' ' . $route . '"',
+ 'black',
+ 'light_gray'
+ ),
+ );
+
+ return EXIT_ERROR;
+ }
+
+ $filters = $this->addRequiredFilters($filterCollector, $filters);
+
+ $tbody[] = [
+ strtoupper($method),
+ $route,
+ implode(' ', $filters['before']),
+ implode(' ', $filters['after']),
+ ];
+
+ $thead = [
+ 'Method',
+ 'Route',
+ 'Before Filters',
+ 'After Filters',
+ ];
+
+ CLI::table($tbody, $thead);
+
+ return EXIT_SUCCESS;
+ }
+
+ private function addRequiredFilters(FilterCollector $filterCollector, array $filters): array
+ {
+ $output = [];
+
+ $required = $filterCollector->getRequiredFilters();
+
+ $colored = [];
+
+ foreach ($required['before'] as $filter) {
+ $filter = CLI::color($filter, 'yellow');
+ $colored[] = $filter;
+ }
+ $output['before'] = array_merge($colored, $filters['before']);
+
+ $colored = [];
+
+ foreach ($required['after'] as $filter) {
+ $filter = CLI::color($filter, 'yellow');
+ $colored[] = $filter;
+ }
+ $output['after'] = array_merge($filters['after'], $colored);
+
+ return $output;
+ }
+}
diff --git a/system/Commands/Utilities/Namespaces.php b/system/Commands/Utilities/Namespaces.php
new file mode 100644
index 0000000..c16f692
--- /dev/null
+++ b/system/Commands/Utilities/Namespaces.php
@@ -0,0 +1,160 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use Config\Autoload;
+
+/**
+ * Lists namespaces set in Config\Autoload with their
+ * full server path. Helps you to verify that you have
+ * the namespaces setup correctly.
+ *
+ * @see \CodeIgniter\Commands\Utilities\NamespacesTest
+ */
+class Namespaces extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'CodeIgniter';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'namespaces';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Verifies your namespaces are setup correctly.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'namespaces';
+
+ /**
+ * the Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [];
+
+ /**
+ * the Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '-c' => 'Show only CodeIgniter config namespaces.',
+ '-r' => 'Show raw path strings.',
+ '-m' => 'Specify max length of the path strings to output. Default: 60.',
+ ];
+
+ /**
+ * Displays the help for the spark cli script itself.
+ */
+ public function run(array $params)
+ {
+ $params['m'] = (int) ($params['m'] ?? 60);
+
+ $tbody = array_key_exists('c', $params) ? $this->outputCINamespaces($params) : $this->outputAllNamespaces($params);
+
+ $thead = [
+ 'Namespace',
+ 'Path',
+ 'Found?',
+ ];
+
+ CLI::table($tbody, $thead);
+ }
+
+ private function outputAllNamespaces(array $params): array
+ {
+ $maxLength = $params['m'];
+
+ $autoloader = service('autoloader');
+
+ $tbody = [];
+
+ foreach ($autoloader->getNamespace() as $ns => $paths) {
+ foreach ($paths as $path) {
+ if (array_key_exists('r', $params)) {
+ $pathOutput = $this->truncate($path, $maxLength);
+ } else {
+ $pathOutput = $this->truncate(clean_path($path), $maxLength);
+ }
+
+ $tbody[] = [
+ $ns,
+ $pathOutput,
+ is_dir($path) ? 'Yes' : 'MISSING',
+ ];
+ }
+ }
+
+ return $tbody;
+ }
+
+ private function truncate(string $string, int $max): string
+ {
+ $length = strlen($string);
+
+ if ($length > $max) {
+ return substr($string, 0, $max - 3) . '...';
+ }
+
+ return $string;
+ }
+
+ private function outputCINamespaces(array $params): array
+ {
+ $maxLength = $params['m'];
+
+ $config = new Autoload();
+
+ $tbody = [];
+
+ foreach ($config->psr4 as $ns => $paths) {
+ foreach ((array) $paths as $path) {
+ if (array_key_exists('r', $params)) {
+ $pathOutput = $this->truncate($path, $maxLength);
+ } else {
+ $pathOutput = $this->truncate(clean_path($path), $maxLength);
+ }
+
+ $path = realpath($path) ?: $path;
+
+ $tbody[] = [
+ $ns,
+ $pathOutput,
+ is_dir($path) ? 'Yes' : 'MISSING',
+ ];
+ }
+ }
+
+ return $tbody;
+ }
+}
diff --git a/system/Commands/Utilities/Optimize.php b/system/Commands/Utilities/Optimize.php
new file mode 100644
index 0000000..fa7612d
--- /dev/null
+++ b/system/Commands/Utilities/Optimize.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\Commands\Utilities;
+
+use CodeIgniter\Autoloader\FileLocator;
+use CodeIgniter\Autoloader\FileLocatorCached;
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\Publisher\Publisher;
+use RuntimeException;
+
+/**
+ * Optimize for production.
+ */
+final class Optimize extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'CodeIgniter';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'optimize';
+
+ /**
+ * The Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Optimize for production.';
+
+ /**
+ * The Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'optimize';
+
+ /**
+ * {@inheritDoc}
+ */
+ public function run(array $params)
+ {
+ try {
+ $this->enableCaching();
+ $this->clearCache();
+ $this->removeDevPackages();
+ } catch (RuntimeException) {
+ CLI::error('The "spark optimize" failed.');
+
+ return EXIT_ERROR;
+ }
+
+ return EXIT_SUCCESS;
+ }
+
+ private function clearCache(): void
+ {
+ $locator = new FileLocatorCached(new FileLocator(service('autoloader')));
+ $locator->deleteCache();
+ CLI::write('Removed FileLocatorCache.', 'green');
+
+ $cache = WRITEPATH . 'cache/FactoriesCache_config';
+ $this->removeFile($cache);
+ }
+
+ private function removeFile(string $cache): void
+ {
+ if (is_file($cache)) {
+ $result = unlink($cache);
+
+ if ($result) {
+ CLI::write('Removed "' . clean_path($cache) . '".', 'green');
+
+ return;
+ }
+
+ CLI::error('Error in removing file: ' . clean_path($cache));
+
+ throw new RuntimeException(__METHOD__);
+ }
+ }
+
+ private function enableCaching(): void
+ {
+ $publisher = new Publisher(APPPATH, APPPATH);
+
+ $config = APPPATH . 'Config/Optimize.php';
+
+ $result = $publisher->replace(
+ $config,
+ [
+ 'public bool $configCacheEnabled = false;' => 'public bool $configCacheEnabled = true;',
+ 'public bool $locatorCacheEnabled = false;' => 'public bool $locatorCacheEnabled = true;',
+ ]
+ );
+
+ if ($result) {
+ CLI::write(
+ 'Config Caching and FileLocator Caching are enabled in "app/Config/Optimize.php".',
+ 'green'
+ );
+
+ return;
+ }
+
+ CLI::error('Error in updating file: ' . clean_path($config));
+
+ throw new RuntimeException(__METHOD__);
+ }
+
+ private function removeDevPackages(): void
+ {
+ if (! defined('VENDORPATH')) {
+ return;
+ }
+
+ chdir(ROOTPATH);
+ passthru('composer install --no-dev', $status);
+
+ if ($status === 0) {
+ CLI::write('Removed Composer dev packages.', 'green');
+
+ return;
+ }
+
+ CLI::error('Error in removing Composer dev packages.');
+
+ throw new RuntimeException(__METHOD__);
+ }
+}
diff --git a/system/Commands/Utilities/PhpIniCheck.php b/system/Commands/Utilities/PhpIniCheck.php
new file mode 100644
index 0000000..0426f90
--- /dev/null
+++ b/system/Commands/Utilities/PhpIniCheck.php
@@ -0,0 +1,77 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\Security\CheckPhpIni;
+
+/**
+ * Check php.ini values.
+ */
+final class PhpIniCheck extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'CodeIgniter';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'phpini:check';
+
+ /**
+ * The Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Check your php.ini values.';
+
+ /**
+ * The Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'phpini:check';
+
+ /**
+ * The Command's arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ ];
+
+ /**
+ * The Command's options
+ *
+ * @var array
+ */
+ protected $options = [];
+
+ /**
+ * {@inheritDoc}
+ */
+ public function run(array $params)
+ {
+ CheckPhpIni::run();
+
+ return EXIT_SUCCESS;
+ }
+}
diff --git a/system/Commands/Utilities/Publish.php b/system/Commands/Utilities/Publish.php
new file mode 100644
index 0000000..3e19685
--- /dev/null
+++ b/system/Commands/Utilities/Publish.php
@@ -0,0 +1,106 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities;
+
+use CodeIgniter\CLI\BaseCommand;
+use CodeIgniter\CLI\CLI;
+use CodeIgniter\Publisher\Publisher;
+
+/**
+ * Discovers all Publisher classes from the "Publishers/" directory
+ * across namespaces. Executes `publish()` from each instance, parsing
+ * each result.
+ */
+class Publish extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'CodeIgniter';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'publish';
+
+ /**
+ * The Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Discovers and executes all predefined Publisher classes.';
+
+ /**
+ * The Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'publish []';
+
+ /**
+ * The Command's arguments
+ *
+ * @var array
+ */
+ protected $arguments = [
+ 'directory' => '[Optional] The directory to scan within each namespace. Default: "Publishers".',
+ ];
+
+ /**
+ * the Command's Options
+ *
+ * @var array
+ */
+ protected $options = [];
+
+ /**
+ * Displays the help for the spark cli script itself.
+ */
+ public function run(array $params)
+ {
+ $directory = array_shift($params) ?? 'Publishers';
+
+ if ([] === $publishers = Publisher::discover($directory)) {
+ CLI::write(lang('Publisher.publishMissing', [$directory]));
+
+ return;
+ }
+
+ foreach ($publishers as $publisher) {
+ if ($publisher->publish()) {
+ CLI::write(lang('Publisher.publishSuccess', [
+ $publisher::class,
+ count($publisher->getPublished()),
+ $publisher->getDestination(),
+ ]), 'green');
+ } else {
+ CLI::error(lang('Publisher.publishFailure', [
+ $publisher::class,
+ $publisher->getDestination(),
+ ]), 'light_gray', 'red');
+
+ foreach ($publisher->getErrors() as $file => $exception) {
+ CLI::write($file);
+ CLI::error($exception->getMessage());
+ CLI::newLine();
+ }
+ }
+ }
+ }
+}
diff --git a/system/Commands/Utilities/Routes.php b/system/Commands/Utilities/Routes.php
new file mode 100644
index 0000000..9adcc1b
--- /dev/null
+++ b/system/Commands/Utilities/Routes.php
@@ -0,0 +1,222 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities;
+
+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 CodeIgniter\Router\Router;
+use Config\Feature;
+use Config\Routing;
+
+/**
+ * Lists all the routes. This will include any Routes files
+ * that can be discovered, and will include routes that are not defined
+ * in routes files, but are instead discovered through auto-routing.
+ */
+class Routes extends BaseCommand
+{
+ /**
+ * The group the command is lumped under
+ * when listing commands.
+ *
+ * @var string
+ */
+ protected $group = 'CodeIgniter';
+
+ /**
+ * The Command's name
+ *
+ * @var string
+ */
+ protected $name = 'routes';
+
+ /**
+ * the Command's short description
+ *
+ * @var string
+ */
+ protected $description = 'Displays all routes.';
+
+ /**
+ * the Command's usage
+ *
+ * @var string
+ */
+ protected $usage = 'routes';
+
+ /**
+ * the Command's Arguments
+ *
+ * @var array
+ */
+ protected $arguments = [];
+
+ /**
+ * the Command's Options
+ *
+ * @var array
+ */
+ protected $options = [
+ '-h' => 'Sort by Handler.',
+ '--host' => 'Specify hostname in request URI.',
+ ];
+
+ /**
+ * Displays the help for the spark cli script itself.
+ */
+ public function run(array $params)
+ {
+ $sortByHandler = array_key_exists('h', $params);
+ $host = $params['host'] ?? null;
+
+ // Set HTTP_HOST
+ if ($host) {
+ $request = service('request');
+ $_SERVER = $request->getServer();
+ $_SERVER['HTTP_HOST'] = $host;
+ $request->setGlobal('server', $_SERVER);
+ }
+
+ $collection = service('routes')->loadRoutes();
+
+ // Reset HTTP_HOST
+ if ($host) {
+ unset($_SERVER['HTTP_HOST']);
+ }
+
+ $methods = Router::HTTP_METHODS;
+
+ $tbody = [];
+ $uriGenerator = new SampleURIGenerator();
+ $filterCollector = new FilterCollector();
+
+ $definedRouteCollector = new DefinedRouteCollector($collection);
+
+ foreach ($definedRouteCollector->collect() as $route) {
+ $sampleUri = $uriGenerator->get($route['route']);
+ $filters = $filterCollector->get($route['method'], $sampleUri);
+
+ $routeName = ($route['route'] === $route['name']) ? '»' : $route['name'];
+
+ $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()) {
+ $autoRoutesImproved = config(Feature::class)->autoRoutesImproved ?? false;
+
+ if ($autoRoutesImproved) {
+ $autoRouteCollector = new AutoRouteCollectorImproved(
+ $collection->getDefaultNamespace(),
+ $collection->getDefaultController(),
+ $collection->getDefaultMethod(),
+ $methods,
+ $collection->getRegisteredControllers('*')
+ );
+
+ $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(),
+ $collection->getDefaultController(),
+ $collection->getDefaultMethod()
+ );
+
+ $autoRoutes = $autoRouteCollector->get();
+
+ foreach ($autoRoutes as &$routes) {
+ // There is no `AUTO` method, but it is intentional not to get route filters.
+ $filters = $filterCollector->get('AUTO', $uriGenerator->get($routes[1]));
+
+ $routes[] = implode(' ', array_map(class_basename(...), $filters['before']));
+ $routes[] = implode(' ', array_map(class_basename(...), $filters['after']));
+ }
+ }
+
+ $tbody = [...$tbody, ...$autoRoutes];
+ }
+
+ $thead = [
+ 'Method',
+ 'Route',
+ 'Name',
+ $sortByHandler ? 'Handler ↓' : 'Handler',
+ 'Before Filters',
+ 'After Filters',
+ ];
+
+ // Sort by Handler.
+ if ($sortByHandler) {
+ usort($tbody, static fn ($handler1, $handler2) => strcmp($handler1[3], $handler2[3]));
+ }
+
+ if ($host) {
+ CLI::write('Host: ' . $host);
+ }
+
+ CLI::table($tbody, $thead);
+
+ $this->showRequiredFilters();
+ }
+
+ private function showRequiredFilters(): void
+ {
+ $filterCollector = new FilterCollector();
+
+ $required = $filterCollector->getRequiredFilters();
+
+ $filters = [];
+
+ foreach ($required['before'] as $filter) {
+ $filters[] = CLI::color($filter, 'yellow');
+ }
+
+ CLI::write('Required Before Filters: ' . implode(', ', $filters));
+
+ $filters = [];
+
+ foreach ($required['after'] as $filter) {
+ $filters[] = CLI::color($filter, 'yellow');
+ }
+
+ CLI::write(' Required After Filters: ' . implode(', ', $filters));
+ }
+}
diff --git a/system/Commands/Utilities/Routes/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouteCollector.php
new file mode 100644
index 0000000..73009b9
--- /dev/null
+++ b/system/Commands/Utilities/Routes/AutoRouteCollector.php
@@ -0,0 +1,59 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities\Routes;
+
+/**
+ * Collects data for auto route listing.
+ *
+ * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouteCollectorTest
+ */
+final class AutoRouteCollector
+{
+ /**
+ * @param string $namespace namespace to search
+ */
+ public function __construct(private readonly string $namespace, private readonly string $defaultController, private readonly string $defaultMethod)
+ {
+ }
+
+ /**
+ * @return list>
+ */
+ public function get(): array
+ {
+ $finder = new ControllerFinder($this->namespace);
+ $reader = new ControllerMethodReader($this->namespace);
+
+ $tbody = [];
+
+ foreach ($finder->find() as $class) {
+ $output = $reader->read(
+ $class,
+ $this->defaultController,
+ $this->defaultMethod
+ );
+
+ foreach ($output as $item) {
+ $tbody[] = [
+ 'auto',
+ $item['route'],
+ '',
+ $item['handler'],
+ ];
+ }
+ }
+
+ return $tbody;
+ }
+}
diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php
new file mode 100644
index 0000000..5799693
--- /dev/null
+++ b/system/Commands/Utilities/Routes/AutoRouterImproved/AutoRouteCollector.php
@@ -0,0 +1,151 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved;
+
+use CodeIgniter\Commands\Utilities\Routes\ControllerFinder;
+use CodeIgniter\Commands\Utilities\Routes\FilterCollector;
+
+/**
+ * Collects data for Auto Routing Improved.
+ *
+ * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved\AutoRouteCollectorTest
+ */
+final class AutoRouteCollector
+{
+ /**
+ * @param string $namespace namespace to search
+ * @param list $protectedControllers List of controllers in Defined
+ * Routes that should not be accessed via Auto-Routing.
+ * @param list $httpMethods
+ * @param string $prefix URI prefix for Module Routing
+ */
+ public function __construct(
+ private readonly string $namespace,
+ private readonly string $defaultController,
+ private readonly string $defaultMethod,
+ private readonly array $httpMethods,
+ private readonly array $protectedControllers,
+ private string $prefix = ''
+ ) {
+ }
+
+ /**
+ * @return list>
+ */
+ public function get(): array
+ {
+ $finder = new ControllerFinder($this->namespace);
+ $reader = new ControllerMethodReader($this->namespace, $this->httpMethods);
+
+ $tbody = [];
+
+ foreach ($finder->find() as $class) {
+ // Exclude controllers in Defined Routes.
+ if (in_array('\\' . $class, $this->protectedControllers, true)) {
+ continue;
+ }
+
+ $routes = $reader->read(
+ $class,
+ $this->defaultController,
+ $this->defaultMethod
+ );
+
+ if ($routes === []) {
+ continue;
+ }
+
+ $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)',
+ $route,
+ '',
+ $item['handler'],
+ $item['before'],
+ $item['after'],
+ ];
+ }
+ }
+
+ return $tbody;
+ }
+
+ /**
+ * Adding Filters
+ *
+ * @param list> $routes
+ *
+ * @return list>
+ */
+ 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'], $routePath . $sampleUri);
+
+ // Search filters for the URI without optional params
+ $sampleUri = $this->generateSampleUri($route, false);
+ $filtersShortest = $filterCollector->get($route['method'], $routePath . $sampleUri);
+
+ // Get common array elements
+ $filters['before'] = array_intersect($filtersLongest['before'], $filtersShortest['before']);
+ $filters['after'] = array_intersect($filtersLongest['after'], $filtersShortest['after']);
+
+ $route['before'] = implode(' ', array_map(class_basename(...), $filters['before']));
+ $route['after'] = implode(' ', array_map(class_basename(...), $filters['after']));
+ }
+
+ return $routes;
+ }
+
+ private function generateSampleUri(array $route, bool $longest = true): string
+ {
+ $sampleUri = '';
+
+ if (isset($route['params'])) {
+ $i = 1;
+
+ foreach ($route['params'] as $required) {
+ if ($longest && ! $required) {
+ $sampleUri .= '/' . $i++;
+ }
+ }
+ }
+
+ return $sampleUri;
+ }
+}
diff --git a/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php
new file mode 100644
index 0000000..e08a16f
--- /dev/null
+++ b/system/Commands/Utilities/Routes/AutoRouterImproved/ControllerMethodReader.php
@@ -0,0 +1,243 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved;
+
+use Config\Routing;
+use ReflectionClass;
+use ReflectionMethod;
+
+/**
+ * Reads a controller and returns a list of auto route listing.
+ *
+ * @see \CodeIgniter\Commands\Utilities\Routes\AutoRouterImproved\ControllerMethodReaderTest
+ */
+final class ControllerMethodReader
+{
+ private readonly bool $translateURIDashes;
+ private readonly bool $translateUriToCamelCase;
+
+ /**
+ * @param string $namespace the default namespace
+ * @param list $httpMethods
+ */
+ public function __construct(
+ private readonly string $namespace,
+ private readonly array $httpMethods
+ ) {
+ $config = config(Routing::class);
+ $this->translateURIDashes = $config->translateURIDashes;
+ $this->translateUriToCamelCase = $config->translateUriToCamelCase;
+ }
+
+ /**
+ * Returns found route info in the controller.
+ *
+ * @param class-string $class
+ *
+ * @return list>
+ */
+ public function read(string $class, string $defaultController = 'Home', string $defaultMethod = 'index'): array
+ {
+ $reflection = new ReflectionClass($class);
+
+ if ($reflection->isAbstract()) {
+ return [];
+ }
+
+ $classname = $reflection->getName();
+ $classShortname = $reflection->getShortName();
+
+ $output = [];
+ $classInUri = $this->convertClassNameToUri($classname);
+
+ foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ $methodName = $method->getName();
+
+ foreach ($this->httpMethods as $httpVerb) {
+ if (str_starts_with($methodName, strtolower($httpVerb))) {
+ // Remove HTTP verb prefix.
+ $methodInUri = $this->convertMethodNameToUri($httpVerb, $methodName);
+
+ // Check if it is the default method.
+ if ($methodInUri === $defaultMethod) {
+ $routeForDefaultController = $this->getRouteForDefaultController(
+ $classShortname,
+ $defaultController,
+ $classInUri,
+ $classname,
+ $methodName,
+ $httpVerb,
+ $method
+ );
+
+ if ($routeForDefaultController !== []) {
+ // The controller is the default controller. It only
+ // has a route for the default method. Other methods
+ // will not be routed even if they exist.
+ $output = [...$output, ...$routeForDefaultController];
+
+ continue;
+ }
+
+ [$params, $routeParams] = $this->getParameters($method);
+
+ // Route for the default method.
+ $output[] = [
+ 'method' => $httpVerb,
+ 'route' => $classInUri,
+ 'route_params' => $routeParams,
+ 'handler' => '\\' . $classname . '::' . $methodName,
+ 'params' => $params,
+ ];
+
+ continue;
+ }
+
+ $route = $classInUri . '/' . $methodInUri;
+
+ [$params, $routeParams] = $this->getParameters($method);
+
+ // If it is the default controller, the method will not be
+ // routed.
+ if ($classShortname === $defaultController) {
+ $route = 'x ' . $route;
+ }
+
+ $output[] = [
+ 'method' => $httpVerb,
+ 'route' => $route,
+ 'route_params' => $routeParams,
+ 'handler' => '\\' . $classname . '::' . $methodName,
+ 'params' => $params,
+ ];
+ }
+ }
+ }
+
+ 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];
+ }
+
+ /**
+ * @param class-string $classname
+ *
+ * @return string URI path part from the folder(s) and controller
+ */
+ private function convertClassNameToUri(string $classname): string
+ {
+ // remove the namespace
+ $pattern = '/' . preg_quote($this->namespace, '/') . '/';
+ $class = ltrim(preg_replace($pattern, '', $classname), '\\');
+
+ $classParts = explode('\\', $class);
+ $classPath = '';
+
+ foreach ($classParts as $part) {
+ // make the first letter lowercase, because auto routing makes
+ // the URI path's first letter uppercase and search the controller
+ $classPath .= lcfirst($part) . '/';
+ }
+
+ $classUri = rtrim($classPath, '/');
+
+ return $this->translateToUri($classUri);
+ }
+
+ /**
+ * @return string URI path part from the method
+ */
+ private function convertMethodNameToUri(string $httpVerb, string $methodName): string
+ {
+ $methodUri = lcfirst(substr($methodName, strlen($httpVerb)));
+
+ return $this->translateToUri($methodUri);
+ }
+
+ /**
+ * @param string $string classname or method name
+ */
+ private function translateToUri(string $string): string
+ {
+ if ($this->translateUriToCamelCase) {
+ $string = strtolower(
+ preg_replace('/([a-z\d])([A-Z])/', '$1-$2', $string)
+ );
+ } elseif ($this->translateURIDashes) {
+ $string = str_replace('_', '-', $string);
+ }
+
+ return $string;
+ }
+
+ /**
+ * Gets a route for the default controller.
+ *
+ * @return list
+ */
+ private function getRouteForDefaultController(
+ string $classShortname,
+ string $defaultController,
+ string $uriByClass,
+ string $classname,
+ string $methodName,
+ string $httpVerb,
+ ReflectionMethod $method
+ ): array {
+ $output = [];
+
+ if ($classShortname === $defaultController) {
+ $pattern = '#' . preg_quote(lcfirst($defaultController), '#') . '\z#';
+ $routeWithoutController = rtrim(preg_replace($pattern, '', $uriByClass), '/');
+ $routeWithoutController = $routeWithoutController ?: '/';
+
+ [$params, $routeParams] = $this->getParameters($method);
+
+ if ($routeWithoutController === '/' && $routeParams !== '') {
+ $routeWithoutController = '';
+ }
+
+ $output[] = [
+ 'method' => $httpVerb,
+ 'route' => $routeWithoutController,
+ 'route_params' => $routeParams,
+ 'handler' => '\\' . $classname . '::' . $methodName,
+ 'params' => $params,
+ ];
+ }
+
+ return $output;
+ }
+}
diff --git a/system/Commands/Utilities/Routes/ControllerFinder.php b/system/Commands/Utilities/Routes/ControllerFinder.php
new file mode 100644
index 0000000..71de168
--- /dev/null
+++ b/system/Commands/Utilities/Routes/ControllerFinder.php
@@ -0,0 +1,74 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities\Routes;
+
+use CodeIgniter\Autoloader\FileLocatorInterface;
+
+/**
+ * Finds all controllers in a namespace for auto route listing.
+ *
+ * @see \CodeIgniter\Commands\Utilities\Routes\ControllerFinderTest
+ */
+final class ControllerFinder
+{
+ private readonly FileLocatorInterface $locator;
+
+ /**
+ * @param string $namespace namespace to search
+ */
+ public function __construct(
+ private readonly string $namespace
+ ) {
+ $this->locator = service('locator');
+ }
+
+ /**
+ * @return list
+ */
+ public function find(): array
+ {
+ $nsArray = explode('\\', trim($this->namespace, '\\'));
+ $count = count($nsArray);
+ $ns = '';
+ $files = [];
+
+ for ($i = 0; $i < $count; $i++) {
+ $ns .= '\\' . array_shift($nsArray);
+ $path = implode('\\', $nsArray);
+
+ $files = $this->locator->listNamespaceFiles($ns, $path);
+
+ if ($files !== []) {
+ break;
+ }
+ }
+
+ $classes = [];
+
+ foreach ($files as $file) {
+ if (\is_file($file)) {
+ $classnameOrEmpty = $this->locator->getClassname($file);
+
+ if ($classnameOrEmpty !== '') {
+ /** @var class-string $classname */
+ $classname = $classnameOrEmpty;
+
+ $classes[] = $classname;
+ }
+ }
+ }
+
+ return $classes;
+ }
+}
diff --git a/system/Commands/Utilities/Routes/ControllerMethodReader.php b/system/Commands/Utilities/Routes/ControllerMethodReader.php
new file mode 100644
index 0000000..c443b66
--- /dev/null
+++ b/system/Commands/Utilities/Routes/ControllerMethodReader.php
@@ -0,0 +1,171 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities\Routes;
+
+use ReflectionClass;
+use ReflectionMethod;
+
+/**
+ * Reads a controller and returns a list of auto route listing.
+ *
+ * @see \CodeIgniter\Commands\Utilities\Routes\ControllerMethodReaderTest
+ */
+final class ControllerMethodReader
+{
+ /**
+ * @param string $namespace the default namespace
+ */
+ public function __construct(private readonly string $namespace)
+ {
+ }
+
+ /**
+ * @param class-string $class
+ *
+ * @return list
+ */
+ public function read(string $class, string $defaultController = 'Home', string $defaultMethod = 'index'): array
+ {
+ $reflection = new ReflectionClass($class);
+
+ if ($reflection->isAbstract()) {
+ return [];
+ }
+
+ $classname = $reflection->getName();
+ $classShortname = $reflection->getShortName();
+
+ $output = [];
+ $uriByClass = $this->getUriByClass($classname);
+
+ if ($this->hasRemap($reflection)) {
+ $methodName = '_remap';
+
+ $routeWithoutController = $this->getRouteWithoutController(
+ $classShortname,
+ $defaultController,
+ $uriByClass,
+ $classname,
+ $methodName
+ );
+ $output = [...$output, ...$routeWithoutController];
+
+ $output[] = [
+ 'route' => $uriByClass . '[/...]',
+ 'handler' => '\\' . $classname . '::' . $methodName,
+ ];
+
+ return $output;
+ }
+
+ foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ $methodName = $method->getName();
+
+ $route = $uriByClass . '/' . $methodName;
+
+ // Exclude BaseController and initController
+ // See system/Config/Routes.php
+ if (preg_match('#\AbaseController.*#', $route) === 1) {
+ continue;
+ }
+ if (preg_match('#.*/initController\z#', $route) === 1) {
+ continue;
+ }
+
+ if ($methodName === $defaultMethod) {
+ $routeWithoutController = $this->getRouteWithoutController(
+ $classShortname,
+ $defaultController,
+ $uriByClass,
+ $classname,
+ $methodName
+ );
+ $output = [...$output, ...$routeWithoutController];
+
+ $output[] = [
+ 'route' => $uriByClass,
+ 'handler' => '\\' . $classname . '::' . $methodName,
+ ];
+ }
+
+ $output[] = [
+ 'route' => $route . '[/...]',
+ 'handler' => '\\' . $classname . '::' . $methodName,
+ ];
+ }
+
+ return $output;
+ }
+
+ /**
+ * Whether the class has a _remap() method.
+ */
+ private function hasRemap(ReflectionClass $class): bool
+ {
+ if ($class->hasMethod('_remap')) {
+ $remap = $class->getMethod('_remap');
+
+ return $remap->isPublic();
+ }
+
+ return false;
+ }
+
+ /**
+ * @param class-string $classname
+ *
+ * @return string URI path part from the folder(s) and controller
+ */
+ private function getUriByClass(string $classname): string
+ {
+ // remove the namespace
+ $pattern = '/' . preg_quote($this->namespace, '/') . '/';
+ $class = ltrim(preg_replace($pattern, '', $classname), '\\');
+
+ $classParts = explode('\\', $class);
+ $classPath = '';
+
+ foreach ($classParts as $part) {
+ // make the first letter lowercase, because auto routing makes
+ // the URI path's first letter uppercase and search the controller
+ $classPath .= lcfirst($part) . '/';
+ }
+
+ return rtrim($classPath, '/');
+ }
+
+ /**
+ * Gets a route without default controller.
+ */
+ private function getRouteWithoutController(
+ string $classShortname,
+ string $defaultController,
+ string $uriByClass,
+ string $classname,
+ string $methodName
+ ): array {
+ if ($classShortname !== $defaultController) {
+ return [];
+ }
+
+ $pattern = '#' . preg_quote(lcfirst($defaultController), '#') . '\z#';
+ $routeWithoutController = rtrim(preg_replace($pattern, '', $uriByClass), '/');
+ $routeWithoutController = $routeWithoutController ?: '/';
+
+ return [[
+ 'route' => $routeWithoutController,
+ 'handler' => '\\' . $classname . '::' . $methodName,
+ ]];
+ }
+}
diff --git a/system/Commands/Utilities/Routes/FilterCollector.php b/system/Commands/Utilities/Routes/FilterCollector.php
new file mode 100644
index 0000000..002a529
--- /dev/null
+++ b/system/Commands/Utilities/Routes/FilterCollector.php
@@ -0,0 +1,117 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities\Routes;
+
+use CodeIgniter\Config\Services;
+use CodeIgniter\Filters\Filters;
+use CodeIgniter\HTTP\Method;
+use CodeIgniter\HTTP\Request;
+use CodeIgniter\Router\Router;
+use Config\Filters as FiltersConfig;
+
+/**
+ * Collects filters for a route.
+ *
+ * @see \CodeIgniter\Commands\Utilities\Routes\FilterCollectorTest
+ */
+final class FilterCollector
+{
+ public function __construct(
+ /**
+ * Whether to reset Defined Routes.
+ *
+ * If set to true, route filters are not found.
+ */
+ private readonly bool $resetRoutes = false
+ ) {
+ }
+
+ /**
+ * Returns filters for the URI
+ *
+ * @param string $method HTTP verb like `GET`,`POST` or `CLI`.
+ * @param string $uri URI path to find filters for
+ *
+ * @return array{before: list, after: list} array of filter alias or classname
+ */
+ public function get(string $method, string $uri): array
+ {
+ if ($method === strtolower($method)) {
+ @trigger_error(
+ 'Passing lowercase HTTP method "' . $method . '" is deprecated.'
+ . ' Use uppercase HTTP method like "' . strtoupper($method) . '".',
+ E_USER_DEPRECATED
+ );
+ }
+
+ /**
+ * @deprecated 4.5.0
+ * @TODO Remove this in the future.
+ */
+ $method = strtoupper($method);
+
+ if ($method === 'CLI') {
+ return [
+ 'before' => [],
+ 'after' => [],
+ ];
+ }
+
+ $request = Services::incomingrequest(null, false);
+ $request->setMethod($method);
+
+ $router = $this->createRouter($request);
+ $filters = $this->createFilters($request);
+
+ $finder = new FilterFinder($router, $filters);
+
+ return $finder->find($uri);
+ }
+
+ /**
+ * Returns Required Filters
+ *
+ * @return array{before: list, after: list} array of filter alias or classname
+ */
+ public function getRequiredFilters(): array
+ {
+ $request = Services::incomingrequest(null, false);
+ $request->setMethod(Method::GET);
+
+ $router = $this->createRouter($request);
+ $filters = $this->createFilters($request);
+
+ $finder = new FilterFinder($router, $filters);
+
+ return $finder->getRequiredFilters();
+ }
+
+ private function createRouter(Request $request): Router
+ {
+ $routes = service('routes');
+
+ if ($this->resetRoutes) {
+ $routes->resetRoutes();
+ }
+
+ return new Router($routes, $request);
+ }
+
+ private function createFilters(Request $request): Filters
+ {
+ $config = config(FiltersConfig::class);
+
+ return new Filters($config, $request, service('response'));
+ }
+}
diff --git a/system/Commands/Utilities/Routes/FilterFinder.php b/system/Commands/Utilities/Routes/FilterFinder.php
new file mode 100644
index 0000000..7971e5c
--- /dev/null
+++ b/system/Commands/Utilities/Routes/FilterFinder.php
@@ -0,0 +1,99 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities\Routes;
+
+use CodeIgniter\Exceptions\PageNotFoundException;
+use CodeIgniter\Filters\Filters;
+use CodeIgniter\HTTP\Exceptions\BadRequestException;
+use CodeIgniter\HTTP\Exceptions\RedirectException;
+use CodeIgniter\Router\Router;
+use Config\Feature;
+
+/**
+ * Finds filters.
+ *
+ * @see \CodeIgniter\Commands\Utilities\Routes\FilterFinderTest
+ */
+final class FilterFinder
+{
+ private readonly Router $router;
+ private readonly Filters $filters;
+
+ public function __construct(?Router $router = null, ?Filters $filters = null)
+ {
+ $this->router = $router ?? service('router');
+ $this->filters = $filters ?? service('filters');
+ }
+
+ private function getRouteFilters(string $uri): array
+ {
+ $this->router->handle($uri);
+
+ return $this->router->getFilters();
+ }
+
+ /**
+ * @param string $uri URI path to find filters for
+ *
+ * @return array{before: list, after: list} array of filter alias or classname
+ */
+ public function find(string $uri): array
+ {
+ $this->filters->reset();
+
+ // Add route filters
+ try {
+ $routeFilters = $this->getRouteFilters($uri);
+
+ $this->filters->enableFilters($routeFilters, 'before');
+
+ $oldFilterOrder = config(Feature::class)->oldFilterOrder ?? false;
+ if (! $oldFilterOrder) {
+ $routeFilters = array_reverse($routeFilters);
+ }
+
+ $this->filters->enableFilters($routeFilters, 'after');
+
+ $this->filters->initialize($uri);
+
+ return $this->filters->getFilters();
+ } catch (RedirectException) {
+ return [
+ 'before' => [],
+ 'after' => [],
+ ];
+ } catch (BadRequestException|PageNotFoundException) {
+ return [
+ 'before' => [''],
+ 'after' => [''],
+ ];
+ }
+ }
+
+ /**
+ * Returns Required Filters
+ *
+ * @return array{before: list, after:list}
+ */
+ public function getRequiredFilters(): array
+ {
+ [$requiredBefore] = $this->filters->getRequiredFilters('before');
+ [$requiredAfter] = $this->filters->getRequiredFilters('after');
+
+ return [
+ 'before' => $requiredBefore,
+ 'after' => $requiredAfter,
+ ];
+ }
+}
diff --git a/system/Commands/Utilities/Routes/SampleURIGenerator.php b/system/Commands/Utilities/Routes/SampleURIGenerator.php
new file mode 100644
index 0000000..45eb2f9
--- /dev/null
+++ b/system/Commands/Utilities/Routes/SampleURIGenerator.php
@@ -0,0 +1,73 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Commands\Utilities\Routes;
+
+use CodeIgniter\Router\RouteCollection;
+use Config\App;
+
+/**
+ * Generate a sample URI path from route key regex.
+ *
+ * @see \CodeIgniter\Commands\Utilities\Routes\SampleURIGeneratorTest
+ */
+final class SampleURIGenerator
+{
+ private readonly RouteCollection $routes;
+
+ /**
+ * Sample URI path for placeholder.
+ *
+ * @var array
+ */
+ private array $samples = [
+ 'any' => '123/abc',
+ 'segment' => 'abc_123',
+ 'alphanum' => 'abc123',
+ 'num' => '123',
+ 'alpha' => 'abc',
+ 'hash' => 'abc_123',
+ ];
+
+ public function __construct(?RouteCollection $routes = null)
+ {
+ $this->routes = $routes ?? service('routes');
+ }
+
+ /**
+ * @param string $routeKey route key regex
+ *
+ * @return string sample URI path
+ */
+ public function get(string $routeKey): string
+ {
+ $sampleUri = $routeKey;
+
+ if (str_contains($routeKey, '{locale}')) {
+ $sampleUri = str_replace(
+ '{locale}',
+ config(App::class)->defaultLocale,
+ $routeKey
+ );
+ }
+
+ foreach ($this->routes->getPlaceholders() as $placeholder => $regex) {
+ $sample = $this->samples[$placeholder] ?? '::unknown::';
+
+ $sampleUri = str_replace('(' . $regex . ')', $sample, $sampleUri);
+ }
+
+ // auto route
+ return str_replace('[/...]', '/1/2/3/4/5', $sampleUri);
+ }
+}
diff --git a/system/Common.php b/system/Common.php
new file mode 100644
index 0000000..49b3d28
--- /dev/null
+++ b/system/Common.php
@@ -0,0 +1,1259 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+use CodeIgniter\Cache\CacheInterface;
+use CodeIgniter\Config\BaseConfig;
+use CodeIgniter\Config\Factories;
+use CodeIgniter\Cookie\Cookie;
+use CodeIgniter\Cookie\CookieStore;
+use CodeIgniter\Cookie\Exceptions\CookieException;
+use CodeIgniter\Database\BaseConnection;
+use CodeIgniter\Database\ConnectionInterface;
+use CodeIgniter\Debug\Timer;
+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;
+use CodeIgniter\HTTP\ResponseInterface;
+use CodeIgniter\Model;
+use CodeIgniter\Session\Session;
+use CodeIgniter\Test\TestLogger;
+use Config\App;
+use Config\Database;
+use Config\DocTypes;
+use Config\Logger;
+use Config\Services;
+use Config\View;
+use Laminas\Escaper\Escaper;
+
+// Services Convenience Functions
+
+if (! function_exists('app_timezone')) {
+ /**
+ * Returns the timezone the application has been set to display
+ * dates in. This might be different than the timezone set
+ * at the server level, as you often want to stores dates in UTC
+ * and convert them on the fly for the user.
+ */
+ function app_timezone(): string
+ {
+ $config = config(App::class);
+
+ return $config->appTimezone;
+ }
+}
+
+if (! function_exists('cache')) {
+ /**
+ * A convenience method that provides access to the Cache
+ * object. If no parameter is provided, will return the object,
+ * otherwise, will attempt to return the cached value.
+ *
+ * Examples:
+ * cache()->save('foo', 'bar');
+ * $foo = cache('bar');
+ *
+ * @return array|bool|CacheInterface|float|int|object|string|null
+ * @phpstan-return ($key is null ? CacheInterface : array|bool|float|int|object|string|null)
+ */
+ function cache(?string $key = null)
+ {
+ $cache = service('cache');
+
+ // No params - return cache object
+ if ($key === null) {
+ return $cache;
+ }
+
+ // Still here? Retrieve the value.
+ return $cache->get($key);
+ }
+}
+
+if (! function_exists('clean_path')) {
+ /**
+ * A convenience method to clean paths for
+ * a nicer looking output. Useful for exception
+ * handling, error logging, etc.
+ */
+ function clean_path(string $path): string
+ {
+ // Resolve relative paths
+ try {
+ $path = realpath($path) ?: $path;
+ } catch (ErrorException|ValueError) {
+ $path = 'error file path: ' . urlencode($path);
+ }
+
+ return match (true) {
+ str_starts_with($path, APPPATH) => 'APPPATH' . DIRECTORY_SEPARATOR . substr($path, strlen(APPPATH)),
+ str_starts_with($path, SYSTEMPATH) => 'SYSTEMPATH' . DIRECTORY_SEPARATOR . substr($path, strlen(SYSTEMPATH)),
+ str_starts_with($path, FCPATH) => 'FCPATH' . DIRECTORY_SEPARATOR . substr($path, strlen(FCPATH)),
+ defined('VENDORPATH') && str_starts_with($path, VENDORPATH) => 'VENDORPATH' . DIRECTORY_SEPARATOR . substr($path, strlen(VENDORPATH)),
+ str_starts_with($path, ROOTPATH) => 'ROOTPATH' . DIRECTORY_SEPARATOR . substr($path, strlen(ROOTPATH)),
+ default => $path,
+ };
+ }
+}
+
+if (! function_exists('command')) {
+ /**
+ * Runs a single command.
+ * Input expected in a single string as would
+ * be used on the command line itself:
+ *
+ * > command('migrate:create SomeMigration');
+ *
+ * @return false|string
+ */
+ function command(string $command)
+ {
+ $runner = service('commands');
+ $regexString = '([^\s]+?)(?:\s|(? $arg) {
+ if (mb_strpos($arg, '-') !== 0) {
+ if ($optionValue) {
+ // if this was an option value, it was already
+ // included in the previous iteration
+ $optionValue = false;
+ } else {
+ // add to segments if not starting with '-'
+ // and not an option value
+ $params[] = $arg;
+ }
+
+ continue;
+ }
+
+ $arg = ltrim($arg, '-');
+ $value = null;
+
+ if (isset($args[$i + 1]) && mb_strpos($args[$i + 1], '-') !== 0) {
+ $value = $args[$i + 1];
+ $optionValue = true;
+ }
+
+ $params[$arg] = $value;
+ }
+
+ ob_start();
+ $runner->run($command, $params);
+
+ return ob_get_clean();
+ }
+}
+
+if (! function_exists('config')) {
+ /**
+ * More simple way of getting config instances from Factories
+ *
+ * @template ConfigTemplate of BaseConfig
+ *
+ * @param class-string|string $name
+ *
+ * @return ConfigTemplate|null
+ * @phpstan-return ($name is class-string ? ConfigTemplate : object|null)
+ */
+ function config(string $name, bool $getShared = true)
+ {
+ if ($getShared) {
+ return Factories::get('config', $name);
+ }
+
+ return Factories::config($name, ['getShared' => $getShared]);
+ }
+}
+
+if (! function_exists('cookie')) {
+ /**
+ * Simpler way to create a new Cookie instance.
+ *
+ * @param string $name Name of the cookie
+ * @param string $value Value of the cookie
+ * @param array $options Array of options to be passed to the cookie
+ *
+ * @throws CookieException
+ */
+ function cookie(string $name, string $value = '', array $options = []): Cookie
+ {
+ return new Cookie($name, $value, $options);
+ }
+}
+
+if (! function_exists('cookies')) {
+ /**
+ * Fetches the global `CookieStore` instance held by `Response`.
+ *
+ * @param list $cookies If `getGlobal` is false, this is passed to CookieStore's constructor
+ * @param bool $getGlobal If false, creates a new instance of CookieStore
+ */
+ function cookies(array $cookies = [], bool $getGlobal = true): CookieStore
+ {
+ if ($getGlobal) {
+ return service('response')->getCookieStore();
+ }
+
+ return new CookieStore($cookies);
+ }
+}
+
+if (! function_exists('csrf_token')) {
+ /**
+ * Returns the CSRF token name.
+ * Can be used in Views when building hidden inputs manually,
+ * or used in javascript vars when using APIs.
+ */
+ function csrf_token(): string
+ {
+ return service('security')->getTokenName();
+ }
+}
+
+if (! function_exists('csrf_header')) {
+ /**
+ * Returns the CSRF header name.
+ * Can be used in Views by adding it to the meta tag
+ * or used in javascript to define a header name when using APIs.
+ */
+ function csrf_header(): string
+ {
+ return service('security')->getHeaderName();
+ }
+}
+
+if (! function_exists('csrf_hash')) {
+ /**
+ * Returns the current hash value for the CSRF protection.
+ * Can be used in Views when building hidden inputs manually,
+ * or used in javascript vars for API usage.
+ */
+ function csrf_hash(): string
+ {
+ return service('security')->getHash();
+ }
+}
+
+if (! function_exists('csrf_field')) {
+ /**
+ * Generates a hidden input field for use within manually generated forms.
+ *
+ * @param non-empty-string|null $id
+ */
+ function csrf_field(?string $id = null): string
+ {
+ return '';
+ }
+}
+
+if (! function_exists('csrf_meta')) {
+ /**
+ * Generates a meta tag for use within javascript calls.
+ *
+ * @param non-empty-string|null $id
+ */
+ function csrf_meta(?string $id = null): string
+ {
+ return '';
+ }
+}
+
+if (! function_exists('csp_style_nonce')) {
+ /**
+ * Generates a nonce attribute for style tag.
+ */
+ function csp_style_nonce(): string
+ {
+ $csp = service('csp');
+
+ if (! $csp->enabled()) {
+ return '';
+ }
+
+ return 'nonce="' . $csp->getStyleNonce() . '"';
+ }
+}
+
+if (! function_exists('csp_script_nonce')) {
+ /**
+ * Generates a nonce attribute for script tag.
+ */
+ function csp_script_nonce(): string
+ {
+ $csp = service('csp');
+
+ if (! $csp->enabled()) {
+ return '';
+ }
+
+ return 'nonce="' . $csp->getScriptNonce() . '"';
+ }
+}
+
+if (! function_exists('db_connect')) {
+ /**
+ * Grabs a database connection and returns it to the user.
+ *
+ * This is a convenience wrapper for \Config\Database::connect()
+ * and supports the same parameters. Namely:
+ *
+ * When passing in $db, you may pass any of the following to connect:
+ * - group name
+ * - existing connection instance
+ * - array of database configuration values
+ *
+ * If $getShared === false then a new connection instance will be provided,
+ * otherwise it will all calls will return the same instance.
+ *
+ * @param array|ConnectionInterface|string|null $db
+ *
+ * @return BaseConnection
+ */
+ function db_connect($db = null, bool $getShared = true)
+ {
+ return Database::connect($db, $getShared);
+ }
+}
+
+if (! function_exists('env')) {
+ /**
+ * Allows user to retrieve values from the environment
+ * variables that have been set. Especially useful for
+ * retrieving values set from the .env file for
+ * use in config files.
+ *
+ * @param string|null $default
+ *
+ * @return bool|string|null
+ */
+ function env(string $key, $default = null)
+ {
+ $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
+
+ // Not found? Return the default value
+ if ($value === false) {
+ return $default;
+ }
+
+ // Handle any boolean values
+ return match (strtolower($value)) {
+ 'true' => true,
+ 'false' => false,
+ 'empty' => '',
+ 'null' => null,
+ default => $value,
+ };
+ }
+}
+
+if (! function_exists('esc')) {
+ /**
+ * Performs simple auto-escaping of data for security reasons.
+ * Might consider making this more complex at a later date.
+ *
+ * If $data is a string, then it simply escapes and returns it.
+ * If $data is an array, then it loops over it, escaping each
+ * 'value' of the key/value pairs.
+ *
+ * @param array|string $data
+ * @phpstan-param 'html'|'js'|'css'|'url'|'attr'|'raw' $context
+ * @param string|null $encoding Current encoding for escaping.
+ * If not UTF-8, we convert strings from this encoding
+ * pre-escaping and back to this encoding post-escaping.
+ *
+ * @return array|string
+ *
+ * @throws InvalidArgumentException
+ */
+ function esc($data, string $context = 'html', ?string $encoding = null)
+ {
+ $context = strtolower($context);
+
+ // Provide a way to NOT escape data since
+ // this could be called automatically by
+ // the View library.
+ if ($context === 'raw') {
+ return $data;
+ }
+
+ if (is_array($data)) {
+ foreach ($data as &$value) {
+ $value = esc($value, $context);
+ }
+ }
+
+ if (is_string($data)) {
+ if (! in_array($context, ['html', 'js', 'css', 'url', 'attr'], true)) {
+ throw new InvalidArgumentException('Invalid escape context provided.');
+ }
+
+ $method = $context === 'attr' ? 'escapeHtmlAttr' : 'escape' . ucfirst($context);
+
+ static $escaper;
+ if (! $escaper) {
+ $escaper = new Escaper($encoding);
+ }
+
+ if ($encoding && $escaper->getEncoding() !== $encoding) {
+ $escaper = new Escaper($encoding);
+ }
+
+ $data = $escaper->{$method}($data);
+ }
+
+ return $data;
+ }
+}
+
+if (! function_exists('force_https')) {
+ /**
+ * Used to force a page to be accessed in via HTTPS.
+ * Uses a standard redirect, plus will set the HSTS header
+ * for modern browsers that support, which gives best
+ * protection against man-in-the-middle attacks.
+ *
+ * @see https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security
+ *
+ * @param int $duration How long should the SSL header be set for? (in seconds)
+ * Defaults to 1 year.
+ *
+ * @throws HTTPException
+ * @throws RedirectException
+ */
+ function force_https(
+ int $duration = 31_536_000,
+ ?RequestInterface $request = null,
+ ?ResponseInterface $response = null
+ ): void {
+ $request ??= service('request');
+
+ if (! $request instanceof IncomingRequest) {
+ return;
+ }
+
+ $response ??= service('response');
+
+ if ((ENVIRONMENT !== 'testing' && (is_cli() || $request->isSecure()))
+ || $request->getServer('HTTPS') === 'test'
+ ) {
+ return; // @codeCoverageIgnore
+ }
+
+ // If the session status is active, we should regenerate
+ // the session ID for safety sake.
+ if (ENVIRONMENT !== 'testing' && session_status() === PHP_SESSION_ACTIVE) {
+ service('session')->regenerate(); // @codeCoverageIgnore
+ }
+
+ $uri = $request->getUri()->withScheme('https');
+
+ // Set an HSTS header
+ $response->setHeader('Strict-Transport-Security', 'max-age=' . $duration)
+ ->redirect((string) $uri)
+ ->setStatusCode(307)
+ ->setBody('')
+ ->getCookieStore()
+ ->clear();
+
+ throw new RedirectException($response);
+ }
+}
+
+if (! function_exists('function_usable')) {
+ /**
+ * Function usable
+ *
+ * Executes a function_exists() check, and if the Suhosin PHP
+ * extension is loaded - checks whether the function that is
+ * checked might be disabled in there as well.
+ *
+ * This is useful as function_exists() will return FALSE for
+ * functions disabled via the *disable_functions* php.ini
+ * setting, but not for *suhosin.executor.func.blacklist* and
+ * *suhosin.executor.disable_eval*. These settings will just
+ * terminate script execution if a disabled function is executed.
+ *
+ * The above described behavior turned out to be a bug in Suhosin,
+ * but even though a fix was committed for 0.9.34 on 2012-02-12,
+ * that version is yet to be released. This function will therefore
+ * be just temporary, but would probably be kept for a few years.
+ *
+ * @see http://www.hardened-php.net/suhosin/
+ *
+ * @param string $functionName Function to check for
+ *
+ * @return bool TRUE if the function exists and is safe to call,
+ * FALSE otherwise.
+ *
+ * @codeCoverageIgnore This is too exotic
+ */
+ function function_usable(string $functionName): bool
+ {
+ static $_suhosin_func_blacklist;
+
+ if (function_exists($functionName)) {
+ if (! isset($_suhosin_func_blacklist)) {
+ $_suhosin_func_blacklist = extension_loaded('suhosin') ? explode(',', trim(ini_get('suhosin.executor.func.blacklist'))) : [];
+ }
+
+ return ! in_array($functionName, $_suhosin_func_blacklist, true);
+ }
+
+ return false;
+ }
+}
+
+if (! function_exists('helper')) {
+ /**
+ * Loads a helper file into memory. Supports namespaced helpers,
+ * both in and out of the 'Helpers' directory of a namespaced directory.
+ *
+ * Will load ALL helpers of the matching name, in the following order:
+ * 1. app/Helpers
+ * 2. {namespace}/Helpers
+ * 3. system/Helpers
+ *
+ * @param array|string $filenames
+ *
+ * @throws FileNotFoundException
+ */
+ function helper($filenames): void
+ {
+ static $loaded = [];
+
+ $loader = service('locator');
+
+ if (! is_array($filenames)) {
+ $filenames = [$filenames];
+ }
+
+ // Store a list of all files to include...
+ $includes = [];
+
+ foreach ($filenames as $filename) {
+ // Store our system and application helper
+ // versions so that we can control the load ordering.
+ $systemHelper = null;
+ $appHelper = null;
+ $localIncludes = [];
+
+ if (! str_contains($filename, '_helper')) {
+ $filename .= '_helper';
+ }
+
+ // Check if this helper has already been loaded
+ if (in_array($filename, $loaded, true)) {
+ continue;
+ }
+
+ // If the file is namespaced, we'll just grab that
+ // file and not search for any others
+ if (str_contains($filename, '\\')) {
+ $path = $loader->locateFile($filename, 'Helpers');
+
+ if (empty($path)) {
+ throw FileNotFoundException::forFileNotFound($filename);
+ }
+
+ $includes[] = $path;
+ $loaded[] = $filename;
+ } else {
+ // No namespaces, so search in all available locations
+ $paths = $loader->search('Helpers/' . $filename);
+
+ foreach ($paths as $path) {
+ if (str_starts_with($path, APPPATH . 'Helpers' . DIRECTORY_SEPARATOR)) {
+ $appHelper = $path;
+ } elseif (str_starts_with($path, SYSTEMPATH . 'Helpers' . DIRECTORY_SEPARATOR)) {
+ $systemHelper = $path;
+ } else {
+ $localIncludes[] = $path;
+ $loaded[] = $filename;
+ }
+ }
+
+ // App-level helpers should override all others
+ if (! empty($appHelper)) {
+ $includes[] = $appHelper;
+ $loaded[] = $filename;
+ }
+
+ // All namespaced files get added in next
+ $includes = [...$includes, ...$localIncludes];
+
+ // And the system default one should be added in last.
+ if (! empty($systemHelper)) {
+ $includes[] = $systemHelper;
+ $loaded[] = $filename;
+ }
+ }
+ }
+
+ // Now actually include all of the files
+ foreach ($includes as $path) {
+ include_once $path;
+ }
+ }
+}
+
+if (! function_exists('is_cli')) {
+ /**
+ * Check if PHP was invoked from the command line.
+ *
+ * @codeCoverageIgnore Cannot be tested fully as PHPUnit always run in php-cli
+ */
+ function is_cli(): bool
+ {
+ if (in_array(PHP_SAPI, ['cli', 'phpdbg'], true)) {
+ return true;
+ }
+
+ // PHP_SAPI could be 'cgi-fcgi', 'fpm-fcgi'.
+ // See https://github.com/codeigniter4/CodeIgniter4/pull/5393
+ return ! isset($_SERVER['REMOTE_ADDR']) && ! isset($_SERVER['REQUEST_METHOD']);
+ }
+}
+
+if (! function_exists('is_really_writable')) {
+ /**
+ * Tests for file writability
+ *
+ * is_writable() returns TRUE on Windows servers when you really can't write to
+ * the file, based on the read-only attribute. is_writable() is also unreliable
+ * on Unix servers if safe_mode is on.
+ *
+ * @see https://bugs.php.net/bug.php?id=54709
+ *
+ * @throws Exception
+ *
+ * @codeCoverageIgnore Not practical to test, as travis runs on linux
+ */
+ function is_really_writable(string $file): bool
+ {
+ // If we're on a Unix server we call is_writable
+ if (! is_windows()) {
+ return is_writable($file);
+ }
+
+ /* For Windows servers and safe_mode "on" installations we'll actually
+ * write a file then read it. Bah...
+ */
+ if (is_dir($file)) {
+ $file = rtrim($file, '/') . '/' . bin2hex(random_bytes(16));
+ if (($fp = @fopen($file, 'ab')) === false) {
+ return false;
+ }
+
+ fclose($fp);
+ @chmod($file, 0777);
+ @unlink($file);
+
+ return true;
+ }
+
+ if (! is_file($file) || ($fp = @fopen($file, 'ab')) === false) {
+ return false;
+ }
+
+ fclose($fp);
+
+ return true;
+ }
+}
+
+if (! function_exists('is_windows')) {
+ /**
+ * Detect if platform is running in Windows.
+ */
+ function is_windows(?bool $mock = null): bool
+ {
+ static $mocked;
+
+ if (func_num_args() === 1) {
+ $mocked = $mock;
+ }
+
+ return $mocked ?? DIRECTORY_SEPARATOR === '\\';
+ }
+}
+
+if (! function_exists('lang')) {
+ /**
+ * A convenience method to translate a string or array of them and format
+ * the result with the intl extension's MessageFormatter.
+ *
+ * @return list|string
+ */
+ function lang(string $line, array $args = [], ?string $locale = null)
+ {
+ $language = service('language');
+
+ // Get active locale
+ $activeLocale = $language->getLocale();
+
+ if ($locale && $locale !== $activeLocale) {
+ $language->setLocale($locale);
+ }
+
+ $lines = $language->getLine($line, $args);
+
+ if ($locale && $locale !== $activeLocale) {
+ // Reset to active locale
+ $language->setLocale($activeLocale);
+ }
+
+ return $lines;
+ }
+}
+
+if (! function_exists('log_message')) {
+ /**
+ * A convenience/compatibility method for logging events through
+ * the Log system.
+ *
+ * Allowed log levels are:
+ * - emergency
+ * - alert
+ * - critical
+ * - error
+ * - warning
+ * - notice
+ * - info
+ * - debug
+ */
+ function log_message(string $level, string $message, array $context = []): void
+ {
+ // When running tests, we want to always ensure that the
+ // TestLogger is running, which provides utilities for
+ // for asserting that logs were called in the test code.
+ if (ENVIRONMENT === 'testing') {
+ $logger = new TestLogger(new Logger());
+
+ $logger->log($level, $message, $context);
+
+ return;
+ }
+
+ service('logger')->log($level, $message, $context); // @codeCoverageIgnore
+ }
+}
+
+if (! function_exists('model')) {
+ /**
+ * More simple way of getting model instances from Factories
+ *
+ * @template ModelTemplate of Model
+ *
+ * @param class-string|string $name
+ *
+ * @return ModelTemplate|null
+ * @phpstan-return ($name is class-string ? ModelTemplate : object|null)
+ */
+ function model(string $name, bool $getShared = true, ?ConnectionInterface &$conn = null)
+ {
+ return Factories::models($name, ['getShared' => $getShared], $conn);
+ }
+}
+
+if (! function_exists('old')) {
+ /**
+ * Provides access to "old input" that was set in the session
+ * during a redirect()->withInput().
+ *
+ * @param string|null $default
+ * @param false|string $escape
+ * @phpstan-param false|'attr'|'css'|'html'|'js'|'raw'|'url' $escape
+ *
+ * @return array|string|null
+ */
+ function old(string $key, $default = null, $escape = 'html')
+ {
+ // Ensure the session is loaded
+ if (session_status() === PHP_SESSION_NONE && ENVIRONMENT !== 'testing') {
+ session(); // @codeCoverageIgnore
+ }
+
+ $request = service('request');
+
+ $value = $request->getOldInput($key);
+
+ // Return the default value if nothing
+ // found in the old input.
+ if ($value === null) {
+ return $default;
+ }
+
+ return $escape === false ? $value : esc($value, $escape);
+ }
+}
+
+if (! function_exists('redirect')) {
+ /**
+ * Convenience method that works with the current global $request and
+ * $router instances to redirect using named/reverse-routed routes
+ * to determine the URL to go to.
+ *
+ * If more control is needed, you must use $response->redirect explicitly.
+ *
+ * @param non-empty-string|null $route Route name or Controller::method
+ */
+ function redirect(?string $route = null): RedirectResponse
+ {
+ $response = service('redirectresponse');
+
+ if ($route !== null) {
+ return $response->route($route);
+ }
+
+ return $response;
+ }
+}
+
+if (! function_exists('_solidus')) {
+ /**
+ * Generates the solidus character (`/`) depending on the HTML5 compatibility flag in `Config\DocTypes`
+ *
+ * @param DocTypes|null $docTypesConfig New config. For testing purpose only.
+ *
+ * @internal
+ */
+ function _solidus(?DocTypes $docTypesConfig = null): string
+ {
+ static $docTypes = null;
+
+ if ($docTypesConfig !== null) {
+ $docTypes = $docTypesConfig;
+ }
+
+ $docTypes ??= new DocTypes();
+
+ if ($docTypes->html5 ?? false) {
+ return '';
+ }
+
+ return ' /';
+ }
+}
+
+if (! function_exists('remove_invisible_characters')) {
+ /**
+ * Remove Invisible Characters
+ *
+ * This prevents sandwiching null characters
+ * between ascii characters, like Java\0script.
+ */
+ function remove_invisible_characters(string $str, bool $urlEncoded = true): string
+ {
+ $nonDisplayables = [];
+
+ // every control character except newline (dec 10),
+ // carriage return (dec 13) and horizontal tab (dec 09)
+ if ($urlEncoded) {
+ $nonDisplayables[] = '/%0[0-8bcef]/'; // url encoded 00-08, 11, 12, 14, 15
+ $nonDisplayables[] = '/%1[0-9a-f]/'; // url encoded 16-31
+ }
+
+ $nonDisplayables[] = '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/S'; // 00-08, 11, 12, 14-31, 127
+
+ do {
+ $str = preg_replace($nonDisplayables, '', $str, -1, $count);
+ } while ($count);
+
+ return $str;
+ }
+}
+
+if (! function_exists('request')) {
+ /**
+ * Returns the shared Request.
+ *
+ * @return CLIRequest|IncomingRequest
+ */
+ function request()
+ {
+ return service('request');
+ }
+}
+
+if (! function_exists('response')) {
+ /**
+ * Returns the shared Response.
+ */
+ function response(): ResponseInterface
+ {
+ return service('response');
+ }
+}
+
+if (! function_exists('route_to')) {
+ /**
+ * Given a route name or controller/method string and any params,
+ * will attempt to build the relative URL to the
+ * matching route.
+ *
+ * NOTE: This requires the controller/method to
+ * have a route defined in the routes Config file.
+ *
+ * @param string $method Route name or Controller::method
+ * @param int|string ...$params One or more parameters to be passed to the route.
+ * The last parameter allows you to set the locale.
+ *
+ * @return false|string The route (URI path relative to baseURL) or false if not found.
+ */
+ function route_to(string $method, ...$params)
+ {
+ return service('routes')->reverseRoute($method, ...$params);
+ }
+}
+
+if (! function_exists('session')) {
+ /**
+ * A convenience method for accessing the session instance,
+ * or an item that has been set in the session.
+ *
+ * Examples:
+ * session()->set('foo', 'bar');
+ * $foo = session('bar');
+ *
+ * @return array|bool|float|int|object|Session|string|null
+ * @phpstan-return ($val is null ? Session : array|bool|float|int|object|string|null)
+ */
+ function session(?string $val = null)
+ {
+ $session = service('session');
+
+ // Returning a single item?
+ if (is_string($val)) {
+ return $session->get($val);
+ }
+
+ return $session;
+ }
+}
+
+if (! function_exists('service')) {
+ /**
+ * Allows cleaner access to the Services Config file.
+ * Always returns a SHARED instance of the class, so
+ * calling the function multiple times should always
+ * return the same instance.
+ *
+ * These are equal:
+ * - $timer = service('timer')
+ * - $timer = \CodeIgniter\Config\Services::timer();
+ *
+ * @param array|bool|float|int|object|string|null ...$params
+ */
+ function service(string $name, ...$params): ?object
+ {
+ if ($params === []) {
+ return Services::get($name);
+ }
+
+ return Services::$name(...$params);
+ }
+}
+
+if (! function_exists('single_service')) {
+ /**
+ * Always returns a new instance of the class.
+ *
+ * @param array|bool|float|int|object|string|null ...$params
+ */
+ function single_service(string $name, ...$params): ?object
+ {
+ $service = Services::serviceExists($name);
+
+ if ($service === null) {
+ // The service is not defined anywhere so just return.
+ return null;
+ }
+
+ $method = new ReflectionMethod($service, $name);
+ $count = $method->getNumberOfParameters();
+ $mParam = $method->getParameters();
+
+ if ($count === 1) {
+ // This service needs only one argument, which is the shared
+ // instance flag, so let's wrap up and pass false here.
+ return $service::$name(false);
+ }
+
+ // Fill in the params with the defaults, but stop before the last
+ for ($startIndex = count($params); $startIndex <= $count - 2; $startIndex++) {
+ $params[$startIndex] = $mParam[$startIndex]->getDefaultValue();
+ }
+
+ // Ensure the last argument will not create a shared instance
+ $params[$count - 1] = false;
+
+ return $service::$name(...$params);
+ }
+}
+
+if (! function_exists('slash_item')) {
+ // Unlike CI3, this function is placed here because
+ // it's not a config, or part of a config.
+ /**
+ * Fetch a config file item with slash appended (if not empty)
+ *
+ * @param string $item Config item name
+ *
+ * @return string|null The configuration item or NULL if
+ * the item doesn't exist
+ */
+ function slash_item(string $item): ?string
+ {
+ $config = config(App::class);
+
+ if (! property_exists($config, $item)) {
+ return null;
+ }
+
+ $configItem = $config->{$item};
+
+ if (! is_scalar($configItem)) {
+ throw new RuntimeException(sprintf(
+ 'Cannot convert "%s::$%s" of type "%s" to type "string".',
+ App::class,
+ $item,
+ gettype($configItem)
+ ));
+ }
+
+ $configItem = trim((string) $configItem);
+
+ if ($configItem === '') {
+ return $configItem;
+ }
+
+ return rtrim($configItem, '/') . '/';
+ }
+}
+
+if (! function_exists('stringify_attributes')) {
+ /**
+ * Stringify attributes for use in HTML tags.
+ *
+ * Helper function used to convert a string, array, or object
+ * of attributes to a string.
+ *
+ * @param array|object|string $attributes string, array, object that can be cast to array
+ */
+ function stringify_attributes($attributes, bool $js = false): string
+ {
+ $atts = '';
+
+ if (empty($attributes)) {
+ return $atts;
+ }
+
+ if (is_string($attributes)) {
+ return ' ' . $attributes;
+ }
+
+ $attributes = (array) $attributes;
+
+ foreach ($attributes as $key => $val) {
+ $atts .= ($js) ? $key . '=' . esc($val, 'js') . ',' : ' ' . $key . '="' . esc($val) . '"';
+ }
+
+ return rtrim($atts, ',');
+ }
+}
+
+if (! function_exists('timer')) {
+ /**
+ * A convenience method for working with the timer.
+ * If no parameter is passed, it will return the timer instance.
+ * If callable is passed, it measures time of callable and
+ * returns its return value if any.
+ * Otherwise will start or stop the timer intelligently.
+ *
+ * @param non-empty-string|null $name
+ * @param (callable(): mixed)|null $callable
+ *
+ * @return mixed|Timer
+ * @phpstan-return ($name is null ? Timer : ($callable is (callable(): mixed) ? mixed : Timer))
+ */
+ function timer(?string $name = null, ?callable $callable = null)
+ {
+ $timer = service('timer');
+
+ if ($name === null) {
+ return $timer;
+ }
+
+ if ($callable !== null) {
+ return $timer->record($name, $callable);
+ }
+
+ if ($timer->has($name)) {
+ return $timer->stop($name);
+ }
+
+ return $timer->start($name);
+ }
+}
+
+if (! function_exists('view')) {
+ /**
+ * Grabs the current RendererInterface-compatible class
+ * and tells it to render the specified view. Simply provides
+ * a convenience method that can be used in Controllers,
+ * libraries, and routed closures.
+ *
+ * NOTE: Does not provide any escaping of the data, so that must
+ * all be handled manually by the developer.
+ *
+ * @param array $options Options for saveData or third-party extensions.
+ */
+ function view(string $name, array $data = [], array $options = []): string
+ {
+ $renderer = service('renderer');
+
+ $config = config(View::class);
+ $saveData = $config->saveData;
+
+ if (array_key_exists('saveData', $options)) {
+ $saveData = (bool) $options['saveData'];
+ unset($options['saveData']);
+ }
+
+ return $renderer->setData($data, 'raw')->render($name, $options, $saveData);
+ }
+}
+
+if (! function_exists('view_cell')) {
+ /**
+ * View cells are used within views to insert HTML chunks that are managed
+ * by other classes.
+ *
+ * @param array|string|null $params
+ *
+ * @throws ReflectionException
+ */
+ function view_cell(string $library, $params = null, int $ttl = 0, ?string $cacheName = null): string
+ {
+ return service('viewcell')
+ ->render($library, $params, $ttl, $cacheName);
+ }
+}
+
+/**
+ * These helpers come from Laravel so will not be
+ * re-tested and can be ignored safely.
+ *
+ * @see https://github.com/laravel/framework/blob/8.x/src/Illuminate/Support/helpers.php
+ */
+if (! function_exists('class_basename')) {
+ /**
+ * Get the class "basename" of the given object / class.
+ *
+ * @param object|string $class
+ *
+ * @return string
+ *
+ * @codeCoverageIgnore
+ */
+ function class_basename($class)
+ {
+ $class = is_object($class) ? $class::class : $class;
+
+ return basename(str_replace('\\', '/', $class));
+ }
+}
+
+if (! function_exists('class_uses_recursive')) {
+ /**
+ * Returns all traits used by a class, its parent classes and trait of their traits.
+ *
+ * @param object|string $class
+ *
+ * @return array
+ *
+ * @codeCoverageIgnore
+ */
+ function class_uses_recursive($class)
+ {
+ if (is_object($class)) {
+ $class = $class::class;
+ }
+
+ $results = [];
+
+ foreach (array_reverse(class_parents($class)) + [$class => $class] as $class) {
+ $results += trait_uses_recursive($class);
+ }
+
+ return array_unique($results);
+ }
+}
+
+if (! function_exists('trait_uses_recursive')) {
+ /**
+ * Returns all traits used by a trait and its traits.
+ *
+ * @param string $trait
+ *
+ * @return array
+ *
+ * @codeCoverageIgnore
+ */
+ function trait_uses_recursive($trait)
+ {
+ $traits = class_uses($trait) ?: [];
+
+ foreach ($traits as $trait) {
+ $traits += trait_uses_recursive($trait);
+ }
+
+ return $traits;
+ }
+}
diff --git a/system/ComposerScripts.php b/system/ComposerScripts.php
new file mode 100644
index 0000000..661247d
--- /dev/null
+++ b/system/ComposerScripts.php
@@ -0,0 +1,174 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter;
+
+use FilesystemIterator;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use SplFileInfo;
+
+/**
+ * This class is used by Composer during installs and updates
+ * to move files to locations within the system folder so that end-users
+ * do not need to use Composer to install a package, but can simply
+ * download.
+ *
+ * @codeCoverageIgnore
+ *
+ * @internal
+ */
+final class ComposerScripts
+{
+ /**
+ * Path to the ThirdParty directory.
+ */
+ private static string $path = __DIR__ . '/ThirdParty/';
+
+ /**
+ * Direct dependencies of CodeIgniter to copy
+ * contents to `system/ThirdParty/`.
+ *
+ * @var array>
+ */
+ private static array $dependencies = [
+ 'kint-src' => [
+ 'license' => __DIR__ . '/../vendor/kint-php/kint/LICENSE',
+ 'from' => __DIR__ . '/../vendor/kint-php/kint/src/',
+ 'to' => __DIR__ . '/ThirdParty/Kint/',
+ ],
+ 'kint-resources' => [
+ 'from' => __DIR__ . '/../vendor/kint-php/kint/resources/',
+ 'to' => __DIR__ . '/ThirdParty/Kint/resources/',
+ ],
+ 'escaper' => [
+ 'license' => __DIR__ . '/../vendor/laminas/laminas-escaper/LICENSE.md',
+ 'from' => __DIR__ . '/../vendor/laminas/laminas-escaper/src/',
+ 'to' => __DIR__ . '/ThirdParty/Escaper/',
+ ],
+ 'psr-log' => [
+ 'license' => __DIR__ . '/../vendor/psr/log/LICENSE',
+ 'from' => __DIR__ . '/../vendor/psr/log/src/',
+ 'to' => __DIR__ . '/ThirdParty/PSR/Log/',
+ ],
+ ];
+
+ /**
+ * This static method is called by Composer after every update event,
+ * i.e., `composer install`, `composer update`, `composer remove`.
+ */
+ public static function postUpdate()
+ {
+ self::recursiveDelete(self::$path);
+
+ foreach (self::$dependencies as $key => $dependency) {
+ // Kint may be removed.
+ if (! is_dir($dependency['from']) && str_starts_with($key, 'kint')) {
+ continue;
+ }
+
+ self::recursiveMirror($dependency['from'], $dependency['to']);
+
+ if (isset($dependency['license'])) {
+ $license = basename($dependency['license']);
+ copy($dependency['license'], $dependency['to'] . '/' . $license);
+ }
+ }
+
+ self::copyKintInitFiles();
+ }
+
+ /**
+ * Recursively remove the contents of the previous `system/ThirdParty`.
+ */
+ private static function recursiveDelete(string $directory): void
+ {
+ if (! is_dir($directory)) {
+ echo sprintf('Cannot recursively delete "%s" as it does not exist.', $directory) . PHP_EOL;
+
+ return;
+ }
+
+ /** @var SplFileInfo $file */
+ foreach (new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator(rtrim($directory, '\\/'), FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ ) as $file) {
+ $path = $file->getPathname();
+
+ if ($file->isDir()) {
+ @rmdir($path);
+ } else {
+ @unlink($path);
+ }
+ }
+ }
+
+ /**
+ * Recursively copy the files and directories of the origin directory
+ * into the target directory, i.e. "mirror" its contents.
+ */
+ private static function recursiveMirror(string $originDir, string $targetDir): void
+ {
+ $originDir = rtrim($originDir, '\\/');
+ $targetDir = rtrim($targetDir, '\\/');
+
+ if (! is_dir($originDir)) {
+ echo sprintf('The origin directory "%s" was not found.', $originDir);
+
+ exit(1);
+ }
+
+ if (is_dir($targetDir)) {
+ echo sprintf('The target directory "%s" is existing. Run %s::recursiveDelete(\'%s\') first.', $targetDir, self::class, $targetDir);
+
+ exit(1);
+ }
+
+ if (! @mkdir($targetDir, 0755, true)) {
+ echo sprintf('Cannot create the target directory: "%s"', $targetDir) . PHP_EOL;
+
+ exit(1);
+ }
+
+ $dirLen = strlen($originDir);
+
+ /** @var SplFileInfo $file */
+ foreach (new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($originDir, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::SELF_FIRST
+ ) as $file) {
+ $origin = $file->getPathname();
+ $target = $targetDir . substr($origin, $dirLen);
+
+ if ($file->isDir()) {
+ @mkdir($target, 0755);
+ } else {
+ @copy($origin, $target);
+ }
+ }
+ }
+
+ /**
+ * Copy Kint's init files into `system/ThirdParty/Kint/`
+ */
+ private static function copyKintInitFiles(): void
+ {
+ $originDir = self::$dependencies['kint-src']['from'] . '../';
+ $targetDir = self::$dependencies['kint-src']['to'];
+
+ foreach (['init.php', 'init_helpers.php'] as $kintInit) {
+ @copy($originDir . $kintInit, $targetDir . $kintInit);
+ }
+ }
+}
diff --git a/system/Config/AutoloadConfig.php b/system/Config/AutoloadConfig.php
new file mode 100644
index 0000000..0a99cdb
--- /dev/null
+++ b/system/Config/AutoloadConfig.php
@@ -0,0 +1,153 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Config;
+
+use Laminas\Escaper\Escaper;
+use Laminas\Escaper\Exception\ExceptionInterface;
+use Laminas\Escaper\Exception\InvalidArgumentException as EscaperInvalidArgumentException;
+use Laminas\Escaper\Exception\RuntimeException;
+use Psr\Log\AbstractLogger;
+use Psr\Log\InvalidArgumentException;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use Psr\Log\LoggerInterface;
+use Psr\Log\LoggerTrait;
+use Psr\Log\LogLevel;
+use Psr\Log\NullLogger;
+
+/**
+ * AUTOLOADER CONFIGURATION
+ *
+ * This file defines the namespaces and class maps so the Autoloader
+ * can find the files as needed.
+ */
+class AutoloadConfig
+{
+ /**
+ * -------------------------------------------------------------------
+ * Namespaces
+ * -------------------------------------------------------------------
+ * This maps the locations of any namespaces in your application to
+ * their location on the file system. These are used by the autoloader
+ * to locate files the first time they have been instantiated.
+ *
+ * The '/app' and '/system' directories are already mapped for you.
+ * you may change the name of the 'App' namespace if you wish,
+ * but this should be done prior to creating any namespaced classes,
+ * else you will need to modify all of those classes for this to work.
+ *
+ * @var array|string>
+ */
+ public $psr4 = [];
+
+ /**
+ * -------------------------------------------------------------------
+ * Class Map
+ * -------------------------------------------------------------------
+ * The class map provides a map of class names and their exact
+ * location on the drive. Classes loaded in this manner will have
+ * slightly faster performance because they will not have to be
+ * searched for within one or more directories as they would if they
+ * were being autoloaded through a namespace.
+ *
+ * @var array
+ */
+ public $classmap = [];
+
+ /**
+ * -------------------------------------------------------------------
+ * Files
+ * -------------------------------------------------------------------
+ * The files array provides a list of paths to __non-class__ files
+ * that will be autoloaded. This can be useful for bootstrap operations
+ * or for loading functions.
+ *
+ * @var list
+ */
+ public $files = [];
+
+ /**
+ * -------------------------------------------------------------------
+ * Namespaces
+ * -------------------------------------------------------------------
+ * This maps the locations of any namespaces in your application to
+ * their location on the file system. These are used by the autoloader
+ * to locate files the first time they have been instantiated.
+ *
+ * Do not change the name of the CodeIgniter namespace or your application
+ * will break.
+ *
+ * @var array
+ */
+ protected $corePsr4 = [
+ 'CodeIgniter' => SYSTEMPATH,
+ 'Config' => APPPATH . 'Config',
+ ];
+
+ /**
+ * -------------------------------------------------------------------
+ * Class Map
+ * -------------------------------------------------------------------
+ * The class map provides a map of class names and their exact
+ * location on the drive. Classes loaded in this manner will have
+ * slightly faster performance because they will not have to be
+ * searched for within one or more directories as they would if they
+ * were being autoloaded through a namespace.
+ *
+ * @var array
+ */
+ protected $coreClassmap = [
+ AbstractLogger::class => SYSTEMPATH . 'ThirdParty/PSR/Log/AbstractLogger.php',
+ InvalidArgumentException::class => SYSTEMPATH . 'ThirdParty/PSR/Log/InvalidArgumentException.php',
+ LoggerAwareInterface::class => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerAwareInterface.php',
+ LoggerAwareTrait::class => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerAwareTrait.php',
+ LoggerInterface::class => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerInterface.php',
+ LoggerTrait::class => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerTrait.php',
+ LogLevel::class => SYSTEMPATH . 'ThirdParty/PSR/Log/LogLevel.php',
+ NullLogger::class => SYSTEMPATH . 'ThirdParty/PSR/Log/NullLogger.php',
+ ExceptionInterface::class => SYSTEMPATH . 'ThirdParty/Escaper/Exception/ExceptionInterface.php',
+ EscaperInvalidArgumentException::class => SYSTEMPATH . 'ThirdParty/Escaper/Exception/InvalidArgumentException.php',
+ RuntimeException::class => SYSTEMPATH . 'ThirdParty/Escaper/Exception/RuntimeException.php',
+ Escaper::class => SYSTEMPATH . 'ThirdParty/Escaper/Escaper.php',
+ ];
+
+ /**
+ * -------------------------------------------------------------------
+ * Core Files
+ * -------------------------------------------------------------------
+ * List of files from the framework to be autoloaded early.
+ *
+ * @var array
+ */
+ protected $coreFiles = [];
+
+ /**
+ * Constructor.
+ *
+ * Merge the built-in and developer-configured psr4 and classmap,
+ * with preference to the developer ones.
+ */
+ public function __construct()
+ {
+ if (isset($_SERVER['CI_ENVIRONMENT']) && $_SERVER['CI_ENVIRONMENT'] === 'testing') {
+ $this->psr4['Tests\Support'] = SUPPORTPATH;
+ $this->classmap['CodeIgniter\Log\TestLogger'] = SYSTEMPATH . 'Test/TestLogger.php';
+ $this->classmap['CIDatabaseTestCase'] = SYSTEMPATH . 'Test/CIDatabaseTestCase.php';
+ }
+
+ $this->psr4 = array_merge($this->corePsr4, $this->psr4);
+ $this->classmap = array_merge($this->coreClassmap, $this->classmap);
+ $this->files = [...$this->coreFiles, ...$this->files];
+ }
+}
diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php
new file mode 100644
index 0000000..8a82cbf
--- /dev/null
+++ b/system/Config/BaseConfig.php
@@ -0,0 +1,273 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Config;
+
+use Config\Encryption;
+use Config\Modules;
+use ReflectionClass;
+use ReflectionException;
+use RuntimeException;
+
+/**
+ * Class BaseConfig
+ *
+ * Not intended to be used on its own, this class will attempt to
+ * automatically populate the child class' properties with values
+ * from the environment.
+ *
+ * These can be set within the .env file.
+ *
+ * @phpstan-consistent-constructor
+ * @see \CodeIgniter\Config\BaseConfigTest
+ */
+class BaseConfig
+{
+ /**
+ * An optional array of classes that will act as Registrars
+ * for rapidly setting config class properties.
+ *
+ * @var array
+ */
+ public static $registrars = [];
+
+ /**
+ * Whether to override properties by Env vars and Registrars.
+ */
+ public static bool $override = true;
+
+ /**
+ * Has module discovery happened yet?
+ *
+ * @var bool
+ */
+ protected static $didDiscovery = false;
+
+ /**
+ * The modules configuration.
+ *
+ * @var Modules|null
+ */
+ 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;
+ }
+
+ /**
+ * @internal For testing purposes only.
+ * @testTag
+ */
+ public static function setModules(Modules $modules): void
+ {
+ static::$moduleConfig = $modules;
+ }
+
+ /**
+ * @internal For testing purposes only.
+ * @testTag
+ */
+ public static function reset(): void
+ {
+ static::$registrars = [];
+ static::$override = true;
+ static::$didDiscovery = false;
+ static::$moduleConfig = null;
+ }
+
+ /**
+ * Will attempt to get environment variables with names
+ * that match the properties of the child class.
+ *
+ * The "shortPrefix" is the lowercase-only config class name.
+ */
+ public function __construct()
+ {
+ static::$moduleConfig ??= new Modules();
+
+ if (! static::$override) {
+ return;
+ }
+
+ $this->registerProperties();
+
+ $properties = array_keys(get_object_vars($this));
+ $prefix = static::class;
+ $slashAt = strrpos($prefix, '\\');
+ $shortPrefix = strtolower(substr($prefix, $slashAt === false ? 0 : $slashAt + 1));
+
+ foreach ($properties as $property) {
+ $this->initEnvValue($this->{$property}, $property, $prefix, $shortPrefix);
+
+ if ($this instanceof Encryption && $property === 'key') {
+ if (str_starts_with($this->{$property}, 'hex2bin:')) {
+ // Handle hex2bin prefix
+ $this->{$property} = hex2bin(substr($this->{$property}, 8));
+ } elseif (str_starts_with($this->{$property}, 'base64:')) {
+ // Handle base64 prefix
+ $this->{$property} = base64_decode(substr($this->{$property}, 7), true);
+ }
+ }
+ }
+ }
+
+ /**
+ * Initialization an environment-specific configuration setting
+ *
+ * @param array|bool|float|int|string|null $property
+ *
+ * @return void
+ */
+ protected function initEnvValue(&$property, string $name, string $prefix, string $shortPrefix)
+ {
+ if (is_array($property)) {
+ foreach (array_keys($property) as $key) {
+ $this->initEnvValue($property[$key], "{$name}.{$key}", $prefix, $shortPrefix);
+ }
+ } elseif (($value = $this->getEnvValue($name, $prefix, $shortPrefix)) !== false && $value !== null) {
+ if ($value === 'false') {
+ $value = false;
+ } elseif ($value === 'true') {
+ $value = true;
+ }
+ if (is_bool($value)) {
+ $property = $value;
+
+ return;
+ }
+
+ $value = trim($value, '\'"');
+
+ if (is_int($property)) {
+ $value = (int) $value;
+ } elseif (is_float($property)) {
+ $value = (float) $value;
+ }
+
+ // If the default value of the property is `null` and the type is not
+ // `string`, TypeError will happen.
+ // So cannot set `declare(strict_types=1)` in this file.
+ $property = $value;
+ }
+ }
+
+ /**
+ * Retrieve an environment-specific configuration setting
+ *
+ * @return string|null
+ */
+ protected function getEnvValue(string $property, string $prefix, string $shortPrefix)
+ {
+ $shortPrefix = ltrim($shortPrefix, '\\');
+ $underscoreProperty = str_replace('.', '_', $property);
+
+ switch (true) {
+ case array_key_exists("{$shortPrefix}.{$property}", $_ENV):
+ return $_ENV["{$shortPrefix}.{$property}"];
+
+ case array_key_exists("{$shortPrefix}_{$underscoreProperty}", $_ENV):
+ return $_ENV["{$shortPrefix}_{$underscoreProperty}"];
+
+ case array_key_exists("{$shortPrefix}.{$property}", $_SERVER):
+ return $_SERVER["{$shortPrefix}.{$property}"];
+
+ case array_key_exists("{$shortPrefix}_{$underscoreProperty}", $_SERVER):
+ return $_SERVER["{$shortPrefix}_{$underscoreProperty}"];
+
+ case array_key_exists("{$prefix}.{$property}", $_ENV):
+ return $_ENV["{$prefix}.{$property}"];
+
+ case array_key_exists("{$prefix}_{$underscoreProperty}", $_ENV):
+ return $_ENV["{$prefix}_{$underscoreProperty}"];
+
+ case array_key_exists("{$prefix}.{$property}", $_SERVER):
+ return $_SERVER["{$prefix}.{$property}"];
+
+ case array_key_exists("{$prefix}_{$underscoreProperty}", $_SERVER):
+ return $_SERVER["{$prefix}_{$underscoreProperty}"];
+
+ default:
+ $value = getenv("{$shortPrefix}.{$property}");
+ $value = $value === false ? getenv("{$shortPrefix}_{$underscoreProperty}") : $value;
+ $value = $value === false ? getenv("{$prefix}.{$property}") : $value;
+ $value = $value === false ? getenv("{$prefix}_{$underscoreProperty}") : $value;
+
+ return $value === false ? null : $value;
+ }
+ }
+
+ /**
+ * Provides external libraries a simple way to register one or more
+ * options into a config file.
+ *
+ * @return void
+ *
+ * @throws ReflectionException
+ */
+ protected function registerProperties()
+ {
+ if (! static::$moduleConfig->shouldDiscover('registrars')) {
+ return;
+ }
+
+ if (! static::$didDiscovery) {
+ $locator = service('locator');
+ $registrarsFiles = $locator->search('Config/Registrar.php');
+
+ foreach ($registrarsFiles as $file) {
+ $className = $locator->findQualifiedNameFromPath($file);
+
+ if ($className === false) {
+ continue;
+ }
+
+ static::$registrars[] = new $className();
+ }
+
+ static::$didDiscovery = true;
+ }
+
+ $shortName = (new ReflectionClass($this))->getShortName();
+
+ // Check the registrar class for a method named after this class' shortName
+ foreach (static::$registrars as $callable) {
+ // ignore non-applicable registrars
+ if (! method_exists($callable, $shortName)) {
+ continue; // @codeCoverageIgnore
+ }
+
+ $properties = $callable::$shortName();
+
+ if (! is_array($properties)) {
+ throw new RuntimeException('Registrars must return an array of properties and their values.');
+ }
+
+ foreach ($properties as $property => $value) {
+ if (isset($this->{$property}) && is_array($this->{$property}) && is_array($value)) {
+ $this->{$property} = array_merge($this->{$property}, $value);
+ } else {
+ $this->{$property} = $value;
+ }
+ }
+ }
+ }
+}
diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php
new file mode 100644
index 0000000..687044b
--- /dev/null
+++ b/system/Config/BaseService.php
@@ -0,0 +1,423 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Config;
+
+use CodeIgniter\Autoloader\Autoloader;
+use CodeIgniter\Autoloader\FileLocator;
+use CodeIgniter\Autoloader\FileLocatorCached;
+use CodeIgniter\Autoloader\FileLocatorInterface;
+use CodeIgniter\Cache\CacheInterface;
+use CodeIgniter\Cache\ResponseCache;
+use CodeIgniter\CLI\Commands;
+use CodeIgniter\CodeIgniter;
+use CodeIgniter\Database\ConnectionInterface;
+use CodeIgniter\Database\MigrationRunner;
+use CodeIgniter\Debug\Exceptions;
+use CodeIgniter\Debug\Iterator;
+use CodeIgniter\Debug\Timer;
+use CodeIgniter\Debug\Toolbar;
+use CodeIgniter\Email\Email;
+use CodeIgniter\Encryption\EncrypterInterface;
+use CodeIgniter\Filters\Filters;
+use CodeIgniter\Format\Format;
+use CodeIgniter\Honeypot\Honeypot;
+use CodeIgniter\HTTP\CLIRequest;
+use CodeIgniter\HTTP\ContentSecurityPolicy;
+use CodeIgniter\HTTP\CURLRequest;
+use CodeIgniter\HTTP\IncomingRequest;
+use CodeIgniter\HTTP\Negotiate;
+use CodeIgniter\HTTP\RedirectResponse;
+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;
+use CodeIgniter\Log\Logger;
+use CodeIgniter\Pager\Pager;
+use CodeIgniter\Router\RouteCollection;
+use CodeIgniter\Router\RouteCollectionInterface;
+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;
+use CodeIgniter\View\Cell;
+use CodeIgniter\View\Parser;
+use CodeIgniter\View\RendererInterface;
+use CodeIgniter\View\View;
+use Config\App;
+use Config\Autoload;
+use Config\Cache;
+use Config\ContentSecurityPolicy as CSPConfig;
+use Config\Encryption;
+use Config\Exceptions as ConfigExceptions;
+use Config\Filters as ConfigFilters;
+use Config\Format as ConfigFormat;
+use Config\Honeypot as ConfigHoneyPot;
+use Config\Images;
+use Config\Migrations;
+use Config\Modules;
+use Config\Optimize;
+use Config\Pager as ConfigPager;
+use Config\Services as AppServices;
+use Config\Session as ConfigSession;
+use Config\Toolbar as ConfigToolbar;
+use Config\Validation as ConfigValidation;
+use Config\View as ConfigView;
+use InvalidArgumentException;
+
+/**
+ * Services Configuration file.
+ *
+ * Services are simply other classes/libraries that the system uses
+ * to do its job. This is used by CodeIgniter to allow the core of the
+ * framework to be swapped out easily without affecting the usage within
+ * the rest of your application.
+ *
+ * This is used in place of a Dependency Injection container primarily
+ * due to its simplicity, which allows a better long-term maintenance
+ * of the applications built on top of CodeIgniter. A bonus side-effect
+ * is that IDEs are able to determine what class you are calling
+ * whereas with DI Containers there usually isn't a way for them to do this.
+ *
+ * Warning: To allow overrides by service providers do not use static calls,
+ * instead call out to \Config\Services (imported as AppServices).
+ *
+ * @see http://blog.ircmaxell.com/2015/11/simple-easy-risk-and-change.html
+ * @see http://www.infoq.com/presentations/Simple-Made-Easy
+ *
+ * @method static CacheInterface cache(Cache $config = null, $getShared = true)
+ * @method static CLIRequest clirequest(App $config = null, $getShared = true)
+ * @method static CodeIgniter codeigniter(App $config = null, $getShared = true)
+ * @method static Commands commands($getShared = true)
+ * @method static void createRequest(App $config, bool $isCli = false)
+ * @method static ContentSecurityPolicy csp(CSPConfig $config = null, $getShared = true)
+ * @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, $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)
+ * @method static BaseHandler image($handler = null, Images $config = null, $getShared = true)
+ * @method static IncomingRequest incomingrequest(?App $config = null, bool $getShared = true)
+ * @method static Iterator iterator($getShared = true)
+ * @method static Language language($locale = null, $getShared = true)
+ * @method static Logger logger($getShared = true)
+ * @method static MigrationRunner migrations(Migrations $config = null, ConnectionInterface $db = null, $getShared = true)
+ * @method static Negotiate negotiator(RequestInterface $request = null, $getShared = true)
+ * @method static Pager pager(ConfigPager $config = null, RendererInterface $view = null, $getShared = true)
+ * @method static Parser parser($viewPath = null, ConfigView $config = null, $getShared = true)
+ * @method static RedirectResponse redirectresponse(App $config = null, $getShared = true)
+ * @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(ConfigSession $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)
+ * @method static Typography typography($getShared = true)
+ * @method static URI uri($uri = null, $getShared = true)
+ * @method static ValidationInterface validation(ConfigValidation $config = null, $getShared = true)
+ * @method static Cell viewcell($getShared = true)
+ */
+class BaseService
+{
+ /**
+ * Cache for instance of any services that
+ * have been requested as a "shared" instance.
+ * Keys should be lowercase service names.
+ *
+ * @var array [key => instance]
+ */
+ protected static $instances = [];
+
+ /**
+ * Factory method list.
+ *
+ * @var array [key => callable]
+ */
+ protected static array $factories = [];
+
+ /**
+ * Mock objects for testing which are returned if exist.
+ *
+ * @var array [key => instance]
+ */
+ protected static $mocks = [];
+
+ /**
+ * Have we already discovered other Services?
+ *
+ * @var bool
+ */
+ protected static $discovered = false;
+
+ /**
+ * A cache of other service classes we've found.
+ *
+ * @var array
+ *
+ * @deprecated 4.5.0 No longer used.
+ */
+ protected static $services = [];
+
+ /**
+ * A cache of the names of services classes found.
+ *
+ * @var list
+ */
+ private static array $serviceNames = [];
+
+ /**
+ * Simple method to get an entry fast.
+ *
+ * @param string $key Identifier of the entry to look for.
+ *
+ * @return object|null Entry.
+ */
+ public static function get(string $key): ?object
+ {
+ return static::$instances[$key] ?? static::__callStatic($key, []);
+ }
+
+ /**
+ * Sets an entry.
+ *
+ * @param string $key Identifier of the entry.
+ */
+ public static function set(string $key, object $value): void
+ {
+ if (isset(static::$instances[$key])) {
+ throw new InvalidArgumentException('The entry for "' . $key . '" is already set.');
+ }
+
+ static::$instances[$key] = $value;
+ }
+
+ /**
+ * Overrides an existing entry.
+ *
+ * @param string $key Identifier of the entry.
+ */
+ public static function override(string $key, object $value): void
+ {
+ static::$instances[$key] = $value;
+ }
+
+ /**
+ * Returns a shared instance of any of the class' services.
+ *
+ * $key must be a name matching a service.
+ *
+ * @param array|bool|float|int|object|string|null ...$params
+ *
+ * @return object
+ */
+ protected static function getSharedInstance(string $key, ...$params)
+ {
+ $key = strtolower($key);
+
+ // Returns mock if exists
+ if (isset(static::$mocks[$key])) {
+ return static::$mocks[$key];
+ }
+
+ if (! isset(static::$instances[$key])) {
+ // Make sure $getShared is false
+ $params[] = false;
+
+ static::$instances[$key] = AppServices::$key(...$params);
+ }
+
+ return static::$instances[$key];
+ }
+
+ /**
+ * The Autoloader class is the central class that handles our
+ * spl_autoload_register method, and helper methods.
+ *
+ * @return Autoloader
+ */
+ public static function autoloader(bool $getShared = true)
+ {
+ if ($getShared) {
+ if (empty(static::$instances['autoloader'])) {
+ static::$instances['autoloader'] = new Autoloader();
+ }
+
+ return static::$instances['autoloader'];
+ }
+
+ return new Autoloader();
+ }
+
+ /**
+ * The file locator provides utility methods for looking for non-classes
+ * within namespaced folders, as well as convenience methods for
+ * loading 'helpers', and 'libraries'.
+ *
+ * @return FileLocatorInterface
+ */
+ public static function locator(bool $getShared = true)
+ {
+ if ($getShared) {
+ if (empty(static::$instances['locator'])) {
+ $cacheEnabled = class_exists(Optimize::class)
+ && (new Optimize())->locatorCacheEnabled;
+
+ if ($cacheEnabled) {
+ static::$instances['locator'] = new FileLocatorCached(new FileLocator(static::autoloader()));
+ } else {
+ static::$instances['locator'] = new FileLocator(static::autoloader());
+ }
+ }
+
+ return static::$mocks['locator'] ?? static::$instances['locator'];
+ }
+
+ return new FileLocator(static::autoloader());
+ }
+
+ /**
+ * Provides the ability to perform case-insensitive calling of service
+ * names.
+ *
+ * @return object|null
+ */
+ public static function __callStatic(string $name, array $arguments)
+ {
+ if (isset(static::$factories[$name])) {
+ return static::$factories[$name](...$arguments);
+ }
+
+ $service = static::serviceExists($name);
+
+ if ($service === null) {
+ return null;
+ }
+
+ return $service::$name(...$arguments);
+ }
+
+ /**
+ * Check if the requested service is defined and return the declaring
+ * class. Return null if not found.
+ */
+ public static function serviceExists(string $name): ?string
+ {
+ static::buildServicesCache();
+
+ $services = array_merge(self::$serviceNames, [Services::class]);
+ $name = strtolower($name);
+
+ foreach ($services as $service) {
+ if (method_exists($service, $name)) {
+ static::$factories[$name] = [$service, $name];
+
+ return $service;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Reset shared instances and mocks for testing.
+ *
+ * @return void
+ *
+ * @testTag only available to test code
+ */
+ public static function reset(bool $initAutoloader = true)
+ {
+ static::$mocks = [];
+ static::$instances = [];
+ static::$factories = [];
+
+ if ($initAutoloader) {
+ static::autoloader()->initialize(new Autoload(), new Modules());
+ }
+ }
+
+ /**
+ * Resets any mock and shared instances for a single service.
+ *
+ * @return void
+ *
+ * @testTag only available to test code
+ */
+ public static function resetSingle(string $name)
+ {
+ $name = strtolower($name);
+ unset(static::$mocks[$name], static::$instances[$name]);
+ }
+
+ /**
+ * Inject mock object for testing.
+ *
+ * @param object $mock
+ *
+ * @return void
+ *
+ * @testTag only available to test code
+ */
+ public static function injectMock(string $name, $mock)
+ {
+ static::$instances[$name] = $mock;
+ static::$mocks[strtolower($name)] = $mock;
+ }
+
+ protected static function buildServicesCache(): void
+ {
+ if (! static::$discovered) {
+ if ((new Modules())->shouldDiscover('services')) {
+ $locator = static::locator();
+ $files = $locator->search('Config/Services');
+
+ $systemPath = static::autoloader()->getNamespace('CodeIgniter')[0];
+
+ // Get instances of all service classes and cache them locally.
+ foreach ($files as $file) {
+ // Does not search `CodeIgniter` namespace to prevent from loading twice.
+ if (str_starts_with($file, $systemPath)) {
+ continue;
+ }
+
+ $classname = $locator->findQualifiedNameFromPath($file);
+
+ if ($classname === false) {
+ continue;
+ }
+
+ if ($classname !== Services::class) {
+ self::$serviceNames[] = $classname;
+ }
+ }
+ }
+
+ static::$discovered = true;
+ }
+ }
+}
diff --git a/system/Config/DotEnv.php b/system/Config/DotEnv.php
new file mode 100644
index 0000000..db7152f
--- /dev/null
+++ b/system/Config/DotEnv.php
@@ -0,0 +1,240 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Config;
+
+use InvalidArgumentException;
+
+/**
+ * Environment-specific configuration
+ *
+ * @see \CodeIgniter\Config\DotEnvTest
+ */
+class DotEnv
+{
+ /**
+ * The directory where the .env file can be located.
+ *
+ * @var string
+ */
+ protected $path;
+
+ /**
+ * Builds the path to our file.
+ */
+ public function __construct(string $path, string $file = '.env')
+ {
+ $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $file;
+ }
+
+ /**
+ * The main entry point, will load the .env file and process it
+ * so that we end up with all settings in the PHP environment vars
+ * (i.e. getenv(), $_ENV, and $_SERVER)
+ */
+ public function load(): bool
+ {
+ $vars = $this->parse();
+
+ return $vars !== null;
+ }
+
+ /**
+ * Parse the .env file into an array of key => value
+ */
+ public function parse(): ?array
+ {
+ // We don't want to enforce the presence of a .env file, they should be optional.
+ if (! is_file($this->path)) {
+ return null;
+ }
+
+ // Ensure the file is readable
+ if (! is_readable($this->path)) {
+ throw new InvalidArgumentException("The .env file is not readable: {$this->path}");
+ }
+
+ $vars = [];
+
+ $lines = file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+
+ foreach ($lines as $line) {
+ // Is it a comment?
+ if (str_starts_with(trim($line), '#')) {
+ continue;
+ }
+
+ // If there is an equal sign, then we know we are assigning a variable.
+ if (str_contains($line, '=')) {
+ [$name, $value] = $this->normaliseVariable($line);
+ $vars[$name] = $value;
+ $this->setVariable($name, $value);
+ }
+ }
+
+ return $vars;
+ }
+
+ /**
+ * Sets the variable into the environment. Will parse the string
+ * first to look for {name}={value} pattern, ensure that nested
+ * variables are handled, and strip it of single and double quotes.
+ *
+ * @return void
+ */
+ protected function setVariable(string $name, string $value = '')
+ {
+ if (! getenv($name, true)) {
+ putenv("{$name}={$value}");
+ }
+
+ if (empty($_ENV[$name])) {
+ $_ENV[$name] = $value;
+ }
+
+ if (empty($_SERVER[$name])) {
+ $_SERVER[$name] = $value;
+ }
+ }
+
+ /**
+ * Parses for assignment, cleans the $name and $value, and ensures
+ * that nested variables are handled.
+ */
+ public function normaliseVariable(string $name, string $value = ''): array
+ {
+ // Split our compound string into its parts.
+ if (str_contains($name, '=')) {
+ [$name, $value] = explode('=', $name, 2);
+ }
+
+ $name = trim($name);
+ $value = trim($value);
+
+ // Sanitize the name
+ $name = preg_replace('/^export[ \t]++(\S+)/', '$1', $name);
+ $name = str_replace(['\'', '"'], '', $name);
+
+ // Sanitize the value
+ $value = $this->sanitizeValue($value);
+ $value = $this->resolveNestedVariables($value);
+
+ return [$name, $value];
+ }
+
+ /**
+ * Strips quotes from the environment variable value.
+ *
+ * This was borrowed from the excellent phpdotenv with very few changes.
+ * https://github.com/vlucas/phpdotenv
+ *
+ * @throws InvalidArgumentException
+ */
+ protected function sanitizeValue(string $value): string
+ {
+ if ($value === '') {
+ return $value;
+ }
+
+ // Does it begin with a quote?
+ if (strpbrk($value[0], '"\'') !== false) {
+ // value starts with a quote
+ $quote = $value[0];
+
+ $regexPattern = sprintf(
+ '/^
+ %1$s # match a quote at the start of the value
+ ( # capturing sub-pattern used
+ (?: # we do not need to capture this
+ [^%1$s\\\\] # any character other than a quote or backslash
+ |\\\\\\\\ # or two backslashes together
+ |\\\\%1$s # or an escaped quote e.g \"
+ )* # as many characters that match the previous rules
+ ) # end of the capturing sub-pattern
+ %1$s # and the closing quote
+ .*$ # and discard any string after the closing quote
+ /mx',
+ $quote
+ );
+
+ $value = preg_replace($regexPattern, '$1', $value);
+ $value = str_replace("\\{$quote}", $quote, $value);
+ $value = str_replace('\\\\', '\\', $value);
+ } else {
+ $parts = explode(' #', $value, 2);
+ $value = trim($parts[0]);
+
+ // Unquoted values cannot contain whitespace
+ if (preg_match('/\s+/', $value) > 0) {
+ throw new InvalidArgumentException('.env values containing spaces must be surrounded by quotes.');
+ }
+ }
+
+ return $value;
+ }
+
+ /**
+ * Resolve the nested variables.
+ *
+ * Look for ${varname} patterns in the variable value and replace with an existing
+ * environment variable.
+ *
+ * This was borrowed from the excellent phpdotenv with very few changes.
+ * https://github.com/vlucas/phpdotenv
+ */
+ protected function resolveNestedVariables(string $value): string
+ {
+ if (str_contains($value, '$')) {
+ $value = preg_replace_callback(
+ '/\${([a-zA-Z0-9_\.]+)}/',
+ function ($matchedPatterns) {
+ $nestedVariable = $this->getVariable($matchedPatterns[1]);
+
+ if ($nestedVariable === null) {
+ return $matchedPatterns[0];
+ }
+
+ return $nestedVariable;
+ },
+ $value
+ );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Search the different places for environment variables and return first value found.
+ *
+ * This was borrowed from the excellent phpdotenv with very few changes.
+ * https://github.com/vlucas/phpdotenv
+ *
+ * @return string|null
+ */
+ protected function getVariable(string $name)
+ {
+ switch (true) {
+ case array_key_exists($name, $_ENV):
+ return $_ENV[$name];
+
+ case array_key_exists($name, $_SERVER):
+ return $_SERVER[$name];
+
+ default:
+ $value = getenv($name);
+
+ // switch getenv default to null
+ return $value === false ? null : $value;
+ }
+ }
+}
diff --git a/system/Config/Factories.php b/system/Config/Factories.php
new file mode 100644
index 0000000..d98664a
--- /dev/null
+++ b/system/Config/Factories.php
@@ -0,0 +1,556 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Config;
+
+use CodeIgniter\Database\ConnectionInterface;
+use CodeIgniter\Model;
+use InvalidArgumentException;
+
+/**
+ * Factories for creating instances.
+ *
+ * Factories allow dynamic loading of components by their path
+ * and name. The "shared instance" implementation provides a
+ * large performance boost and helps keep code clean of lengthy
+ * instantiation checks.
+ *
+ * @method static BaseConfig|null config(...$arguments)
+ * @method static Model|null models(string $alias, array $options = [], ?ConnectionInterface &$conn = null)
+ * @see \CodeIgniter\Config\FactoriesTest
+ */
+final class Factories
+{
+ /**
+ * Store of component-specific options, usually
+ * from CodeIgniter\Config\Factory.
+ *
+ * @var array>
+ */
+ private static $options = [];
+
+ /**
+ * Explicit options for the Config
+ * component to prevent logic loops.
+ *
+ * @var array
+ */
+ private static array $configOptions = [
+ 'component' => 'config',
+ 'path' => 'Config',
+ 'instanceOf' => null,
+ 'getShared' => true,
+ 'preferApp' => true,
+ ];
+
+ /**
+ * 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>
+ */
+ private 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>
+ */
+ private static $instances = [];
+
+ /**
+ * Whether the component instances are updated?
+ *
+ * @var array [component => true]
+ *
+ * @internal For caching only
+ */
+ private 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 class-string $classname FQCN to be loaded
+ */
+ public static function define(string $component, string $alias, string $classname): void
+ {
+ $component = strtolower($component);
+
+ 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;
+ self::$updated[$component] = true;
+ }
+
+ /**
+ * Loads instances based on the method component name. Either
+ * creates a new instance or returns an existing shared instance.
+ *
+ * @return object|null
+ */
+ public static function __callStatic(string $component, array $arguments)
+ {
+ $component = strtolower($component);
+
+ // 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($component), $options);
+
+ if (! $options['getShared']) {
+ if (isset(self::$aliases[$options['component']][$alias])) {
+ $class = self::$aliases[$options['component']][$alias];
+
+ return new $class(...$arguments);
+ }
+
+ // Try to locate the class
+ $class = self::locateClass($options, $alias);
+ if ($class !== null) {
+ return new $class(...$arguments);
+ }
+
+ return null;
+ }
+
+ // 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;
+ }
+
+ self::createInstance($options['component'], $class, $arguments);
+ self::setAlias($options['component'], $alias, $class);
+
+ return self::$instances[$options['component']][$class];
+ }
+
+ /**
+ * Simple method to get the shared instance fast.
+ */
+ public static function get(string $component, string $alias): ?object
+ {
+ if (isset(self::$aliases[$component][$alias])) {
+ $class = self::$aliases[$component][$alias];
+
+ if (isset(self::$instances[$component][$class])) {
+ return self::$instances[$component][$class];
+ }
+ }
+
+ return self::__callStatic($component, [$alias]);
+ }
+
+ /**
+ * Gets the defined instance. If not exists, creates new one.
+ *
+ * @return object|null
+ */
+ private static function getDefinedInstance(array $options, string $alias, array $arguments)
+ {
+ // The alias is already defined.
+ 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::createInstance($options['component'], $class, $arguments);
+
+ return self::$instances[$options['component']][$class];
+ }
+ }
+
+ // Try to locate the class
+ if (! $class = self::locateClass($options, $alias)) {
+ return null;
+ }
+
+ // Check for an existing instance for the class
+ if (isset(self::$instances[$options['component']][$class])) {
+ self::setAlias($options['component'], $alias, $class);
+
+ return self::$instances[$options['component']][$class];
+ }
+
+ return null;
+ }
+
+ /**
+ * Creates the shared instance.
+ */
+ private static function createInstance(string $component, string $class, array $arguments): void
+ {
+ self::$instances[$component][$class] = new $class(...$arguments);
+ self::$updated[$component] = true;
+ }
+
+ /**
+ * Sets alias
+ */
+ private static function setAlias(string $component, string $alias, string $class): void
+ {
+ self::$aliases[$component][$alias] = $class;
+ self::$updated[$component] = true;
+
+ // If a short classname is specified, also register FQCN to share the instance.
+ if (! isset(self::$aliases[$component][$class]) && ! self::isNamespaced($alias)) {
+ self::$aliases[$component][$class] = $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 $alias Class alias. See the $aliases property.
+ */
+ private static function locateClass(array $options, string $alias): ?string
+ {
+ // Check for low-hanging fruit
+ 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($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 (
+ // 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($alias) && self::verifyInstanceOf($options, $alias)) {
+ return $alias;
+ }
+
+ // Have to do this the hard way...
+ $locator = service('locator');
+
+ // 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 . $alias)) {
+ return null;
+ }
+
+ // Check all files for a valid class
+ foreach ($files as $file) {
+ $class = $locator->findQualifiedNameFromPath($file);
+
+ if ($class !== false && self::verifyInstanceOf($options, $class)) {
+ return $class;
+ }
+ }
+
+ 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 str_contains($alias, '\\');
+ }
+
+ /**
+ * Verifies that a class & config satisfy the "preferApp" option
+ *
+ * @param array $options The array of component-specific directives
+ * @param string $alias Class alias. See the $aliases property.
+ */
+ private static function verifyPreferApp(array $options, string $alias): bool
+ {
+ // Anything without that restriction passes
+ if (! $options['preferApp']) {
+ return true;
+ }
+
+ // Special case for Config since its App namespace is actually \Config
+ if (self::isConfig($options['component'])) {
+ return str_starts_with($alias, 'Config');
+ }
+
+ return str_starts_with($alias, APP_NAMESPACE);
+ }
+
+ /**
+ * Verifies that a class & config satisfy the "instanceOf" option
+ *
+ * @param array $options The array of component-specific directives
+ * @param string $alias Class alias. See the $aliases property.
+ */
+ private static function verifyInstanceOf(array $options, string $alias): bool
+ {
+ // Anything without that restriction passes
+ if (! $options['instanceOf']) {
+ return true;
+ }
+
+ return is_a($alias, $options['instanceOf'], true);
+ }
+
+ /**
+ * Returns the component-specific configuration
+ *
+ * @param string $component Lowercase, plural component name
+ *
+ * @return array
+ *
+ * @internal For testing only
+ * @testTag
+ */
+ public static function getOptions(string $component): array
+ {
+ $component = strtolower($component);
+
+ // Check for a stored version
+ if (isset(self::$options[$component])) {
+ return self::$options[$component];
+ }
+
+ $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')->{$component} ?? [];
+
+ // The setOptions() reset the component. So getOptions() may reset
+ // the component.
+ return self::setOptions($component, $values);
+ }
+
+ /**
+ * 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
+ */
+ public static function setOptions(string $component, array $values): array
+ {
+ $component = strtolower($component);
+
+ // Allow the config to replace the component name, to support "aliases"
+ $values['component'] = strtolower($values['component'] ?? $component);
+
+ // Reset this component so instances can be rediscovered with the updated config
+ self::reset($values['component']);
+
+ // If no path was available then use the component
+ $values['path'] = trim($values['path'] ?? ucfirst($values['component']), '\\ ');
+
+ // Add defaults for any missing values
+ $values = array_merge(Factory::$default, $values);
+
+ // Store the result to the supplied name and potential alias
+ self::$options[$component] = $values;
+ self::$options[$values['component']] = $values;
+
+ return $values;
+ }
+
+ /**
+ * Resets the static arrays, optionally just for one component
+ *
+ * @param string|null $component Lowercase, plural component name
+ *
+ * @return void
+ */
+ public static function reset(?string $component = null)
+ {
+ if ($component !== null) {
+ unset(
+ self::$options[$component],
+ self::$aliases[$component],
+ self::$instances[$component],
+ self::$updated[$component]
+ );
+
+ return;
+ }
+
+ self::$options = [];
+ self::$aliases = [];
+ self::$instances = [];
+ self::$updated = [];
+ }
+
+ /**
+ * Helper method for injecting mock instances
+ *
+ * @param string $component Lowercase, plural component name
+ * @param string $alias Class alias. See the $aliases property.
+ *
+ * @return void
+ *
+ * @internal For testing only
+ * @testTag
+ */
+ public static function injectMock(string $component, string $alias, object $instance)
+ {
+ $component = strtolower($component);
+
+ // Force a configuration to exist for this component
+ self::getOptions($component);
+
+ $class = $instance::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 alias, namespaced or not.
+ *
+ * @internal For testing only
+ * @testTag
+ */
+ public static function getBasename(string $alias): string
+ {
+ // Determine the basename
+ if ($basename = strrchr($alias, '\\')) {
+ return substr($basename, 1);
+ }
+
+ return $alias;
+ }
+
+ /**
+ * Gets component data for caching.
+ *
+ * @internal For caching only
+ */
+ public static function getComponentInstances(string $component): array
+ {
+ if (! isset(self::$aliases[$component])) {
+ return [
+ 'options' => [],
+ 'aliases' => [],
+ 'instances' => [],
+ ];
+ }
+
+ return [
+ 'options' => self::$options[$component],
+ 'aliases' => self::$aliases[$component],
+ 'instances' => self::$instances[$component],
+ ];
+ }
+
+ /**
+ * Sets component data
+ *
+ * @internal For caching only
+ */
+ public static function setComponentInstances(string $component, array $data): void
+ {
+ self::$options[$component] = $data['options'];
+ self::$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/Factory.php b/system/Config/Factory.php
new file mode 100644
index 0000000..b252677
--- /dev/null
+++ b/system/Config/Factory.php
@@ -0,0 +1,50 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Config;
+
+/**
+ * Factories Configuration file.
+ *
+ * Provides overriding directives for how
+ * Factories should handle discovery and
+ * instantiation of specific components.
+ * Each property should correspond to the
+ * lowercase, plural component name.
+ */
+class Factory extends BaseConfig
+{
+ /**
+ * Supplies a default set of options to merge for
+ * all unspecified factory components.
+ *
+ * @var array
+ */
+ public static $default = [
+ 'component' => null,
+ 'path' => null,
+ 'instanceOf' => null,
+ 'getShared' => true,
+ 'preferApp' => true,
+ ];
+
+ /**
+ * Specifies that Models should always favor child
+ * classes to allow easy extension of module Models.
+ *
+ * @var array
+ */
+ public $models = [
+ 'preferApp' => true,
+ ];
+}
diff --git a/system/Config/Filters.php b/system/Config/Filters.php
new file mode 100644
index 0000000..562eae8
--- /dev/null
+++ b/system/Config/Filters.php
@@ -0,0 +1,118 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Config;
+
+use CodeIgniter\Filters\Cors;
+use CodeIgniter\Filters\CSRF;
+use CodeIgniter\Filters\DebugToolbar;
+use CodeIgniter\Filters\ForceHTTPS;
+use CodeIgniter\Filters\Honeypot;
+use CodeIgniter\Filters\InvalidChars;
+use CodeIgniter\Filters\PageCache;
+use CodeIgniter\Filters\PerformanceMetrics;
+use CodeIgniter\Filters\SecureHeaders;
+
+/**
+ * Filters configuration
+ */
+class Filters extends BaseConfig
+{
+ /**
+ * Configures aliases for Filter classes to
+ * make reading things nicer and simpler.
+ *
+ * @var array>
+ *
+ * [filter_name => classname]
+ * or [filter_name => [classname1, classname2, ...]]
+ */
+ public array $aliases = [
+ 'csrf' => CSRF::class,
+ 'toolbar' => DebugToolbar::class,
+ 'honeypot' => Honeypot::class,
+ 'invalidchars' => InvalidChars::class,
+ 'secureheaders' => SecureHeaders::class,
+ 'cors' => Cors::class,
+ 'forcehttps' => ForceHTTPS::class,
+ 'pagecache' => PageCache::class,
+ 'performance' => PerformanceMetrics::class,
+ ];
+
+ /**
+ * List of special required filters.
+ *
+ * The filters listed here are special. They are applied before and after
+ * other kinds of filters, and always applied even if a route does not exist.
+ *
+ * Filters set by default provide framework functionality. If removed,
+ * those functions will no longer work.
+ *
+ * @see https://codeigniter.com/user_guide/incoming/filters.html#provided-filters
+ *
+ * @var array{before: list, after: list}
+ */
+ public array $required = [
+ 'before' => [
+ 'forcehttps', // Force Global Secure Requests
+ 'pagecache', // Web Page Caching
+ ],
+ 'after' => [
+ 'pagecache', // Web Page Caching
+ 'performance', // Performance Metrics
+ 'toolbar', // Debug Toolbar
+ ],
+ ];
+
+ /**
+ * List of filter aliases that are always
+ * applied before and after every request.
+ *
+ * @var array>>|array>
+ */
+ public array $globals = [
+ 'before' => [
+ // 'honeypot',
+ // 'csrf',
+ // 'invalidchars',
+ ],
+ 'after' => [
+ // 'honeypot',
+ // 'secureheaders',
+ ],
+ ];
+
+ /**
+ * List of filter aliases that works on a
+ * particular HTTP method (GET, POST, etc.).
+ *
+ * Example:
+ * 'POST' => ['foo', 'bar']
+ *
+ * If you use this, you should disable auto-routing because auto-routing
+ * permits any HTTP method to access a controller. Accessing the controller
+ * with a method you don't expect could bypass the filter.
+ *
+ * @var array>
+ */
+ public array $methods = [];
+
+ /**
+ * List of filter aliases that should run on any
+ * before or after URI patterns.
+ *
+ * Example:
+ * 'isLoggedIn' => ['before' => ['account/*', 'profiles/*']]
+ */
+ public array $filters = [];
+}
diff --git a/system/Config/ForeignCharacters.php b/system/Config/ForeignCharacters.php
new file mode 100644
index 0000000..2663a53
--- /dev/null
+++ b/system/Config/ForeignCharacters.php
@@ -0,0 +1,117 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Config;
+
+/**
+ * Describes foreign characters for transliteration with the text helper.
+ */
+class ForeignCharacters
+{
+ /**
+ * The list of foreign characters.
+ *
+ * @var array
+ */
+ public $characterList = [
+ '/ä|æ|ǽ/' => 'ae',
+ '/ö|œ/' => 'oe',
+ '/ü/' => 'ue',
+ '/Ä/' => 'Ae',
+ '/Ü/' => 'Ue',
+ '/Ö/' => 'Oe',
+ '/À|Á|Â|Ã|Ä|Å|Ǻ|Ā|Ă|Ą|Ǎ|Α|Ά|Ả|Ạ|Ầ|Ẫ|Ẩ|Ậ|Ằ|Ắ|Ẵ|Ẳ|Ặ|А/' => 'A',
+ '/à|á|â|ã|å|ǻ|ā|ă|ą|ǎ|ª|α|ά|ả|ạ|ầ|ấ|ẫ|ẩ|ậ|ằ|ắ|ẵ|ẳ|ặ|а/' => 'a',
+ '/Б/' => 'B',
+ '/б/' => 'b',
+ '/Ç|Ć|Ĉ|Ċ|Č/' => 'C',
+ '/ç|ć|ĉ|ċ|č/' => 'c',
+ '/Д/' => 'D',
+ '/д/' => 'd',
+ '/Ð|Ď|Đ|Δ/' => 'Dj',
+ '/ð|ď|đ|δ/' => 'dj',
+ '/È|É|Ê|Ë|Ē|Ĕ|Ė|Ę|Ě|Ε|Έ|Ẽ|Ẻ|Ẹ|Ề|Ế|Ễ|Ể|Ệ|Е|Э/' => 'E',
+ '/è|é|ê|ë|ē|ĕ|ė|ę|ě|έ|ε|ẽ|ẻ|ẹ|ề|ế|ễ|ể|ệ|е|э/' => 'e',
+ '/Ф/' => 'F',
+ '/ф/' => 'f',
+ '/Ĝ|Ğ|Ġ|Ģ|Γ|Г|Ґ/' => 'G',
+ '/ĝ|ğ|ġ|ģ|γ|г|ґ/' => 'g',
+ '/Ĥ|Ħ/' => 'H',
+ '/ĥ|ħ/' => 'h',
+ '/Ì|Í|Î|Ï|Ĩ|Ī|Ĭ|Ǐ|Į|İ|Η|Ή|Ί|Ι|Ϊ|Ỉ|Ị|И|Ы/' => 'I',
+ '/ì|í|î|ï|ĩ|ī|ĭ|ǐ|į|ı|η|ή|ί|ι|ϊ|ỉ|ị|и|ы|ї/' => 'i',
+ '/Ĵ/' => 'J',
+ '/ĵ/' => 'j',
+ '/Ķ|Κ|К/' => 'K',
+ '/ķ|κ|к/' => 'k',
+ '/Ĺ|Ļ|Ľ|Ŀ|Ł|Λ|Л/' => 'L',
+ '/ĺ|ļ|ľ|ŀ|ł|λ|л/' => 'l',
+ '/М/' => 'M',
+ '/м/' => 'm',
+ '/Ñ|Ń|Ņ|Ň|Ν|Н/' => 'N',
+ '/ñ|ń|ņ|ň|ʼn|ν|н/' => 'n',
+ '/Ò|Ó|Ô|Õ|Ō|Ŏ|Ǒ|Ő|Ơ|Ø|Ǿ|Ο|Ό|Ω|Ώ|Ỏ|Ọ|Ồ|Ố|Ỗ|Ổ|Ộ|Ờ|Ớ|Ỡ|Ở|Ợ|О/' => 'O',
+ '/ò|ó|ô|õ|ō|ŏ|ǒ|ő|ơ|ø|ǿ|º|ο|ό|ω|ώ|ỏ|ọ|ồ|ố|ỗ|ổ|ộ|ờ|ớ|ỡ|ở|ợ|о/' => 'o',
+ '/П/' => 'P',
+ '/п/' => 'p',
+ '/Ŕ|Ŗ|Ř|Ρ|Р/' => 'R',
+ '/ŕ|ŗ|ř|ρ|р/' => 'r',
+ '/Ś|Ŝ|Ş|Ș|Š|Σ|С/' => 'S',
+ '/ś|ŝ|ş|ș|š|ſ|σ|ς|с/' => 's',
+ '/Ț|Ţ|Ť|Ŧ|τ|Т/' => 'T',
+ '/ț|ţ|ť|ŧ|т/' => 't',
+ '/Ù|Ú|Û|Ũ|Ū|Ŭ|Ů|Ű|Ų|Ư|Ǔ|Ǖ|Ǘ|Ǚ|Ǜ|Ũ|Ủ|Ụ|Ừ|Ứ|Ữ|Ử|Ự|У/' => 'U',
+ '/ù|ú|û|ũ|ū|ŭ|ů|ű|ų|ư|ǔ|ǖ|ǘ|ǚ|ǜ|υ|ύ|ϋ|ủ|ụ|ừ|ứ|ữ|ử|ự|у/' => 'u',
+ '/Ƴ|Ɏ|Ỵ|Ẏ|Ӳ|Ӯ|Ў|Ý|Ÿ|Ŷ|Υ|Ύ|Ϋ|Ỳ|Ỹ|Ỷ|Ỵ|Й/' => 'Y',
+ '/ẙ|ʏ|ƴ|ɏ|ỵ|ẏ|ӳ|ӯ|ў|ý|ÿ|ŷ|ỳ|ỹ|ỷ|ỵ|й/' => 'y',
+ '/В/' => 'V',
+ '/в/' => 'v',
+ '/Ŵ/' => 'W',
+ '/ŵ/' => 'w',
+ '/Ź|Ż|Ž|Ζ|З/' => 'Z',
+ '/ź|ż|ž|ζ|з/' => 'z',
+ '/Æ|Ǽ/' => 'AE',
+ '/ß/' => 'ss',
+ '/IJ/' => 'IJ',
+ '/ij/' => 'ij',
+ '/Œ/' => 'OE',
+ '/ƒ/' => 'f',
+ '/ξ/' => 'ks',
+ '/π/' => 'p',
+ '/β/' => 'v',
+ '/μ/' => 'm',
+ '/ψ/' => 'ps',
+ '/Ё/' => 'Yo',
+ '/ё/' => 'yo',
+ '/Є/' => 'Ye',
+ '/є/' => 'ye',
+ '/Ї/' => 'Yi',
+ '/Ж/' => 'Zh',
+ '/ж/' => 'zh',
+ '/Х/' => 'Kh',
+ '/х/' => 'kh',
+ '/Ц/' => 'Ts',
+ '/ц/' => 'ts',
+ '/Ч/' => 'Ch',
+ '/ч/' => 'ch',
+ '/Ш/' => 'Sh',
+ '/ш/' => 'sh',
+ '/Щ/' => 'Shch',
+ '/щ/' => 'shch',
+ '/Ъ|ъ|Ь|ь/' => '',
+ '/Ю/' => 'Yu',
+ '/ю/' => 'yu',
+ '/Я/' => 'Ya',
+ '/я/' => 'ya',
+ ];
+}
diff --git a/system/Config/Publisher.php b/system/Config/Publisher.php
new file mode 100644
index 0000000..7e1b0ff
--- /dev/null
+++ b/system/Config/Publisher.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\Config;
+
+/**
+ * Publisher Configuration
+ *
+ * Defines basic security restrictions for the Publisher class
+ * to prevent abuse by injecting malicious files into a project.
+ */
+class Publisher extends BaseConfig
+{
+ /**
+ * A list of allowed destinations with a (pseudo-)regex
+ * of allowed files for each destination.
+ * Attempts to publish to directories not in this list will
+ * result in a PublisherException. Files that do no fit the
+ * pattern will cause copy/merge to fail.
+ *
+ * @var array
+ */
+ public $restrictions = [
+ ROOTPATH => '*',
+ FCPATH => '#\.(?css|js|map|htm?|xml|json|webmanifest|tff|eot|woff?|gif|jpe?g|tiff?|png|webp|bmp|ico|svg)$#i',
+ ];
+
+ /**
+ * Disables Registrars to prevent modules from altering the restrictions.
+ */
+ final protected function registerProperties(): void
+ {
+ }
+}
diff --git a/system/Config/Routing.php b/system/Config/Routing.php
new file mode 100644
index 0000000..6999ad3
--- /dev/null
+++ b/system/Config/Routing.php
@@ -0,0 +1,140 @@
+
+ *
+ * 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
+{
+ /**
+ * For Defined Routes.
+ * 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'
+ *
+ * @var list
+ */
+ public array $routeFiles = [
+ APPPATH . 'Config/Routes.php',
+ ];
+
+ /**
+ * For Defined Routes and Auto Routing.
+ * The default namespace to use for Controllers when no other
+ * namespace has been specified.
+ *
+ * Default: 'App\Controllers'
+ */
+ public string $defaultNamespace = 'App\Controllers';
+
+ /**
+ * For Auto Routing.
+ * The default controller to use when no other controller has been
+ * specified.
+ *
+ * Default: 'Home'
+ */
+ public string $defaultController = 'Home';
+
+ /**
+ * For Defined Routes and Auto Routing.
+ * The default method to call on the controller when no other
+ * method has been set in the route.
+ *
+ * Default: 'index'
+ */
+ public string $defaultMethod = 'index';
+
+ /**
+ * For Auto Routing.
+ * Whether to translate dashes in URIs for controller/method 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 the controller/method name like: 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
+ * 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;
+
+ /**
+ * For Defined Routes.
+ * If TRUE, will enable the use of the 'prioritize' option
+ * when defining routes.
+ *
+ * Default: false
+ */
+ public bool $prioritize = false;
+
+ /**
+ * For Defined Routes.
+ * If TRUE, matched multiple URI segments will be passed as one parameter.
+ *
+ * Default: false
+ */
+ public bool $multipleSegmentsOneParam = false;
+
+ /**
+ * For Auto Routing (Improved).
+ * Map of URI segments and namespaces.
+ *
+ * The key is the first URI segment. The value is the controller namespace.
+ * E.g.,
+ * [
+ * 'blog' => 'Acme\Blog\Controllers',
+ * ]
+ *
+ * @var array
+ */
+ public array $moduleRoutes = [];
+
+ /**
+ * For Auto Routing (Improved).
+ * Whether to translate dashes in URIs for controller/method to CamelCase.
+ * E.g., blog-controller -> BlogController
+ *
+ * If you enable this, $translateURIDashes is ignored.
+ *
+ * Default: false
+ */
+ public bool $translateUriToCamelCase = false;
+}
diff --git a/system/Config/Services.php b/system/Config/Services.php
new file mode 100644
index 0000000..e37b127
--- /dev/null
+++ b/system/Config/Services.php
@@ -0,0 +1,866 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Config;
+
+use CodeIgniter\Cache\CacheFactory;
+use CodeIgniter\Cache\CacheInterface;
+use CodeIgniter\Cache\ResponseCache;
+use CodeIgniter\CLI\Commands;
+use CodeIgniter\CodeIgniter;
+use CodeIgniter\Database\ConnectionInterface;
+use CodeIgniter\Database\MigrationRunner;
+use CodeIgniter\Debug\Exceptions;
+use CodeIgniter\Debug\Iterator;
+use CodeIgniter\Debug\Timer;
+use CodeIgniter\Debug\Toolbar;
+use CodeIgniter\Email\Email;
+use CodeIgniter\Encryption\EncrypterInterface;
+use CodeIgniter\Encryption\Encryption;
+use CodeIgniter\Filters\Filters;
+use CodeIgniter\Format\Format;
+use CodeIgniter\Honeypot\Honeypot;
+use CodeIgniter\HTTP\CLIRequest;
+use CodeIgniter\HTTP\ContentSecurityPolicy;
+use CodeIgniter\HTTP\CURLRequest;
+use CodeIgniter\HTTP\IncomingRequest;
+use CodeIgniter\HTTP\Negotiate;
+use CodeIgniter\HTTP\RedirectResponse;
+use CodeIgniter\HTTP\Request;
+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;
+use CodeIgniter\Language\Language;
+use CodeIgniter\Log\Logger;
+use CodeIgniter\Pager\Pager;
+use CodeIgniter\Router\RouteCollection;
+use CodeIgniter\Router\RouteCollectionInterface;
+use CodeIgniter\Router\Router;
+use CodeIgniter\Security\Security;
+use CodeIgniter\Session\Handlers\BaseHandler as SessionBaseHandler;
+use CodeIgniter\Session\Handlers\Database\MySQLiHandler;
+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;
+use CodeIgniter\Validation\ValidationInterface;
+use CodeIgniter\View\Cell;
+use CodeIgniter\View\Parser;
+use CodeIgniter\View\RendererInterface;
+use CodeIgniter\View\View;
+use Config\App;
+use Config\Cache;
+use Config\ContentSecurityPolicy as ContentSecurityPolicyConfig;
+use Config\ContentSecurityPolicy as CSPConfig;
+use Config\Database;
+use Config\Email as EmailConfig;
+use Config\Encryption as EncryptionConfig;
+use Config\Exceptions as ExceptionsConfig;
+use Config\Filters as FiltersConfig;
+use Config\Format as FormatConfig;
+use Config\Honeypot as HoneypotConfig;
+use Config\Images;
+use Config\Logger as LoggerConfig;
+use Config\Migrations;
+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;
+use Config\Validation as ValidationConfig;
+use Config\View as ViewConfig;
+use InvalidArgumentException;
+use Locale;
+
+/**
+ * Services Configuration file.
+ *
+ * Services are simply other classes/libraries that the system uses
+ * to do its job. This is used by CodeIgniter to allow the core of the
+ * framework to be swapped out easily without affecting the usage within
+ * the rest of your application.
+ *
+ * This is used in place of a Dependency Injection container primarily
+ * due to its simplicity, which allows a better long-term maintenance
+ * of the applications built on top of CodeIgniter. A bonus side-effect
+ * is that IDEs are able to determine what class you are calling
+ * whereas with DI Containers there usually isn't a way for them to do this.
+ *
+ * @see http://blog.ircmaxell.com/2015/11/simple-easy-risk-and-change.html
+ * @see http://www.infoq.com/presentations/Simple-Made-Easy
+ * @see \CodeIgniter\Config\ServicesTest
+ */
+class Services extends BaseService
+{
+ /**
+ * The cache class provides a simple way to store and retrieve
+ * complex data for later.
+ *
+ * @return CacheInterface
+ */
+ public static function cache(?Cache $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('cache', $config);
+ }
+
+ $config ??= config(Cache::class);
+
+ return CacheFactory::getHandler($config);
+ }
+
+ /**
+ * The CLI Request class provides for ways to interact with
+ * a command line request.
+ *
+ * @return CLIRequest
+ *
+ * @internal
+ */
+ public static function clirequest(?App $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('clirequest', $config);
+ }
+
+ $config ??= config(App::class);
+
+ return new CLIRequest($config);
+ }
+
+ /**
+ * CodeIgniter, the core of the framework.
+ *
+ * @return CodeIgniter
+ */
+ public static function codeigniter(?App $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('codeigniter', $config);
+ }
+
+ $config ??= config(App::class);
+
+ return new CodeIgniter($config);
+ }
+
+ /**
+ * The commands utility for running and working with CLI commands.
+ *
+ * @return Commands
+ */
+ public static function commands(bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('commands');
+ }
+
+ return new Commands();
+ }
+
+ /**
+ * Content Security Policy
+ *
+ * @return ContentSecurityPolicy
+ */
+ public static function csp(?CSPConfig $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('csp', $config);
+ }
+
+ $config ??= config(ContentSecurityPolicyConfig::class);
+
+ return new ContentSecurityPolicy($config);
+ }
+
+ /**
+ * The CURL Request class acts as a simple HTTP client for interacting
+ * with other servers, typically through APIs.
+ *
+ * @return CURLRequest
+ */
+ public static function curlrequest(array $options = [], ?ResponseInterface $response = null, ?App $config = null, bool $getShared = true)
+ {
+ if ($getShared === true) {
+ return static::getSharedInstance('curlrequest', $options, $response, $config);
+ }
+
+ $config ??= config(App::class);
+ $response ??= new Response($config);
+
+ return new CURLRequest(
+ $config,
+ new URI($options['base_uri'] ?? null),
+ $response,
+ $options
+ );
+ }
+
+ /**
+ * The Email class allows you to send email via mail, sendmail, SMTP.
+ *
+ * @param array|EmailConfig|null $config
+ *
+ * @return Email
+ */
+ public static function email($config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('email', $config);
+ }
+
+ if (empty($config) || ! (is_array($config) || $config instanceof EmailConfig)) {
+ $config = config(EmailConfig::class);
+ }
+
+ return new Email($config);
+ }
+
+ /**
+ * The Encryption class provides two-way encryption.
+ *
+ * @param bool $getShared
+ *
+ * @return EncrypterInterface Encryption handler
+ */
+ public static function encrypter(?EncryptionConfig $config = null, $getShared = false)
+ {
+ if ($getShared === true) {
+ return static::getSharedInstance('encrypter', $config);
+ }
+
+ $config ??= config(EncryptionConfig::class);
+ $encryption = new Encryption($config);
+
+ return $encryption->initialize($config);
+ }
+
+ /**
+ * The Exceptions class holds the methods that handle:
+ *
+ * - set_exception_handler
+ * - set_error_handler
+ * - register_shutdown_function
+ *
+ * @return Exceptions
+ */
+ public static function exceptions(
+ ?ExceptionsConfig $config = null,
+ bool $getShared = true
+ ) {
+ if ($getShared) {
+ return static::getSharedInstance('exceptions', $config);
+ }
+
+ $config ??= config(ExceptionsConfig::class);
+
+ return new Exceptions($config);
+ }
+
+ /**
+ * Filters allow you to run tasks before and/or after a controller
+ * is executed. During before filters, the request can be modified,
+ * and actions taken based on the request, while after filters can
+ * act on or modify the response itself before it is sent to the client.
+ *
+ * @return Filters
+ */
+ public static function filters(?FiltersConfig $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('filters', $config);
+ }
+
+ $config ??= config(FiltersConfig::class);
+
+ return new Filters($config, AppServices::get('request'), AppServices::get('response'));
+ }
+
+ /**
+ * The Format class is a convenient place to create Formatters.
+ *
+ * @return Format
+ */
+ public static function format(?FormatConfig $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('format', $config);
+ }
+
+ $config ??= config(FormatConfig::class);
+
+ return new Format($config);
+ }
+
+ /**
+ * The Honeypot provides a secret input on forms that bots should NOT
+ * fill in, providing an additional safeguard when accepting user input.
+ *
+ * @return Honeypot
+ */
+ public static function honeypot(?HoneypotConfig $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('honeypot', $config);
+ }
+
+ $config ??= config(HoneypotConfig::class);
+
+ return new Honeypot($config);
+ }
+
+ /**
+ * Acts as a factory for ImageHandler classes and returns an instance
+ * of the handler. Used like service('image')->withFile($path)->rotate(90)->save();
+ *
+ * @return BaseHandler
+ */
+ public static function image(?string $handler = null, ?Images $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('image', $handler, $config);
+ }
+
+ $config ??= config(Images::class);
+ assert($config instanceof Images);
+
+ $handler = $handler ?: $config->defaultHandler;
+ $class = $config->handlers[$handler];
+
+ return new $class($config);
+ }
+
+ /**
+ * The Iterator class provides a simple way of looping over a function
+ * and timing the results and memory usage. Used when debugging and
+ * optimizing applications.
+ *
+ * @return Iterator
+ */
+ public static function iterator(bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('iterator');
+ }
+
+ return new Iterator();
+ }
+
+ /**
+ * Responsible for loading the language string translations.
+ *
+ * @return Language
+ */
+ public static function language(?string $locale = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('language', $locale)->setLocale($locale);
+ }
+
+ if (AppServices::get('request') instanceof IncomingRequest) {
+ $requestLocale = AppServices::get('request')->getLocale();
+ } else {
+ $requestLocale = Locale::getDefault();
+ }
+
+ // Use '?:' for empty string check
+ $locale = $locale ?: $requestLocale;
+
+ return new Language($locale);
+ }
+
+ /**
+ * The Logger class is a PSR-3 compatible Logging class that supports
+ * multiple handlers that process the actual logging.
+ *
+ * @return Logger
+ */
+ public static function logger(bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('logger');
+ }
+
+ return new Logger(config(LoggerConfig::class));
+ }
+
+ /**
+ * Return the appropriate Migration runner.
+ *
+ * @return MigrationRunner
+ */
+ public static function migrations(?Migrations $config = null, ?ConnectionInterface $db = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('migrations', $config, $db);
+ }
+
+ $config ??= config(Migrations::class);
+
+ return new MigrationRunner($config, $db);
+ }
+
+ /**
+ * The Negotiate class provides the content negotiation features for
+ * working the request to determine correct language, encoding, charset,
+ * and more.
+ *
+ * @return Negotiate
+ */
+ public static function negotiator(?RequestInterface $request = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('negotiator', $request);
+ }
+
+ $request ??= AppServices::get('request');
+
+ 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::get('cache');
+
+ return new ResponseCache($config, $cache);
+ }
+
+ /**
+ * Return the appropriate pagination handler.
+ *
+ * @return Pager
+ */
+ public static function pager(?PagerConfig $config = null, ?RendererInterface $view = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('pager', $config, $view);
+ }
+
+ $config ??= config(PagerConfig::class);
+ $view ??= AppServices::renderer(null, null, false);
+
+ return new Pager($config, $view);
+ }
+
+ /**
+ * The Parser is a simple template parser.
+ *
+ * @return Parser
+ */
+ public static function parser(?string $viewPath = null, ?ViewConfig $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('parser', $viewPath, $config);
+ }
+
+ $viewPath = $viewPath ?: (new Paths())->viewDirectory;
+ $config ??= config(ViewConfig::class);
+
+ return new Parser($config, $viewPath, AppServices::get('locator'), CI_DEBUG, AppServices::get('logger'));
+ }
+
+ /**
+ * The Renderer class is the class that actually displays a file to the user.
+ * The default View class within CodeIgniter is intentionally simple, but this
+ * service could easily be replaced by a template engine if the user needed to.
+ *
+ * @return View
+ */
+ public static function renderer(?string $viewPath = null, ?ViewConfig $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('renderer', $viewPath, $config);
+ }
+
+ $viewPath = $viewPath ?: (new Paths())->viewDirectory;
+ $config ??= config(ViewConfig::class);
+
+ return new View($config, $viewPath, AppServices::get('locator'), CI_DEBUG, AppServices::get('logger'));
+ }
+
+ /**
+ * Returns the current Request object.
+ *
+ * createRequest() injects IncomingRequest or CLIRequest.
+ *
+ * @return CLIRequest|IncomingRequest
+ *
+ * @deprecated The parameter $config and $getShared are deprecated.
+ */
+ public static function request(?App $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('request', $config);
+ }
+
+ // @TODO remove the following code for backward compatibility
+ return AppServices::incomingrequest($config, $getShared);
+ }
+
+ /**
+ * Create the current Request object, either IncomingRequest or CLIRequest.
+ *
+ * This method is called from CodeIgniter::getRequestObject().
+ *
+ * @internal
+ */
+ public static function createRequest(App $config, bool $isCli = false): void
+ {
+ if ($isCli) {
+ $request = AppServices::clirequest($config);
+ } else {
+ $request = AppServices::incomingrequest($config);
+
+ // guess at protocol if needed
+ $request->setProtocolVersion($_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1');
+ }
+
+ // Inject the request object into Services.
+ static::$instances['request'] = $request;
+ }
+
+ /**
+ * The IncomingRequest class models an HTTP request.
+ *
+ * @return IncomingRequest
+ *
+ * @internal
+ */
+ public static function incomingrequest(?App $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('request', $config);
+ }
+
+ $config ??= config(App::class);
+
+ return new IncomingRequest(
+ $config,
+ AppServices::get('uri'),
+ 'php://input',
+ new UserAgent()
+ );
+ }
+
+ /**
+ * The Response class models an HTTP response.
+ *
+ * @return ResponseInterface
+ */
+ public static function response(?App $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('response', $config);
+ }
+
+ $config ??= config(App::class);
+
+ return new Response($config);
+ }
+
+ /**
+ * The Redirect class provides nice way of working with redirects.
+ *
+ * @return RedirectResponse
+ */
+ public static function redirectresponse(?App $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('redirectresponse', $config);
+ }
+
+ $config ??= config(App::class);
+ $response = new RedirectResponse($config);
+ $response->setProtocolVersion(AppServices::get('request')->getProtocolVersion());
+
+ return $response;
+ }
+
+ /**
+ * The Routes service is a class that allows for easily building
+ * a collection of routes.
+ *
+ * @return RouteCollection
+ */
+ public static function routes(bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('routes');
+ }
+
+ return new RouteCollection(AppServices::get('locator'), config(Modules::class), config(Routing::class));
+ }
+
+ /**
+ * The Router class uses a RouteCollection's array of routes, and determines
+ * the correct Controller and Method to execute.
+ *
+ * @return Router
+ */
+ public static function router(?RouteCollectionInterface $routes = null, ?Request $request = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('router', $routes, $request);
+ }
+
+ $routes ??= AppServices::get('routes');
+ $request ??= AppServices::get('request');
+
+ return new Router($routes, $request);
+ }
+
+ /**
+ * The Security class provides a few handy tools for keeping the site
+ * secure, most notably the CSRF protection tools.
+ *
+ * @return Security
+ */
+ public static function security(?SecurityConfig $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('security', $config);
+ }
+
+ $config ??= config(SecurityConfig::class);
+
+ return new Security($config);
+ }
+
+ /**
+ * Return the session manager.
+ *
+ * @return Session
+ */
+ public static function session(?SessionConfig $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('session', $config);
+ }
+
+ $config ??= config(SessionConfig::class);
+
+ $logger = AppServices::get('logger');
+
+ $driverName = $config->driver;
+
+ if ($driverName === DatabaseHandler::class) {
+ $DBGroup = $config->DBGroup ?? config(Database::class)->defaultGroup;
+
+ $driverPlatform = Database::connect($DBGroup)->getPlatform();
+
+ if ($driverPlatform === 'MySQLi') {
+ $driverName = MySQLiHandler::class;
+ } elseif ($driverPlatform === 'Postgre') {
+ $driverName = PostgreHandler::class;
+ }
+ }
+
+ if (! class_exists($driverName) || ! is_a($driverName, SessionBaseHandler::class, true)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Invalid session handler "%s" provided.',
+ $driverName
+ ));
+ }
+
+ /** @var SessionBaseHandler $driver */
+ $driver = new $driverName($config, AppServices::get('request')->getIPAddress());
+ $driver->setLogger($logger);
+
+ $session = new Session($driver, $config);
+ $session->setLogger($logger);
+
+ if (session_status() === PHP_SESSION_NONE) {
+ // PHP Session emits the headers according to `session.cache_limiter`.
+ // See https://www.php.net/manual/en/function.session-cache-limiter.php.
+ // The headers are not managed by CI's Response class.
+ // So, we remove CI's default Cache-Control header.
+ AppServices::response()->removeHeader('Cache-Control');
+
+ $session->start();
+ }
+
+ 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::get('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.
+ *
+ * @return Throttler
+ */
+ public static function throttler(bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('throttler');
+ }
+
+ return new Throttler(AppServices::get('cache'));
+ }
+
+ /**
+ * The Timer class provides a simple way to Benchmark portions of your
+ * application.
+ *
+ * @return Timer
+ */
+ public static function timer(bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('timer');
+ }
+
+ return new Timer();
+ }
+
+ /**
+ * Return the debug toolbar.
+ *
+ * @return Toolbar
+ */
+ public static function toolbar(?ToolbarConfig $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('toolbar', $config);
+ }
+
+ $config ??= config(ToolbarConfig::class);
+
+ return new Toolbar($config);
+ }
+
+ /**
+ * The URI class provides a way to model and manipulate URIs.
+ *
+ * @param string|null $uri The URI string
+ *
+ * @return URI The current URI if $uri is null.
+ */
+ public static function uri(?string $uri = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('uri', $uri);
+ }
+
+ if ($uri === null) {
+ $appConfig = config(App::class);
+ $factory = AppServices::siteurifactory($appConfig, AppServices::get('superglobals'));
+
+ return $factory->createFromGlobals();
+ }
+
+ return new URI($uri);
+ }
+
+ /**
+ * The Validation class provides tools for validating input data.
+ *
+ * @return ValidationInterface
+ */
+ public static function validation(?ValidationConfig $config = null, bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('validation', $config);
+ }
+
+ $config ??= config(ValidationConfig::class);
+
+ return new Validation($config, AppServices::get('renderer'));
+ }
+
+ /**
+ * View cells are intended to let you insert HTML into view
+ * that has been generated by any callable in the system.
+ *
+ * @return Cell
+ */
+ public static function viewcell(bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('viewcell');
+ }
+
+ return new Cell(AppServices::get('cache'));
+ }
+
+ /**
+ * The Typography class provides a way to format text in semantically relevant ways.
+ *
+ * @return Typography
+ */
+ public static function typography(bool $getShared = true)
+ {
+ if ($getShared) {
+ return static::getSharedInstance('typography');
+ }
+
+ return new Typography();
+ }
+}
diff --git a/system/Config/View.php b/system/Config/View.php
new file mode 100644
index 0000000..038c6fa
--- /dev/null
+++ b/system/Config/View.php
@@ -0,0 +1,136 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Config;
+
+use CodeIgniter\View\ViewDecoratorInterface;
+
+/**
+ * View configuration
+ *
+ * @phpstan-type parser_callable (callable(mixed): mixed)
+ * @phpstan-type parser_callable_string (callable(mixed): mixed)&string
+ */
+class View extends BaseConfig
+{
+ /**
+ * When false, the view method will clear the data between each
+ * call.
+ *
+ * @var bool
+ */
+ public $saveData = true;
+
+ /**
+ * Parser Filters map a filter name with any PHP callable. When the
+ * Parser prepares a variable for display, it will chain it
+ * through the filters in the order defined, inserting any parameters.
+ *
+ * To prevent potential abuse, all filters MUST be defined here
+ * in order for them to be available for use within the Parser.
+ *
+ * @psalm-suppress UndefinedDocblockClass
+ *
+ * @var array
+ * @phpstan-var array
+ */
+ public $filters = [];
+
+ /**
+ * Parser Plugins provide a way to extend the functionality provided
+ * by the core Parser by creating aliases that will be replaced with
+ * any callable. Can be single or tag pair.
+ *
+ * @psalm-suppress UndefinedDocblockClass
+ *
+ * @var array|string>
+ * @phpstan-var array|parser_callable_string|parser_callable>
+ */
+ public $plugins = [];
+
+ /**
+ * Built-in View filters.
+ *
+ * @psalm-suppress UndefinedDocblockClass
+ *
+ * @var array
+ * @phpstan-var array
+ */
+ protected $coreFilters = [
+ 'abs' => '\abs',
+ 'capitalize' => '\CodeIgniter\View\Filters::capitalize',
+ 'date' => '\CodeIgniter\View\Filters::date',
+ 'date_modify' => '\CodeIgniter\View\Filters::date_modify',
+ 'default' => '\CodeIgniter\View\Filters::default',
+ 'esc' => '\CodeIgniter\View\Filters::esc',
+ 'excerpt' => '\CodeIgniter\View\Filters::excerpt',
+ 'highlight' => '\CodeIgniter\View\Filters::highlight',
+ 'highlight_code' => '\CodeIgniter\View\Filters::highlight_code',
+ 'limit_words' => '\CodeIgniter\View\Filters::limit_words',
+ 'limit_chars' => '\CodeIgniter\View\Filters::limit_chars',
+ 'local_currency' => '\CodeIgniter\View\Filters::local_currency',
+ 'local_number' => '\CodeIgniter\View\Filters::local_number',
+ 'lower' => '\strtolower',
+ 'nl2br' => '\CodeIgniter\View\Filters::nl2br',
+ 'number_format' => '\number_format',
+ 'prose' => '\CodeIgniter\View\Filters::prose',
+ 'round' => '\CodeIgniter\View\Filters::round',
+ 'strip_tags' => '\strip_tags',
+ 'title' => '\CodeIgniter\View\Filters::title',
+ 'upper' => '\strtoupper',
+ ];
+
+ /**
+ * Built-in View plugins.
+ *
+ * @psalm-suppress UndefinedDocblockClass
+ *
+ * @var array|string>
+ * @phpstan-var array|parser_callable_string|parser_callable>
+ */
+ protected $corePlugins = [
+ 'csp_script_nonce' => '\CodeIgniter\View\Plugins::cspScriptNonce',
+ 'csp_style_nonce' => '\CodeIgniter\View\Plugins::cspStyleNonce',
+ 'current_url' => '\CodeIgniter\View\Plugins::currentURL',
+ 'previous_url' => '\CodeIgniter\View\Plugins::previousURL',
+ 'mailto' => '\CodeIgniter\View\Plugins::mailto',
+ 'safe_mailto' => '\CodeIgniter\View\Plugins::safeMailto',
+ 'lang' => '\CodeIgniter\View\Plugins::lang',
+ 'validation_errors' => '\CodeIgniter\View\Plugins::validationErrors',
+ 'route' => '\CodeIgniter\View\Plugins::route',
+ 'siteURL' => '\CodeIgniter\View\Plugins::siteURL',
+ ];
+
+ /**
+ * View Decorators are class methods that will be run in sequence to
+ * have a chance to alter the generated output just prior to caching
+ * the results.
+ *
+ * All classes must implement CodeIgniter\View\ViewDecoratorInterface
+ *
+ * @var list>
+ */
+ public array $decorators = [];
+
+ /**
+ * Merge the built-in and developer-configured filters and plugins,
+ * with preference to the developer ones.
+ */
+ public function __construct()
+ {
+ $this->filters = array_merge($this->coreFilters, $this->filters);
+ $this->plugins = array_merge($this->corePlugins, $this->plugins);
+
+ parent::__construct();
+ }
+}
diff --git a/system/Controller.php b/system/Controller.php
new file mode 100644
index 0000000..fc0c41b
--- /dev/null
+++ b/system/Controller.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;
+
+use CodeIgniter\HTTP\CLIRequest;
+use CodeIgniter\HTTP\Exceptions\HTTPException;
+use CodeIgniter\HTTP\IncomingRequest;
+use CodeIgniter\HTTP\RequestInterface;
+use CodeIgniter\HTTP\ResponseInterface;
+use CodeIgniter\Validation\Exceptions\ValidationException;
+use CodeIgniter\Validation\ValidationInterface;
+use Config\Validation;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Class Controller
+ *
+ * @see \CodeIgniter\ControllerTest
+ */
+class Controller
+{
+ /**
+ * Helpers that will be automatically loaded on class instantiation.
+ *
+ * @var list
+ */
+ protected $helpers = [];
+
+ /**
+ * Instance of the main Request object.
+ *
+ * @var CLIRequest|IncomingRequest
+ */
+ protected $request;
+
+ /**
+ * Instance of the main response object.
+ *
+ * @var ResponseInterface
+ */
+ protected $response;
+
+ /**
+ * Instance of logger to use.
+ *
+ * @var LoggerInterface
+ */
+ protected $logger;
+
+ /**
+ * Should enforce HTTPS access for all methods in this controller.
+ *
+ * @var int Number of seconds to set HSTS header
+ */
+ protected $forceHTTPS = 0;
+
+ /**
+ * Once validation has been run, will hold the Validation instance.
+ *
+ * @var ValidationInterface|null
+ */
+ protected $validator;
+
+ /**
+ * Constructor.
+ *
+ * @return void
+ *
+ * @throws HTTPException
+ */
+ public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
+ {
+ $this->request = $request;
+ $this->response = $response;
+ $this->logger = $logger;
+
+ if ($this->forceHTTPS > 0) {
+ $this->forceHTTPS($this->forceHTTPS);
+ }
+
+ // Autoload helper files.
+ helper($this->helpers);
+ }
+
+ /**
+ * A convenience method to use when you need to ensure that a single
+ * method is reached only via HTTPS. If it isn't, then a redirect
+ * will happen back to this method and HSTS header will be sent
+ * to have modern browsers transform requests automatically.
+ *
+ * @param int $duration The number of seconds this link should be
+ * considered secure for. Only with HSTS header.
+ * Default value is 1 year.
+ *
+ * @return void
+ *
+ * @throws HTTPException
+ */
+ protected function forceHTTPS(int $duration = 31_536_000)
+ {
+ force_https($duration, $this->request, $this->response);
+ }
+
+ /**
+ * How long to cache the current page for.
+ *
+ * @params int $time time to live in seconds.
+ *
+ * @return void
+ */
+ protected function cachePage(int $time)
+ {
+ service('responsecache')->setTtl($time);
+ }
+
+ /**
+ * A shortcut to performing validation on Request data.
+ *
+ * @param array|string $rules
+ * @param array $messages An array of custom error messages
+ */
+ protected function validate($rules, array $messages = []): bool
+ {
+ $this->setValidator($rules, $messages);
+
+ return $this->validator->withRequest($this->request)->run();
+ }
+
+ /**
+ * A shortcut to performing validation on any input data.
+ *
+ * @param array $data The data to validate
+ * @param array|string $rules
+ * @param array $messages An array of custom error messages
+ * @param string|null $dbGroup The database group to use
+ */
+ protected function validateData(array $data, $rules, array $messages = [], ?string $dbGroup = null): bool
+ {
+ $this->setValidator($rules, $messages);
+
+ return $this->validator->run($data, null, $dbGroup);
+ }
+
+ /**
+ * @param array|string $rules
+ */
+ private function setValidator($rules, array $messages): void
+ {
+ $this->validator = service('validation');
+
+ // If you replace the $rules array with the name of the group
+ if (is_string($rules)) {
+ $validation = config(Validation::class);
+
+ // If the rule wasn't found in the \Config\Validation, we
+ // should throw an exception so the developer can find it.
+ if (! isset($validation->{$rules})) {
+ throw ValidationException::forRuleNotFound($rules);
+ }
+
+ // If no error message is defined, use the error message in the Config\Validation file
+ if ($messages === []) {
+ $errorName = $rules . '_errors';
+ $messages = $validation->{$errorName} ?? [];
+ }
+
+ $rules = $validation->{$rules};
+ }
+
+ $this->validator->setRules($rules, $messages);
+ }
+}
diff --git a/system/Cookie/CloneableCookieInterface.php b/system/Cookie/CloneableCookieInterface.php
new file mode 100644
index 0000000..0b7d6fd
--- /dev/null
+++ b/system/Cookie/CloneableCookieInterface.php
@@ -0,0 +1,111 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Cookie;
+
+use DateTimeInterface;
+
+/**
+ * Interface for a fresh Cookie instance with selected attribute(s)
+ * only changed from the original instance.
+ */
+interface CloneableCookieInterface extends CookieInterface
+{
+ /**
+ * Creates a new Cookie with a new cookie prefix.
+ *
+ * @return static
+ */
+ public function withPrefix(string $prefix = '');
+
+ /**
+ * Creates a new Cookie with a new name.
+ *
+ * @return static
+ */
+ public function withName(string $name);
+
+ /**
+ * Creates a new Cookie with new value.
+ *
+ * @return static
+ */
+ public function withValue(string $value);
+
+ /**
+ * Creates a new Cookie with a new cookie expires time.
+ *
+ * @param DateTimeInterface|int|string $expires
+ *
+ * @return static
+ */
+ public function withExpires($expires);
+
+ /**
+ * Creates a new Cookie that will expire the cookie from the browser.
+ *
+ * @return static
+ */
+ public function withExpired();
+
+ /**
+ * Creates a new Cookie that will virtually never expire from the browser.
+ *
+ * @return static
+ *
+ * @deprecated See https://github.com/codeigniter4/CodeIgniter4/pull/6413
+ */
+ public function withNeverExpiring();
+
+ /**
+ * Creates a new Cookie with a new path on the server the cookie is available.
+ *
+ * @return static
+ */
+ public function withPath(?string $path);
+
+ /**
+ * Creates a new Cookie with a new domain the cookie is available.
+ *
+ * @return static
+ */
+ public function withDomain(?string $domain);
+
+ /**
+ * Creates a new Cookie with a new "Secure" attribute.
+ *
+ * @return static
+ */
+ public function withSecure(bool $secure = true);
+
+ /**
+ * Creates a new Cookie with a new "HttpOnly" attribute
+ *
+ * @return static
+ */
+ public function withHTTPOnly(bool $httponly = true);
+
+ /**
+ * Creates a new Cookie with a new "SameSite" attribute.
+ *
+ * @return static
+ */
+ public function withSameSite(string $samesite);
+
+ /**
+ * Creates a new Cookie with URL encoding option updated.
+ *
+ * @return static
+ */
+ public function withRaw(bool $raw = true);
+}
diff --git a/system/Cookie/Cookie.php b/system/Cookie/Cookie.php
new file mode 100644
index 0000000..01b10db
--- /dev/null
+++ b/system/Cookie/Cookie.php
@@ -0,0 +1,789 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Cookie;
+
+use ArrayAccess;
+use CodeIgniter\Cookie\Exceptions\CookieException;
+use CodeIgniter\I18n\Time;
+use Config\Cookie as CookieConfig;
+use DateTimeInterface;
+use InvalidArgumentException;
+use LogicException;
+use ReturnTypeWillChange;
+
+/**
+ * A `Cookie` class represents an immutable HTTP cookie value object.
+ *
+ * Being immutable, modifying one or more of its attributes will return
+ * a new `Cookie` instance, rather than modifying itself. Users should
+ * reassign this new instance to a new variable to capture it.
+ *
+ * ```php
+ * $cookie = new Cookie('test_cookie', 'test_value');
+ * $cookie->getName(); // test_cookie
+ *
+ * $cookie->withName('prod_cookie');
+ * $cookie->getName(); // test_cookie
+ *
+ * $cookie2 = $cookie->withName('prod_cookie');
+ * $cookie2->getName(); // prod_cookie
+ * ```
+ *
+ * @template-implements ArrayAccess
+ * @see \CodeIgniter\Cookie\CookieTest
+ */
+class Cookie implements ArrayAccess, CloneableCookieInterface
+{
+ /**
+ * @var string
+ */
+ protected $prefix = '';
+
+ /**
+ * @var string
+ */
+ protected $name;
+
+ /**
+ * @var string
+ */
+ protected $value;
+
+ /**
+ * @var int Unix timestamp
+ */
+ protected $expires;
+
+ /**
+ * @var string
+ */
+ protected $path = '/';
+
+ /**
+ * @var string
+ */
+ protected $domain = '';
+
+ /**
+ * @var bool
+ */
+ protected $secure = false;
+
+ /**
+ * @var bool
+ */
+ protected $httponly = true;
+
+ /**
+ * @var string
+ */
+ protected $samesite = self::SAMESITE_LAX;
+
+ /**
+ * @var bool
+ */
+ protected $raw = false;
+
+ /**
+ * Default attributes for a Cookie object. The keys here are the
+ * lowercase attribute names. Do not camelCase!
+ *
+ * @var array
+ */
+ private static array $defaults = [
+ 'prefix' => '',
+ 'expires' => 0,
+ 'path' => '/',
+ 'domain' => '',
+ 'secure' => false,
+ 'httponly' => true,
+ 'samesite' => self::SAMESITE_LAX,
+ 'raw' => false,
+ ];
+
+ /**
+ * A cookie name can be any US-ASCII characters, except control characters,
+ * spaces, tabs, or separator characters.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes
+ * @see https://tools.ietf.org/html/rfc2616#section-2.2
+ */
+ private static string $reservedCharsList = "=,; \t\r\n\v\f()<>@:\\\"/[]?{}";
+
+ /**
+ * Set the default attributes to a Cookie instance by injecting
+ * the values from the `CookieConfig` config or an array.
+ *
+ * This method is called from Response::__construct().
+ *
+ * @param array|CookieConfig $config
+ *
+ * @return array The old defaults array. Useful for resetting.
+ */
+ public static function setDefaults($config = [])
+ {
+ $oldDefaults = self::$defaults;
+ $newDefaults = [];
+
+ if ($config instanceof CookieConfig) {
+ $newDefaults = [
+ 'prefix' => $config->prefix,
+ 'expires' => $config->expires,
+ 'path' => $config->path,
+ 'domain' => $config->domain,
+ 'secure' => $config->secure,
+ 'httponly' => $config->httponly,
+ 'samesite' => $config->samesite,
+ 'raw' => $config->raw,
+ ];
+ } elseif (is_array($config)) {
+ $newDefaults = $config;
+ }
+
+ // This array union ensures that even if passed `$config` is not
+ // `CookieConfig` or `array`, no empty defaults will occur.
+ self::$defaults = $newDefaults + $oldDefaults;
+
+ return $oldDefaults;
+ }
+
+ // =========================================================================
+ // CONSTRUCTORS
+ // =========================================================================
+
+ /**
+ * Create a new Cookie instance from a `Set-Cookie` header.
+ *
+ * @return static
+ *
+ * @throws CookieException
+ */
+ public static function fromHeaderString(string $cookie, bool $raw = false)
+ {
+ $data = self::$defaults;
+ $data['raw'] = $raw;
+
+ $parts = preg_split('/\;[\s]*/', $cookie);
+ $part = explode('=', array_shift($parts), 2);
+
+ $name = $raw ? $part[0] : urldecode($part[0]);
+ $value = isset($part[1]) ? ($raw ? $part[1] : urldecode($part[1])) : '';
+ unset($part);
+
+ foreach ($parts as $part) {
+ if (str_contains($part, '=')) {
+ [$attr, $val] = explode('=', $part);
+ } else {
+ $attr = $part;
+ $val = true;
+ }
+
+ $data[strtolower($attr)] = $val;
+ }
+
+ return new static($name, $value, $data);
+ }
+
+ /**
+ * Construct a new Cookie instance.
+ *
+ * @param string $name The cookie's name
+ * @param string $value The cookie's value
+ * @param array $options The cookie's options
+ *
+ * @throws CookieException
+ */
+ final public function __construct(string $name, string $value = '', array $options = [])
+ {
+ $options += self::$defaults;
+
+ $options['expires'] = static::convertExpiresTimestamp($options['expires']);
+
+ // If both `Expires` and `Max-Age` are set, `Max-Age` has precedence.
+ if (isset($options['max-age']) && is_numeric($options['max-age'])) {
+ $options['expires'] = Time::now()->getTimestamp() + (int) $options['max-age'];
+ unset($options['max-age']);
+ }
+
+ // to preserve backward compatibility with array-based cookies in previous CI versions
+ $prefix = ($options['prefix'] === '') ? self::$defaults['prefix'] : $options['prefix'];
+ $path = $options['path'] ?: self::$defaults['path'];
+ $domain = $options['domain'] ?: self::$defaults['domain'];
+
+ // empty string SameSite should use the default for browsers
+ $samesite = $options['samesite'] ?: self::$defaults['samesite'];
+
+ $raw = $options['raw'];
+ $secure = $options['secure'];
+ $httponly = $options['httponly'];
+
+ $this->validateName($name, $raw);
+ $this->validatePrefix($prefix, $secure, $path, $domain);
+ $this->validateSameSite($samesite, $secure);
+
+ $this->prefix = $prefix;
+ $this->name = $name;
+ $this->value = $value;
+ $this->expires = static::convertExpiresTimestamp($options['expires']);
+ $this->path = $path;
+ $this->domain = $domain;
+ $this->secure = $secure;
+ $this->httponly = $httponly;
+ $this->samesite = ucfirst(strtolower($samesite));
+ $this->raw = $raw;
+ }
+
+ // =========================================================================
+ // GETTERS
+ // =========================================================================
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getId(): string
+ {
+ return implode(';', [$this->getPrefixedName(), $this->getPath(), $this->getDomain()]);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getPrefix(): string
+ {
+ return $this->prefix;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getPrefixedName(): string
+ {
+ $name = $this->getPrefix();
+
+ if ($this->isRaw()) {
+ $name .= $this->getName();
+ } else {
+ $search = str_split(self::$reservedCharsList);
+ $replace = array_map(rawurlencode(...), $search);
+
+ $name .= str_replace($search, $replace, $this->getName());
+ }
+
+ return $name;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getValue(): string
+ {
+ return $this->value;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getExpiresTimestamp(): int
+ {
+ return $this->expires;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getExpiresString(): string
+ {
+ return gmdate(self::EXPIRES_FORMAT, $this->expires);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isExpired(): bool
+ {
+ return $this->expires === 0 || $this->expires < Time::now()->getTimestamp();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getMaxAge(): int
+ {
+ $maxAge = $this->expires - Time::now()->getTimestamp();
+
+ return $maxAge >= 0 ? $maxAge : 0;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getPath(): string
+ {
+ return $this->path;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getDomain(): string
+ {
+ return $this->domain;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isSecure(): bool
+ {
+ return $this->secure;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isHTTPOnly(): bool
+ {
+ return $this->httponly;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getSameSite(): string
+ {
+ return $this->samesite;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isRaw(): bool
+ {
+ return $this->raw;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getOptions(): array
+ {
+ // This is the order of options in `setcookie`. DO NOT CHANGE.
+ return [
+ 'expires' => $this->expires,
+ 'path' => $this->path,
+ 'domain' => $this->domain,
+ 'secure' => $this->secure,
+ 'httponly' => $this->httponly,
+ 'samesite' => $this->samesite ?: ucfirst(self::SAMESITE_LAX),
+ ];
+ }
+
+ // =========================================================================
+ // CLONING
+ // =========================================================================
+
+ /**
+ * {@inheritDoc}
+ */
+ public function withPrefix(string $prefix = '')
+ {
+ $this->validatePrefix($prefix, $this->secure, $this->path, $this->domain);
+
+ $cookie = clone $this;
+
+ $cookie->prefix = $prefix;
+
+ return $cookie;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function withName(string $name)
+ {
+ $this->validateName($name, $this->raw);
+
+ $cookie = clone $this;
+
+ $cookie->name = $name;
+
+ return $cookie;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function withValue(string $value)
+ {
+ $cookie = clone $this;
+
+ $cookie->value = $value;
+
+ return $cookie;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function withExpires($expires)
+ {
+ $cookie = clone $this;
+
+ $cookie->expires = static::convertExpiresTimestamp($expires);
+
+ return $cookie;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function withExpired()
+ {
+ $cookie = clone $this;
+
+ $cookie->expires = 0;
+
+ return $cookie;
+ }
+
+ /**
+ * @deprecated See https://github.com/codeigniter4/CodeIgniter4/pull/6413
+ */
+ public function withNeverExpiring()
+ {
+ $cookie = clone $this;
+
+ $cookie->expires = Time::now()->getTimestamp() + 5 * YEAR;
+
+ return $cookie;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function withPath(?string $path)
+ {
+ $path = $path ?: self::$defaults['path'];
+ $this->validatePrefix($this->prefix, $this->secure, $path, $this->domain);
+
+ $cookie = clone $this;
+
+ $cookie->path = $path;
+
+ return $cookie;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function withDomain(?string $domain)
+ {
+ $domain ??= self::$defaults['domain'];
+ $this->validatePrefix($this->prefix, $this->secure, $this->path, $domain);
+
+ $cookie = clone $this;
+
+ $cookie->domain = $domain;
+
+ return $cookie;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function withSecure(bool $secure = true)
+ {
+ $this->validatePrefix($this->prefix, $secure, $this->path, $this->domain);
+ $this->validateSameSite($this->samesite, $secure);
+
+ $cookie = clone $this;
+
+ $cookie->secure = $secure;
+
+ return $cookie;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function withHTTPOnly(bool $httponly = true)
+ {
+ $cookie = clone $this;
+
+ $cookie->httponly = $httponly;
+
+ return $cookie;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function withSameSite(string $samesite)
+ {
+ $this->validateSameSite($samesite, $this->secure);
+
+ $cookie = clone $this;
+
+ $cookie->samesite = ucfirst(strtolower($samesite));
+
+ return $cookie;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function withRaw(bool $raw = true)
+ {
+ $this->validateName($this->name, $raw);
+
+ $cookie = clone $this;
+
+ $cookie->raw = $raw;
+
+ return $cookie;
+ }
+
+ // =========================================================================
+ // ARRAY ACCESS FOR BC
+ // =========================================================================
+
+ /**
+ * Whether an offset exists.
+ *
+ * @param string $offset
+ */
+ public function offsetExists($offset): bool
+ {
+ return $offset === 'expire' ? true : property_exists($this, $offset);
+ }
+
+ /**
+ * Offset to retrieve.
+ *
+ * @param string $offset
+ *
+ * @return bool|int|string
+ *
+ * @throws InvalidArgumentException
+ */
+ #[ReturnTypeWillChange]
+ public function offsetGet($offset)
+ {
+ if (! $this->offsetExists($offset)) {
+ throw new InvalidArgumentException(sprintf('Undefined offset "%s".', $offset));
+ }
+
+ return $offset === 'expire' ? $this->expires : $this->{$offset};
+ }
+
+ /**
+ * Offset to set.
+ *
+ * @param string $offset
+ * @param bool|int|string $value
+ *
+ * @throws LogicException
+ */
+ public function offsetSet($offset, $value): void
+ {
+ throw new LogicException(sprintf('Cannot set values of properties of %s as it is immutable.', static::class));
+ }
+
+ /**
+ * Offset to unset.
+ *
+ * @param string $offset
+ *
+ * @throws LogicException
+ */
+ public function offsetUnset($offset): void
+ {
+ throw new LogicException(sprintf('Cannot unset values of properties of %s as it is immutable.', static::class));
+ }
+
+ // =========================================================================
+ // CONVERTERS
+ // =========================================================================
+
+ /**
+ * {@inheritDoc}
+ */
+ public function toHeaderString(): string
+ {
+ return $this->__toString();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function __toString(): string
+ {
+ $cookieHeader = [];
+
+ if ($this->getValue() === '') {
+ $cookieHeader[] = $this->getPrefixedName() . '=deleted';
+ $cookieHeader[] = 'Expires=' . gmdate(self::EXPIRES_FORMAT, 0);
+ $cookieHeader[] = 'Max-Age=0';
+ } else {
+ $value = $this->isRaw() ? $this->getValue() : rawurlencode($this->getValue());
+
+ $cookieHeader[] = sprintf('%s=%s', $this->getPrefixedName(), $value);
+
+ if ($this->getExpiresTimestamp() !== 0) {
+ $cookieHeader[] = 'Expires=' . $this->getExpiresString();
+ $cookieHeader[] = 'Max-Age=' . $this->getMaxAge();
+ }
+ }
+
+ if ($this->getPath() !== '') {
+ $cookieHeader[] = 'Path=' . $this->getPath();
+ }
+
+ if ($this->getDomain() !== '') {
+ $cookieHeader[] = 'Domain=' . $this->getDomain();
+ }
+
+ if ($this->isSecure()) {
+ $cookieHeader[] = 'Secure';
+ }
+
+ if ($this->isHTTPOnly()) {
+ $cookieHeader[] = 'HttpOnly';
+ }
+
+ $samesite = $this->getSameSite();
+
+ if ($samesite === '') {
+ // modern browsers warn in console logs that an empty SameSite attribute
+ // will be given the `Lax` value
+ $samesite = self::SAMESITE_LAX;
+ }
+
+ $cookieHeader[] = 'SameSite=' . ucfirst(strtolower($samesite));
+
+ return implode('; ', $cookieHeader);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function toArray(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'value' => $this->value,
+ 'prefix' => $this->prefix,
+ 'raw' => $this->raw,
+ ] + $this->getOptions();
+ }
+
+ /**
+ * Converts expires time to Unix format.
+ *
+ * @param DateTimeInterface|int|string $expires
+ */
+ protected static function convertExpiresTimestamp($expires = 0): int
+ {
+ if ($expires instanceof DateTimeInterface) {
+ $expires = $expires->format('U');
+ }
+
+ if (! is_string($expires) && ! is_int($expires)) {
+ throw CookieException::forInvalidExpiresTime(gettype($expires));
+ }
+
+ if (! is_numeric($expires)) {
+ $expires = strtotime($expires);
+
+ if ($expires === false) {
+ throw CookieException::forInvalidExpiresValue();
+ }
+ }
+
+ return $expires > 0 ? (int) $expires : 0;
+ }
+
+ // =========================================================================
+ // VALIDATION
+ // =========================================================================
+
+ /**
+ * Validates the cookie name per RFC 2616.
+ *
+ * If `$raw` is true, names should not contain invalid characters
+ * as `setrawcookie()` will reject this.
+ *
+ * @throws CookieException
+ */
+ protected function validateName(string $name, bool $raw): void
+ {
+ if ($raw && strpbrk($name, self::$reservedCharsList) !== false) {
+ throw CookieException::forInvalidCookieName($name);
+ }
+
+ if ($name === '') {
+ throw CookieException::forEmptyCookieName();
+ }
+ }
+
+ /**
+ * Validates the special prefixes if some attribute requirements are met.
+ *
+ * @throws CookieException
+ */
+ protected function validatePrefix(string $prefix, bool $secure, string $path, string $domain): void
+ {
+ if (str_starts_with($prefix, '__Secure-') && ! $secure) {
+ throw CookieException::forInvalidSecurePrefix();
+ }
+
+ if (str_starts_with($prefix, '__Host-') && (! $secure || $domain !== '' || $path !== '/')) {
+ throw CookieException::forInvalidHostPrefix();
+ }
+ }
+
+ /**
+ * Validates the `SameSite` to be within the allowed types.
+ *
+ * @throws CookieException
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
+ */
+ protected function validateSameSite(string $samesite, bool $secure): void
+ {
+ if ($samesite === '') {
+ $samesite = self::$defaults['samesite'];
+ }
+
+ if ($samesite === '') {
+ $samesite = self::SAMESITE_LAX;
+ }
+
+ if (! in_array(strtolower($samesite), self::ALLOWED_SAMESITE_VALUES, true)) {
+ throw CookieException::forInvalidSameSite($samesite);
+ }
+
+ if (strtolower($samesite) === self::SAMESITE_NONE && ! $secure) {
+ throw CookieException::forInvalidSameSiteNone();
+ }
+ }
+}
diff --git a/system/Cookie/CookieInterface.php b/system/Cookie/CookieInterface.php
new file mode 100644
index 0000000..c848fa8
--- /dev/null
+++ b/system/Cookie/CookieInterface.php
@@ -0,0 +1,170 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Cookie;
+
+/**
+ * Interface for a value object representation of an HTTP cookie.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
+ */
+interface CookieInterface
+{
+ /**
+ * Cookies will be sent in all contexts, i.e in responses to both
+ * first-party and cross-origin requests. If `SameSite=None` is set,
+ * the cookie `Secure` attribute must also be set (or the cookie will be blocked).
+ */
+ public const SAMESITE_NONE = 'none';
+
+ /**
+ * Cookies are not sent on normal cross-site subrequests (for example to
+ * load images or frames into a third party site), but are sent when a
+ * user is navigating to the origin site (i.e. when following a link).
+ */
+ public const SAMESITE_LAX = 'lax';
+
+ /**
+ * Cookies will only be sent in a first-party context and not be sent
+ * along with requests initiated by third party websites.
+ */
+ public const SAMESITE_STRICT = 'strict';
+
+ /**
+ * RFC 6265 allowed values for the "SameSite" attribute.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
+ */
+ public const ALLOWED_SAMESITE_VALUES = [
+ self::SAMESITE_NONE,
+ self::SAMESITE_LAX,
+ self::SAMESITE_STRICT,
+ ];
+
+ /**
+ * Expires date format.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date
+ * @see https://tools.ietf.org/html/rfc7231#section-7.1.1.2
+ */
+ public const EXPIRES_FORMAT = 'D, d-M-Y H:i:s T';
+
+ /**
+ * Returns a unique identifier for the cookie consisting
+ * of its prefixed name, path, and domain.
+ */
+ public function getId(): string;
+
+ /**
+ * Gets the cookie prefix.
+ */
+ public function getPrefix(): string;
+
+ /**
+ * Gets the cookie name.
+ */
+ public function getName(): string;
+
+ /**
+ * Gets the cookie name prepended with the prefix, if any.
+ */
+ public function getPrefixedName(): string;
+
+ /**
+ * Gets the cookie value.
+ */
+ public function getValue(): string;
+
+ /**
+ * Gets the time in Unix timestamp the cookie expires.
+ */
+ public function getExpiresTimestamp(): int;
+
+ /**
+ * Gets the formatted expires time.
+ */
+ public function getExpiresString(): string;
+
+ /**
+ * Checks if the cookie is expired.
+ */
+ public function isExpired(): bool;
+
+ /**
+ * Gets the "Max-Age" cookie attribute.
+ */
+ public function getMaxAge(): int;
+
+ /**
+ * Gets the "Path" cookie attribute.
+ */
+ public function getPath(): string;
+
+ /**
+ * Gets the "Domain" cookie attribute.
+ */
+ public function getDomain(): string;
+
+ /**
+ * Gets the "Secure" cookie attribute.
+ *
+ * Checks if the cookie is only sent to the server when a request is made
+ * with the `https:` scheme (except on `localhost`), and therefore is more
+ * resistent to man-in-the-middle attacks.
+ */
+ public function isSecure(): bool;
+
+ /**
+ * Gets the "HttpOnly" cookie attribute.
+ *
+ * Checks if JavaScript is forbidden from accessing the cookie.
+ */
+ public function isHTTPOnly(): bool;
+
+ /**
+ * Gets the "SameSite" cookie attribute.
+ */
+ public function getSameSite(): string;
+
+ /**
+ * Checks if the cookie should be sent with no URL encoding.
+ */
+ public function isRaw(): bool;
+
+ /**
+ * Gets the options that are passable to the `setcookie` variant
+ * available on PHP 7.3+
+ *
+ * @return array
+ */
+ public function getOptions(): array;
+
+ /**
+ * Returns the Cookie as a header value.
+ */
+ public function toHeaderString(): string;
+
+ /**
+ * Returns the string representation of the Cookie object.
+ *
+ * @return string
+ */
+ public function __toString();
+
+ /**
+ * Returns the array representation of the Cookie object.
+ *
+ * @return array
+ */
+ public function toArray(): array;
+}
diff --git a/system/Cookie/CookieStore.php b/system/Cookie/CookieStore.php
new file mode 100644
index 0000000..05ebf99
--- /dev/null
+++ b/system/Cookie/CookieStore.php
@@ -0,0 +1,259 @@
+
+ *
+ * For the full copyright and license information, please view
+ * the LICENSE file that was distributed with this source code.
+ */
+
+namespace CodeIgniter\Cookie;
+
+use ArrayIterator;
+use CodeIgniter\Cookie\Exceptions\CookieException;
+use Countable;
+use IteratorAggregate;
+use Traversable;
+
+/**
+ * The CookieStore object represents an immutable collection of `Cookie` value objects.
+ *
+ * @implements IteratorAggregate
+ * @see \CodeIgniter\Cookie\CookieStoreTest
+ */
+class CookieStore implements Countable, IteratorAggregate
+{
+ /**
+ * The cookie collection.
+ *
+ * @var array
+ */
+ protected $cookies = [];
+
+ /**
+ * Creates a CookieStore from an array of `Set-Cookie` headers.
+ *
+ * @param list $headers
+ *
+ * @return static
+ *
+ * @throws CookieException
+ */
+ public static function fromCookieHeaders(array $headers, bool $raw = false)
+ {
+ /**
+ * @var list