diff --git a/README.md b/README.md index 3dcd3b5..1f95a41 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ We welcome contributions from the community. -Please read the [*Contributing to CodeIgniter*](https://github.com/codeigniter4/CodeIgniter4/blob/develop/contributing.md) section in the development repository. +Please read the [*Contributing to CodeIgniter*](https://github.com/codeigniter4/CodeIgniter4/blob/develop/CONTRIBUTING.md) section in the development repository. ## Server Requirements diff --git a/app/Config/App.php b/app/Config/App.php index 933b5c8..86b94d0 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -241,6 +241,8 @@ * Set a cookie name prefix if you need to avoid collisions. * * @var string + * + * @deprecated use Config\Cookie::$prefix property instead. */ public $cookiePrefix = ''; @@ -252,6 +254,8 @@ * Set to `.your-domain.com` for site-wide cookies. * * @var string + * + * @deprecated use Config\Cookie::$domain property instead. */ public $cookieDomain = ''; @@ -263,6 +267,8 @@ * Typically will be a forward slash. * * @var string + * + * @deprecated use Config\Cookie::$path property instead. */ public $cookiePath = '/'; @@ -274,19 +280,23 @@ * Cookie will only be set if a secure HTTPS connection exists. * * @var boolean + * + * @deprecated use Config\Cookie::$secure property instead. */ public $cookieSecure = false; /** * -------------------------------------------------------------------------- - * Cookie HTTP Only + * Cookie HttpOnly * -------------------------------------------------------------------------- * * Cookie will only be accessible via HTTP(S) (no JavaScript). * * @var boolean + * + * @deprecated use Config\Cookie::$httponly property instead. */ - public $cookieHTTPOnly = false; + public $cookieHTTPOnly = true; /** * -------------------------------------------------------------------------- @@ -299,11 +309,18 @@ * - Strict * - '' * - * Defaults to `Lax` for compatibility with modern browsers. Setting `''` - * (empty string) means no SameSite attribute will be set on cookies. If - * set to `None`, `$cookieSecure` must also be set. + * Alternatively, you can use the constant names: + * - `Cookie::SAMESITE_NONE` + * - `Cookie::SAMESITE_LAX` + * - `Cookie::SAMESITE_STRICT` * - * @var string 'Lax'|'None'|'Strict' + * Defaults to `Lax` for compatibility with modern browsers. Setting `''` + * (empty string) means default SameSite attribute set by browsers (`Lax`) + * will be set on cookies. If set to `None`, `$cookieSecure` must also be set. + * + * @var string + * + * @deprecated use Config\Cookie::$samesite property instead. */ public $cookieSameSite = 'Lax'; diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index fbd75b3..1ed5e3f 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -6,7 +6,7 @@ /** * ------------------------------------------------------------------- - * AUTO-LOADER + * AUTOLOADER CONFIGURATION * ------------------------------------------------------------------- * * This file defines the namespaces and class maps so the Autoloader @@ -31,12 +31,12 @@ * else you will need to modify all of those classes for this to work. * * Prototype: - * + *``` * $psr4 = [ * 'CodeIgniter' => SYSTEMPATH, * 'App' => APPPATH * ]; - * + *``` * @var array */ public $psr4 = [ @@ -55,12 +55,30 @@ * were being autoloaded through a namespace. * * Prototype: - * + *``` * $classmap = [ * 'MyClass' => '/path/to/class/file.php' * ]; - * + *``` * @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. + * + * Prototype: + * ``` + * $files = [ + * '/path/to/my/file.php', + * ]; + * ``` + * @var array + */ + public $files = []; } diff --git a/app/Config/ContentSecurityPolicy.php b/app/Config/ContentSecurityPolicy.php index e357cba..a04e3d0 100644 --- a/app/Config/ContentSecurityPolicy.php +++ b/app/Config/ContentSecurityPolicy.php @@ -125,6 +125,14 @@ public $frameAncestors = null; /** + * The frame-src directive restricts the URLs which may + * be loaded into nested browsing contexts. + * + * @var array|string|null + */ + public $frameSrc = null; + + /** * Restricts the origins allowed to deliver video and audio. * * @var string|string[]|null diff --git a/app/Config/Cookie.php b/app/Config/Cookie.php new file mode 100644 index 0000000..4e4e20d --- /dev/null +++ b/app/Config/Cookie.php @@ -0,0 +1,119 @@ +respond(); diff --git a/app/Config/Exceptions.php b/app/Config/Exceptions.php index c6c5a83..671b078 100644 --- a/app/Config/Exceptions.php +++ b/app/Config/Exceptions.php @@ -45,4 +45,16 @@ * @var string */ public $errorViewPath = APPPATH . 'Views/errors'; + + /** + * -------------------------------------------------------------------------- + * HIDE FROM DEBUG TRACE + * -------------------------------------------------------------------------- + * Any data that you would like to hide from the debug trace. + * In order to specify 2 levels, use "/" to separate. + * ex. ['server', 'setup/password', 'secret_token'] + * + * @var array + */ + public $sensitiveDataInTrace = []; } diff --git a/app/Config/Logger.php b/app/Config/Logger.php index 66f4039..0cee08d 100644 --- a/app/Config/Logger.php +++ b/app/Config/Logger.php @@ -129,12 +129,27 @@ * The ChromeLoggerHandler requires the use of the Chrome web browser * and the ChromeLogger extension. Uncomment this block to use it. */ - // 'CodeIgniter\Log\Handlers\ChromeLoggerHandler' => [ - // /* - // * The log levels that this handler will handle. - // */ - // 'handles' => ['critical', 'alert', 'emergency', 'debug', - // 'error', 'info', 'notice', 'warning'], - // ] + // 'CodeIgniter\Log\Handlers\ChromeLoggerHandler' => [ + // /* + // * The log levels that this handler will handle. + // */ + // 'handles' => ['critical', 'alert', 'emergency', 'debug', + // 'error', 'info', 'notice', 'warning'], + // ], + + /** + * The ErrorlogHandler writes the logs to PHP's native `error_log()` function. + * Uncomment this block to use it. + */ + // 'CodeIgniter\Log\Handlers\ErrorlogHandler' => [ + // /* The log levels this handler can handle. */ + // 'handles' => ['critical', 'alert', 'emergency', 'debug', 'error', 'info', 'notice', 'warning'], + // + // /* + // * The message type where the error should go. Can be 0 or 4, or use the + // * class constants: `ErrorlogHandler::TYPE_OS` (0) or `ErrorlogHandler::TYPE_SAPI` (4) + // */ + // 'messageType' => 0, + // ], ]; } diff --git a/app/Config/Mimes.php b/app/Config/Mimes.php index 0121ea1..7a32e84 100644 --- a/app/Config/Mimes.php +++ b/app/Config/Mimes.php @@ -335,6 +335,8 @@ 'application/msword', 'application/x-zip', ], + 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12', + 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12', 'word' => [ 'application/msword', 'application/octet-stream', diff --git a/app/Config/Modules.php b/app/Config/Modules.php index 8c1d049..0814a0c 100644 --- a/app/Config/Modules.php +++ b/app/Config/Modules.php @@ -12,7 +12,7 @@ * -------------------------------------------------------------------------- * * If true, then auto-discovery will happen across all elements listed in - * $activeExplorers below. If false, no auto-discovery will happen at all, + * $aliases below. If false, no auto-discovery will happen at all, * giving a slight performance boost. * * @var boolean diff --git a/app/Config/Security.php b/app/Config/Security.php index 50cd8c3..afdfd2e 100644 --- a/app/Config/Security.php +++ b/app/Config/Security.php @@ -84,9 +84,12 @@ * Allowed values are: None - Lax - Strict - ''. * * Defaults to `Lax` as recommended in this link: + * * @see https://portswigger.net/web-security/csrf/samesite-cookies * - * @var string 'Lax'|'None'|'Strict' + * @var string + * + * @deprecated */ public $samesite = 'Lax'; } diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index f92bd67..5a1946b 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -3,6 +3,8 @@ namespace App\Controllers; use CodeIgniter\Controller; +use CodeIgniter\HTTP\CLIRequest; +use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Psr\Log\LoggerInterface; @@ -21,6 +23,13 @@ class BaseController extends Controller { /** + * Instance of the main Request object. + * + * @var IncomingRequest|CLIRequest + */ + protected $request; + + /** * An array of helpers to be loaded automatically upon * class instantiation. These helpers will be available * to all other controllers that extend BaseController. diff --git a/app/Views/errors/html/debug.css b/app/Views/errors/html/debug.css index f334c7a..384d66d 100644 --- a/app/Views/errors/html/debug.css +++ b/app/Views/errors/html/debug.css @@ -1,8 +1,18 @@ +:root { + --main-bg-color: #fff; + --main-text-color: #555; + --dark-text-color: #222; + --light-text-color: #c7c7c7; + --brand-primary-color: #E06E3F; + --light-bg-color: #ededee; + --dark-bg-color: #404040; +} + body { height: 100%; - background: #fafafa; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - color: #777; + background: var(--main-bg-color); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + color: var(--main-text-color); font-weight: 300; margin: 0; padding: 0; @@ -11,7 +21,7 @@ font-weight: lighter; letter-spacing: 0.8; font-size: 3rem; - color: #222; + color: var(--dark-text-color); margin: 0; } h1.headline { @@ -30,11 +40,15 @@ padding: 1rem; } .header { - background: #85271f; - color: #fff; + background: var(--light-bg-color); + color: var(--dark-text-color); +} +.header .container { + padding: 1rem 1.75rem 1.75rem 1.75rem; } .header h1 { - color: #fff; + font-size: 2.5rem; + font-weight: 500; } .header p { font-size: 1.2rem; @@ -42,7 +56,7 @@ line-height: 2.5; } .header a { - color: rgba(255,255,255,0.5); + color: var(--brand-primary-color); margin-left: 2rem; display: none; text-decoration: none; @@ -51,6 +65,10 @@ display: inline; } +.footer { + background: var(--dark-bg-color); + color: var(--light-text-color); +} .footer .container { border-top: 1px solid #e7e7e7; margin-top: 1rem; @@ -58,11 +76,12 @@ } .source { - background: #333; - color: #c7c7c7; + background: #343434; + color: var(--light-text-color); padding: 0.5em 1em; border-radius: 5px; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; + font-size: 0.85rem; margin: 0; overflow-x: scroll; } @@ -74,8 +93,8 @@ } .source .line .highlight { display: block; - background: #555; - color: #fff; + background: var(--dark-text-color); + color: var(--light-text-color); } .source span.highlight .number { color: #fff; @@ -96,24 +115,25 @@ padding: 0rem 1rem; line-height: 2.7; text-decoration: none; - color: #a7a7a7; - background: #f1f1f1; - border: 1px solid #e7e7e7; + color: var(--dark-text-color); + background: var(--light-bg-color); + border: 1px solid rgba(0,0,0,0.15); border-bottom: 0; border-top-left-radius: 5px; border-top-right-radius: 5px; display: inline-block; } .tabs a:hover { - background: #e7e7e7; - border-color: #e1e1e1; + background: var(--light-bg-color); + border-color: rgba(0,0,0,0.15); } .tabs a.active { - background: #fff; + background: var(--main-bg-color); + color: var(--main-text-color); } .tab-content { - background: #fff; - border: 1px solid #efefef; + background: var(--main-bg-color); + border: 1px solid rgba(0,0,0,0.15); } .content { padding: 1rem; @@ -166,7 +186,7 @@ font-weight: bold; } .trace td { - background: #e7e7e7; + background: var(--light-bg-color); padding: 0 1rem; } .trace td pre { @@ -174,4 +194,4 @@ } .args { display: none; -} \ No newline at end of file +} diff --git a/app/Views/errors/html/error_404.php b/app/Views/errors/html/error_404.php index 0aa747b..1cca20c 100644 --- a/app/Views/errors/html/error_404.php +++ b/app/Views/errors/html/error_404.php @@ -5,68 +5,68 @@ 404 Page Not Found + div.logo { + height: 200px; + width: 155px; + display: inline-block; + opacity: 0.08; + position: absolute; + top: 2rem; + left: 50%; + margin-left: -73px; + } + body { + height: 100%; + background: #fafafa; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + color: #777; + font-weight: 300; + } + h1 { + font-weight: lighter; + letter-spacing: 0.8; + font-size: 3rem; + margin-top: 0; + margin-bottom: 0; + color: #222; + } + .wrap { + max-width: 1024px; + margin: 5rem auto; + padding: 2rem; + background: #fff; + text-align: center; + border: 1px solid #efefef; + border-radius: 0.5rem; + position: relative; + } + pre { + white-space: normal; + margin-top: 1.5rem; + } + code { + background: #fafafa; + border: 1px solid #efefef; + padding: 0.5rem 1rem; + border-radius: 5px; + display: block; + } + p { + margin-top: 1.5rem; + } + .footer { + margin-top: 2rem; + border-top: 1px solid #efefef; + padding: 1em 2em 0 2em; + font-size: 85%; + color: #999; + } + a:active, + a:link, + a:visited { + color: #dd4814; + } +
@@ -74,7 +74,7 @@

- + Sorry! Cannot seem to find the page you were looking for. diff --git a/app/Views/errors/html/error_exception.php b/app/Views/errors/html/error_exception.php index 43a916c..22b4ecd 100644 --- a/app/Views/errors/html/error_exception.php +++ b/app/Views/errors/html/error_exception.php @@ -21,8 +21,8 @@

getCode() ? ' #' . $exception->getCode() : '') ?>

- getMessage()) ?> - getMessage())) ?>" + getMessage())) ?> + getMessage())) ?>" rel="noreferrer" target="_blank">search →

@@ -43,12 +43,11 @@
diff --git a/app/Views/welcome_message.php b/app/Views/welcome_message.php index f2a7389..7050aa9 100644 --- a/app/Views/welcome_message.php +++ b/app/Views/welcome_message.php @@ -212,7 +212,7 @@
diff --git a/env b/env index 79abea2..1106ce4 100644 --- a/env +++ b/env @@ -25,26 +25,12 @@ # app.sessionDriver = 'CodeIgniter\Session\Handlers\FileHandler' # app.sessionCookieName = 'ci_session' +# app.sessionExpiration = 7200 # app.sessionSavePath = NULL # app.sessionMatchIP = false # app.sessionTimeToUpdate = 300 # app.sessionRegenerateDestroy = false -# app.cookiePrefix = '' -# app.cookieDomain = '' -# app.cookiePath = '/' -# app.cookieSecure = false -# app.cookieHTTPOnly = false -# app.cookieSameSite = 'Lax' - -# app.CSRFProtection = false -# app.CSRFTokenName = 'csrf_test_name' -# app.CSRFCookieName = 'csrf_cookie_name' -# app.CSRFExpire = 7200 -# app.CSRFRegenerate = true -# app.CSRFExcludeURIs = [] -# app.CSRFSameSite = 'Lax' - # app.CSPEnabled = false #-------------------------------------------------------------------- @@ -56,12 +42,14 @@ # database.default.username = root # database.default.password = root # database.default.DBDriver = MySQLi +# database.default.DBPrefix = # database.tests.hostname = localhost # database.tests.database = ci4 # database.tests.username = root # database.tests.password = root # database.tests.DBDriver = MySQLi +# database.tests.DBPrefix = #-------------------------------------------------------------------- # CONTENT SECURITY POLICY @@ -78,6 +66,7 @@ # contentsecuritypolicy.fontSrc = null # contentsecuritypolicy.formAction = null # contentsecuritypolicy.frameAncestors = null +# contentsecuritypolicy.frameSrc = null # contentsecuritypolicy.mediaSrc = null # contentsecuritypolicy.objectSrc = null # contentsecuritypolicy.pluginTypes = null @@ -86,6 +75,19 @@ # contentsecuritypolicy.upgradeInsecureRequests = false #-------------------------------------------------------------------- +# COOKIE +#-------------------------------------------------------------------- + +# cookie.prefix = '' +# cookie.expires = 0 +# cookie.path = '/' +# cookie.domain = '' +# cookie.secure = false +# cookie.httponly = false +# cookie.samesite = 'Lax' +# cookie.raw = false + +#-------------------------------------------------------------------- # ENCRYPTION #-------------------------------------------------------------------- @@ -108,13 +110,13 @@ # SECURITY #-------------------------------------------------------------------- -# security.tokenName = 'csrf_token_name' +# security.tokenName = 'csrf_token_name' # security.headerName = 'X-CSRF-TOKEN' # security.cookieName = 'csrf_cookie_name' -# security.expires = 7200 +# security.expires = 7200 # security.regenerate = true -# security.redirect = true -# security.samesite = 'Lax' +# security.redirect = true +# security.samesite = 'Lax' #-------------------------------------------------------------------- # LOGGER diff --git a/public/.htaccess b/public/.htaccess index 712c901..a5d6c2a 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -19,7 +19,7 @@ # Redirect Trailing Slashes... RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_URI} (.+)/$ - RewriteRule ^ %1 [L,R=301] + RewriteRule ^ %1 [L,R=301] # Rewrite "www.example.com -> example.com" RewriteCond %{HTTPS} !=on @@ -27,23 +27,23 @@ RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L] # Checks to see if the user is attempting to access a valid file, - # such as an image or css document, if this isn't true it sends the - # request to the front controller, index.php + # such as an image or css document, if this isn't true it sends the + # request to the front controller, index.php RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA] # Ensure Authorization header is passed along - RewriteCond %{HTTP:Authorization} . - RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] - # If we don't have mod_rewrite installed, all 404's - # can be sent to index.php, and everything works as normal. - ErrorDocument 404 index.php + # If we don't have mod_rewrite installed, all 404's + # can be sent to index.php, and everything works as normal. + ErrorDocument 404 index.php # Disable server signature start - ServerSignature Off + ServerSignature Off # Disable server signature end diff --git a/public/index.php b/public/index.php index 5cea047..cd60bae 100644 --- a/public/index.php +++ b/public/index.php @@ -1,13 +1,5 @@ showHeader(); +if (is_int($suppress = array_search('--no-header', $_SERVER['argv'], true))) +{ + unset($_SERVER['argv'][$suppress]); // @codeCoverageIgnore + $suppress = true; +} + +$console->showHeader($suppress); // fire off the command in the main framework. $response = $console->run(); diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php index 36d0038..8aa8682 100644 --- a/system/API/ResponseTrait.php +++ b/system/API/ResponseTrait.php @@ -280,12 +280,28 @@ * @param string $message * * @return mixed + * + * @deprecated Use failValidationErrors instead */ public 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 string|string[] $errors + * @param string|null $code + * @param string $message + * + * @return mixed + */ + public function failValidationErrors($errors, string $code = null, string $message = '') + { + return $this->fail($errors, $this->codes['invalid_data'], $code, $message); + } + //-------------------------------------------------------------------- /** diff --git a/system/Autoloader/Autoloader.php b/system/Autoloader/Autoloader.php index 845790b..273be08 100644 --- a/system/Autoloader/Autoloader.php +++ b/system/Autoloader/Autoloader.php @@ -11,6 +11,7 @@ namespace CodeIgniter\Autoloader; +use Composer\Autoload\ClassLoader; use Config\Autoload; use Config\Modules; use InvalidArgumentException; @@ -21,17 +22,17 @@ * 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' @@ -40,9 +41,9 @@ * 'MyClass' => '/path/to/class/file.php' * ] * ]; - * + *``` * Example: - * + *``` * register(); + *``` */ class Autoloader { /** * Stores namespaces as key, and path as values. * - * @var array + * @var array> */ protected $prefixes = []; /** * Stores class name as key, and path as values. * - * @var array + * @var array */ protected $classmap = []; - //-------------------------------------------------------------------- + /** + * Stores files as a list. + * + * @var array + */ + protected $files = []; /** * Reads in the configuration array (described above) and stores @@ -97,6 +104,11 @@ $this->classmap = $config->classmap; } + if (isset($config->files)) + { + $this->files = $config->files; + } + // Should we load through Composer's namespaces, also? if ($modules->discoverInComposer) { @@ -106,8 +118,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Register the loader with the SPL autoloader stack. */ @@ -117,22 +127,18 @@ spl_autoload_register([$this, 'loadClass'], true, true); // @phpstan-ignore-line // Now prepend another loader for the files in our class map. + spl_autoload_register([$this, 'loadClassmap'], true, true); // @phpstan-ignore-line - // @phpstan-ignore-next-line - spl_autoload_register(function ($class) { - if (empty($this->classmap[$class])) + // Load our non-class files + foreach ($this->files as $file) + { + if (is_string($file)) { - return false; + $this->includeFile($file); } - - include_once $this->classmap[$class]; - }, true, // Throw exception - true // Prepend - ); + } } - //-------------------------------------------------------------------- - /** * Registers namespaces with the autoloader. * @@ -170,8 +176,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Get namespaces with prefixes as keys and paths as values. * @@ -191,8 +195,6 @@ return $this->prefixes[trim($prefix, '\\')] ?? []; } - //-------------------------------------------------------------------- - /** * Removes a single namespace from the psr4 settings. * @@ -210,7 +212,24 @@ return $this; } - //-------------------------------------------------------------------- + /** + * Load a class using available class mapping. + * + * @param string $class + * + * @return string|false + */ + public function loadClassmap(string $class) + { + $file = $this->classmap[$class] ?? ''; + + if (is_string($file) && $file !== '') + { + return $this->includeFile($file); + } + + return false; + } /** * Loads the class file for a given class name. @@ -228,8 +247,6 @@ return $this->loadInNamespace($class); } - //-------------------------------------------------------------------- - /** * Loads the class file for a given class name. * @@ -276,8 +293,6 @@ return false; } - //-------------------------------------------------------------------- - /** * A central way to include a file. Split out primarily for testing purposes. * @@ -299,8 +314,6 @@ return false; } - //-------------------------------------------------------------------- - /** * Sanitizes a filename, replacing spaces with dashes. * @@ -312,13 +325,12 @@ * * @param string $filename * - * @return string The sanitized filename + * @return string The sanitized filename */ 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. + // 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. $filename = preg_replace('/[^0-9\p{L}\s\/\-\_\.\:\\\\]/u', '', $filename); @@ -329,21 +341,23 @@ return $filename; } - //-------------------------------------------------------------------- - /** - * Locates all PSR4 compatible namespaces from Composer. + * Locates autoload information from Composer, if available. + * + * @return void */ protected function discoverComposerNamespaces() { if (! is_file(COMPOSER_PATH)) { - return false; + return; } + /** @var ClassLoader $composer */ $composer = include COMPOSER_PATH; + $paths = $composer->getPrefixesPsr4(); + $classes = $composer->getClassMap(); - $paths = $composer->getPrefixesPsr4(); unset($composer); // Get rid of CodeIgniter so we don't have duplicates @@ -352,13 +366,14 @@ unset($paths['CodeIgniter\\']); } - // Composer stores namespaces with trailing slash. We don't. $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); } } diff --git a/system/Autoloader/FileLocator.php b/system/Autoloader/FileLocator.php index 1ee0b46..1774c51 100644 --- a/system/Autoloader/FileLocator.php +++ b/system/Autoloader/FileLocator.php @@ -22,12 +22,10 @@ /** * The Autoloader to use. * - * @var \CodeIgniter\Autoloader\Autoloader + * @var Autoloader */ protected $autoloader; - //-------------------------------------------------------------------- - /** * Constructor * @@ -38,15 +36,13 @@ $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 namespaced file to locate - * @param string $folder The folder within the namespace that we should look for the file. - * @param string $ext The file extension the file should have. + * @param string $file The namespaced file to locate + * @param string|null $folder The folder within the namespace that we should look for the file. + * @param string $ext The file extension the file should have. * * @return string|false The path to the file, or false if not found. */ @@ -55,7 +51,7 @@ $file = $this->ensureExt($file, $ext); // Clears the folder name if it is at the beginning of the filename - if (! empty($folder) && ($pos = strpos($file, $folder)) === 0) + if (! empty($folder) && strpos($file, $folder) === 0) { $file = substr($file, strlen($folder . '/')); } @@ -92,6 +88,7 @@ { continue; } + $paths = $namespaces[$prefix]; $filename = implode('/', $segments); @@ -128,8 +125,6 @@ return false; } - //-------------------------------------------------------------------- - /** * Examines a file and returns the fully qualified domain name. * @@ -168,6 +163,7 @@ { $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) @@ -185,8 +181,6 @@ 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. @@ -218,20 +212,19 @@ if (isset($namespace['path']) && is_file($namespace['path'] . $path)) { $fullPath = $namespace['path'] . $path; + $fullPath = realpath($fullPath) ?: $fullPath; + if ($prioritizeApp) { $foundPaths[] = $fullPath; } + elseif (strpos($fullPath, APPPATH) === 0) + { + $appPaths[] = $fullPath; + } else { - if (strpos($fullPath, APPPATH) === 0) - { - $appPaths[] = $fullPath; - } - else - { - $foundPaths[] = $fullPath; - } + $foundPaths[] = $fullPath; } } } @@ -247,8 +240,6 @@ return $foundPaths; } - //-------------------------------------------------------------------- - /** * Ensures a extension is at the end of a filename * @@ -272,8 +263,6 @@ return $path; } - //-------------------------------------------------------------------- - /** * Return the namespace mappings we know about. * @@ -312,8 +301,6 @@ return $namespaces; } - //-------------------------------------------------------------------- - /** * Find the qualified name of a file according to * the namespace of the first matched namespace path. @@ -346,6 +333,7 @@ ltrim(str_replace('/', '\\', mb_substr( $path, mb_strlen($namespace['path'])) ), '\\'); + // Remove the file extension (.php) $className = mb_substr($className, 0, -4); @@ -360,8 +348,6 @@ return false; } - //-------------------------------------------------------------------- - /** * Scans the defined namespaces, returning a list of all files * that are contained within the subpath specified by $path. @@ -401,8 +387,6 @@ return $files; } - //-------------------------------------------------------------------- - /** * Scans the provided namespace, returning a list of all files * that are contained within the subpath specified by $path. @@ -444,8 +428,6 @@ return $files; } - //-------------------------------------------------------------------- - /** * Checks the app folder to see if the file can be found. * Only for use with filenames that DO NOT include namespacing. diff --git a/system/BaseModel.php b/system/BaseModel.php index 6323c77..75947ad 100644 --- a/system/BaseModel.php +++ b/system/BaseModel.php @@ -12,12 +12,14 @@ namespace CodeIgniter; use Closure; +use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; use CodeIgniter\Exceptions\ModelException; use CodeIgniter\I18n\Time; use CodeIgniter\Pager\Pager; +use CodeIgniter\Validation\Validation; use CodeIgniter\Validation\ValidationInterface; use Config\Services; use InvalidArgumentException; @@ -44,8 +46,6 @@ */ abstract class BaseModel { - // region Properties - /** * Pager instance. * Populated after calling $this->paginate() @@ -160,7 +160,7 @@ /** * Database Connection * - * @var object + * @var BaseConnection */ protected $db; @@ -200,7 +200,7 @@ /** * Our validator instance. * - * @var ValidationInterface + * @var Validation */ protected $validation; @@ -288,10 +288,6 @@ */ protected $afterDelete = []; - // endregion - - // region Constructor - /** * BaseModel constructor. * @@ -302,12 +298,23 @@ $this->tempReturnType = $this->returnType; $this->tempUseSoftDeletes = $this->useSoftDeletes; $this->tempAllowCallbacks = $this->allowCallbacks; - $this->validation = $validation ?? Services::validation(null, false); + + /** + * @var Validation $validation + */ + $validation = $validation ?? Services::validation(null, false); + $this->validation = $validation; + + $this->initialize(); } - // endregion - - // region Abstract Methods + /** + * Initializes the instance with any additional steps. + * Optionally implemented by child classes. + */ + protected function initialize() + { + } /** * Fetches the row of database @@ -357,7 +364,7 @@ * * @param array $data Data * - * @return object|integer|string|false + * @return integer|string|boolean */ abstract protected function doInsert(array $data); @@ -407,7 +414,7 @@ * @param integer|string|array|null $id The rows primary key(s) * @param boolean $purge Allows overriding the soft deletes setting. * - * @return object|boolean + * @return string|boolean * * @throws DatabaseException */ @@ -456,10 +463,27 @@ * @param array|object $data Data * * @return integer|array|string|null + * + * @deprecated Add an override on getIdValue() instead. Will be removed in version 5.0. */ abstract protected function idValue($data); /** + * Public getter to return the id value using the idValue() method + * For example with SQL this will return $data->$this->primaryKey + * + * @param array|object $data + * + * @return array|integer|string|null + * + * @todo: Make abstract in version 5.0 + */ + public function getIdValue($data) + { + return $this->idValue($data); + } + + /** * Override countAllResults to account for soft deleted accounts. * This methods works only with dbCalls * @@ -483,10 +507,6 @@ */ abstract public function chunk(int $size, Closure $userFunc); - // endregion - - // region CRUD & Finders - /** * Fetches the row of database * @@ -548,7 +568,7 @@ throw DataException::forFindColumnHaveMultipleColumns(); } - $resultSet = $resultSet = $this->doFindColumn($columnName); + $resultSet = $this->doFindColumn($columnName); return $resultSet ? array_column($resultSet, $columnName) : null; } @@ -660,17 +680,13 @@ if ($this->shouldUpdate($data)) { - $response = $this->update($this->idValue($data), $data); + $response = $this->update($this->getIdValue($data), $data); } else { $response = $this->insert($data, false); - if ($response instanceof BaseResult) - { - $response = $response->resultID !== false; - } - elseif ($response !== false) + if ($response !== false) { $response = true; } @@ -688,7 +704,7 @@ */ protected function shouldUpdate($data) : bool { - return ! empty($this->idValue($data)); + return ! empty($this->getIdValue($data)); } /** @@ -708,7 +724,7 @@ * @param array|object|null $data Data * @param boolean $returnID Whether insert ID should be returned or not. * - * @return BaseResult|object|integer|string|false + * @return integer|string|boolean * * @throws ReflectionException */ @@ -1087,33 +1103,24 @@ * 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 boolean $forceDB Always grab the db error, not validation * - * @return array|null + * @return array */ public function errors(bool $forceDB = false) { // Do we have validation errors? - if (! $forceDB && ! $this->skipValidation) + if (! $forceDB && ! $this->skipValidation && ($errors = $this->validation->getErrors())) { - $errors = $this->validation->getErrors(); - - if (! empty($errors)) - { - return $errors; - } + return $errors; } - $error = $this->doErrors(); - - return $error['message'] ?? null; + return $this->doErrors(); } - // endregion - - // region Pager - /** * Works with Pager to get the size and offset parameters. * Expects a GET variable (?page=2) that specifies the page of results @@ -1144,10 +1151,6 @@ return $this->findAll($perPage, $offset); } - // endregion - - // region Allowed Fields - /** * It could be used when you have to change default or override current allowed fields. * @@ -1202,7 +1205,7 @@ throw DataException::forInvalidAllowedFields(get_class($this)); } - foreach ($data as $key => $val) + foreach (array_keys($data) as $key) { if (! in_array($key, $this->allowedFields, true)) { @@ -1213,10 +1216,6 @@ return $data; } - // endregion - - // region Timestamps - /** * Sets the date or current date if null value is passed * @@ -1291,10 +1290,6 @@ } } - // endregion - - // region Validation - /** * Set the value of the skipValidation flag. * @@ -1437,7 +1432,6 @@ // or an array of rules. if (is_string($rules)) { - // @phpstan-ignore-next-line $rules = $this->validation->loadRuleGroup($rules); } @@ -1481,7 +1475,7 @@ return []; } - foreach ($rules as $field => $rule) + foreach (array_keys($rules) as $field) { if (! array_key_exists($field, $data)) { @@ -1492,10 +1486,6 @@ return $rules; } - // endregion - - // region Callbacks - /** * Sets $tempAllowCallbacks value so that we can temporarily override * the setting. Resets after the next method that uses triggers. @@ -1554,10 +1544,6 @@ return $eventData; } - // endregion - - // region Utility - /** * Sets the return type of the results to be as an associative array. * @@ -1707,10 +1693,6 @@ return $data; } - // endregion - - // region Magic - /** * Provides the db connection and model's properties. * @@ -1746,13 +1728,7 @@ { return true; } - - if (isset($this->db->$name)) - { - return true; - } - - return false; + return isset($this->db->$name); } /** @@ -1765,20 +1741,14 @@ */ public function __call(string $name, array $params) { - $result = null; - if (method_exists($this->db, $name)) { - $result = $this->db->{$name}(...$params); + return $this->db->{$name}(...$params); } - return $result; + return null; } - // endregion - - // region Deprecated - /** * Replace any placeholders within the rules with the values that * match the 'key' of any properties being set. For example, if @@ -1839,6 +1809,4 @@ return $rules; } - - // endregion } diff --git a/system/CLI/BaseCommand.php b/system/CLI/BaseCommand.php index 996f4b0..54bf4c9 100644 --- a/system/CLI/BaseCommand.php +++ b/system/CLI/BaseCommand.php @@ -16,7 +16,16 @@ use Throwable; /** - * Class BaseCommand + * BaseCommand is the base class used in creating CLI commands. + * + * @property string $group + * @property string $name + * @property string $usage + * @property string $description + * @property array $options + * @property array $arguments + * @property LoggerInterface $logger + * @property Commands $commands */ abstract class BaseCommand { @@ -78,8 +87,6 @@ */ protected $commands; - //-------------------------------------------------------------------- - /** * BaseCommand constructor. * @@ -92,8 +99,6 @@ $this->commands = $commands; } - //-------------------------------------------------------------------- - /** * Actually execute a command. * This has to be over-ridden in any concrete implementation. @@ -102,8 +107,6 @@ */ abstract public function run(array $params); - //-------------------------------------------------------------------- - /** * Can be used by a command to run other commands. * @@ -118,8 +121,6 @@ return $this->commands->run($command, $params); } - //-------------------------------------------------------------------- - /** * A simple method to display an error with line/file, in child commands. * @@ -133,8 +134,6 @@ require APPPATH . 'Views/errors/cli/error_exception.php'; } - //-------------------------------------------------------------------- - /** * Show Help includes (Usage, Arguments, Description, Options). */ @@ -190,8 +189,6 @@ } } - //-------------------------------------------------------------------- - /** * Pads our string out so that all titles are the same length to nicely line up descriptions. * @@ -209,8 +206,6 @@ return str_pad(str_repeat(' ', $indent) . $item, $max); } - //-------------------------------------------------------------------- - /** * Get pad for $key => $value array output * @@ -226,15 +221,15 @@ public function getPad(array $array, int $pad): int { $max = 0; - foreach ($array as $key => $value) + + foreach (array_keys($array) as $key) { $max = max($max, strlen($key)); } + return $max + $pad; } - //-------------------------------------------------------------------- - /** * Makes it simple to access our protected properties. * @@ -252,8 +247,6 @@ return null; } - //-------------------------------------------------------------------- - /** * Makes it simple to check our protected properties. * diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 4334d2b..1f311ad 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -13,6 +13,7 @@ use CodeIgniter\CLI\Exceptions\CLIException; use Config\Services; +use InvalidArgumentException; use Throwable; /** @@ -220,17 +221,27 @@ * * @param string $field Output "field" question * @param string|array $options String to a default value, array to a list of options (the first option will be the default value) - * @param string $validation Validation rules + * @param string|array $validation Validation rules * * @return string The user input * * @codeCoverageIgnore */ - public static function prompt(string $field, $options = null, string $validation = null): string + 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, 'white') . ']'; @@ -250,10 +261,8 @@ } else { - $extraOutput = ' [' . $extraOutputDefault . ', ' . implode(', ', $opts) . ']'; - $validation .= '|in_list[' . implode(',', $options) . ']'; - - $validation = trim($validation, '|'); + $extraOutput = ' [' . $extraOutputDefault . ', ' . implode(', ', $opts) . ']'; + $validation[] = 'in_list[' . implode(',', $options) . ']'; } $default = $options[0]; @@ -264,7 +273,7 @@ // Read the input from keyboard. $input = trim(static::input()) ?: $default; - if (isset($validation)) + if ($validation) { while (! static::validate($field, $input, $validation)) { @@ -280,20 +289,25 @@ /** * Validate one prompt "field" at a time * - * @param string $field Prompt "field" output - * @param string $value Input value - * @param string $rules Validation rules + * @param string $field Prompt "field" output + * @param string $value Input value + * @param string|array $rules Validation rules * * @return boolean * * @codeCoverageIgnore */ - protected static function validate(string $field, string $value, string $rules): bool + protected static function validate(string $field, string $value, $rules): bool { $label = $field; $field = 'temp'; $validation = Services::validation(null, false); - $validation->setRule($field, $label, $rules); + $validation->setRules([ + $field => [ + 'label' => $label, + 'rules' => $rules, + ], + ]); $validation->run([$field => $value]); if ($validation->hasError($field)) @@ -403,7 +417,6 @@ if ($countdown === true) { $time = $seconds; - while ($time > 0) { static::fwrite(STDOUT, $time . '... '); @@ -412,20 +425,17 @@ } static::write(); } + elseif ($seconds > 0) + { + sleep($seconds); + } else { - if ($seconds > 0) - { - sleep($seconds); - } - else - { - // this chunk cannot be tested because of keyboard input - // @codeCoverageIgnoreStart - static::write(static::$wait_msg); - static::input(); - // @codeCoverageIgnoreEnd - } + // this chunk cannot be tested because of keyboard input + // @codeCoverageIgnoreStart + static::write(static::$wait_msg); + static::input(); + // @codeCoverageIgnoreEnd } } @@ -716,33 +726,27 @@ $output = []; exec('mode CON', $output, $return); - if ($return === 0 && $output) + // 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)) { - // Look for the next lines ending in ": " - // Searching for "Columns:" or "Lines:" will fail on non-English locales - if (preg_match('/:\s*(\d+)\n[^:]+:\s*(\d+)\n/', implode("\n", $output), $matches)) - { - static::$height = (int) $matches[1]; - static::$width = (int) $matches[2]; - } + static::$height = (int) $matches[1]; + static::$width = (int) $matches[2]; } } // @codeCoverageIgnoreEnd } + elseif (($size = exec('stty size')) && preg_match('/(\d+)\s+(\d+)/', $size, $matches)) + { + static::$height = (int) $matches[1]; + static::$width = (int) $matches[2]; + } else { - if (($size = exec('stty size')) && preg_match('/(\d+)\s+(\d+)/', $size, $matches)) - { - static::$height = (int) $matches[1]; - static::$width = (int) $matches[2]; - } - else - { - // @codeCoverageIgnoreStart - static::$height = (int) exec('tput lines'); - static::$width = (int) exec('tput cols'); - // @codeCoverageIgnoreEnd - } + // @codeCoverageIgnoreStart + static::$height = (int) exec('tput lines'); + static::$width = (int) exec('tput cols'); + // @codeCoverageIgnoreEnd } } // @codeCoverageIgnoreStart diff --git a/system/CLI/CommandRunner.php b/system/CLI/CommandRunner.php index 05763dd..e1643b6 100644 --- a/system/CLI/CommandRunner.php +++ b/system/CLI/CommandRunner.php @@ -21,14 +21,12 @@ class CommandRunner extends Controller { /** - * The Command Manager + * Instance of class managing the collection of commands * * @var Commands */ protected $commands; - //-------------------------------------------------------------------- - /** * Constructor */ @@ -58,8 +56,6 @@ return $this->index($params); } - //-------------------------------------------------------------------- - /** * Default command. * @@ -70,14 +66,9 @@ */ public function index(array $params) { - $command = array_shift($params); + $command = array_shift($params) ?? 'list'; - if (is_null($command)) - { - $command = 'list'; - } - - return service('commands')->run($command, $params); + return $this->commands->run($command, $params); } /** diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php index d3ce7f6..cdbbed0 100644 --- a/system/CLI/Commands.php +++ b/system/CLI/Commands.php @@ -11,14 +11,12 @@ namespace CodeIgniter\CLI; +use CodeIgniter\Autoloader\FileLocator; use CodeIgniter\Log\Logger; -use Config\Services; use ReflectionClass; use ReflectionException; /** - * Class Commands - * * Core functionality for running, listing, etc commands. */ class Commands @@ -44,7 +42,8 @@ */ public function __construct($logger = null) { - $this->logger = $logger ?? Services::logger(); + $this->logger = $logger ?? service('logger'); + $this->discoverCommands(); } /** @@ -55,8 +54,6 @@ */ public function run(string $command, array $params) { - $this->discoverCommands(); - if (! $this->verifyCommand($command, $this->commands)) { return; @@ -77,39 +74,39 @@ */ public function getCommands() { - $this->discoverCommands(); - 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 (! empty($this->commands)) + if ($this->commands !== []) { return; } - $files = service('locator')->listFiles('Commands/'); + /** @var FileLocator $locator */ + $locator = service('locator'); + $files = $locator->listFiles('Commands/'); // If no matching command files were found, bail - if (empty($files)) + // This should never happen in unit testing. + if ($files === []) { - // This should never happen in unit testing. - // if it does, we have far bigger problems! - // @codeCoverageIgnoreStart - return; - // @codeCoverageIgnoreEnd + return; // @codeCoverageIgnore } // Loop over each file checking to see if a command with that - // alias exists in the class. If so, return it. Otherwise, try the next. + // alias exists in the class. foreach ($files as $file) { - $className = Services::locator()->findQualifiedNameFromPath($file); + $className = $locator->findQualifiedNameFromPath($file); + if (empty($className) || ! class_exists($className)) { continue; @@ -124,10 +121,10 @@ continue; } + /** @var BaseCommand $class */ $class = new $className($this->logger, $this); - // Store it! - if (! is_null($class->group)) + if (isset($class->group)) { $this->commands[$class->name] = [ 'class' => $className, @@ -137,7 +134,6 @@ ]; } - $class = null; unset($class); } catch (ReflectionException $e) @@ -200,7 +196,7 @@ { $alternatives = []; - foreach ($collection as $commandName => $attributes) + foreach (array_keys($collection) as $commandName) { $lev = levenshtein($name, $commandName); diff --git a/system/CLI/Console.php b/system/CLI/Console.php index c998de4..711184f 100644 --- a/system/CLI/Console.php +++ b/system/CLI/Console.php @@ -65,9 +65,16 @@ /** * Displays basic information about the Console. + * + * @param boolean $suppress */ - public function showHeader() + 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(); } diff --git a/system/CLI/GeneratorTrait.php b/system/CLI/GeneratorTrait.php index df484d8..d0e6bc5 100644 --- a/system/CLI/GeneratorTrait.php +++ b/system/CLI/GeneratorTrait.php @@ -42,6 +42,13 @@ protected $template; /** + * Language string key for required class names. + * + * @var string + */ + protected $classNameLang = ''; + + /** * Whether to require class name. * * @internal @@ -60,6 +67,15 @@ private $sortImports = true; /** + * Whether the `--suffix` option has any effect. + * + * @internal + * + * @var boolean + */ + private $enabledSuffixing = true; + + /** * The params array for easy access by other methods. * * @internal @@ -195,18 +211,27 @@ if (is_null($class) && $this->hasClassName) { // @codeCoverageIgnoreStart - $class = CLI::prompt(lang('CLI.generator.className'), null, 'required'); + $nameLang = $this->classNameLang ?: 'CLI.generator.className.default'; + $class = CLI::prompt(lang($nameLang), null, 'required'); CLI::newLine(); // @codeCoverageIgnoreEnd } helper('inflector'); - $component = strtolower(singular($this->component)); - $class = strtolower($class); - $class = strpos($class, $component) !== false ? str_replace($component, ucfirst($component), $class) : $class; + $component = singular($this->component); - if ($this->getOption('suffix') && ! strripos($class, $component)) + /** + * @see https://regex101.com/r/a5KNCR/1 + */ + $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') && ! strripos($class, $component)) { $class .= ucfirst($component); } @@ -261,9 +286,12 @@ { // Retrieves the namespace part from the fully qualified class name. $namespace = trim(implode('\\', array_slice(explode('\\', $class), 0, -1)), '\\'); - - array_push($search, '<@php', '{namespace}', '{class}'); - array_push($replace, 'renderTemplate($data)); } @@ -308,16 +336,14 @@ if (! $base = reset($base)) { - // @codeCoverageIgnoreStart CLI::error(lang('CLI.namespaceNotDefined', [$namespace]), 'light_gray', 'red'); CLI::newLine(); return ''; - // @codeCoverageIgnoreEnd } $base = realpath($base) ?: $base; - $file = $base . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, trim(str_replace($namespace, '', $class), '\\')) . '.php'; + $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); } @@ -351,6 +377,20 @@ } /** + * Allows child generators to modify the internal `$enabledSuffixing` flag. + * + * @param boolean $enabledSuffixing + * + * @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. * diff --git a/system/Cache/CacheInterface.php b/system/Cache/CacheInterface.php index a632b8d..8a929fa 100644 --- a/system/Cache/CacheInterface.php +++ b/system/Cache/CacheInterface.php @@ -41,7 +41,7 @@ * @param mixed $value The data to save * @param integer $ttl Time To Live, in seconds (default 60) * - * @return mixed + * @return boolean Success or failure */ public function save(string $key, $value, int $ttl = 60); @@ -52,7 +52,7 @@ * * @param string $key Cache item name * - * @return mixed + * @return boolean Success or failure */ public function delete(string $key); @@ -85,7 +85,7 @@ /** * Will delete all items in the entire cache. * - * @return mixed + * @return boolean Success or failure */ public function clean(); @@ -108,7 +108,10 @@ * * @param string $key Cache item name. * - * @return mixed + * @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); diff --git a/system/Cache/Exceptions/CacheException.php b/system/Cache/Exceptions/CacheException.php index 6186cc2..9020e0f 100644 --- a/system/Cache/Exceptions/CacheException.php +++ b/system/Cache/Exceptions/CacheException.php @@ -12,6 +12,7 @@ namespace CodeIgniter\Cache\Exceptions; use CodeIgniter\Exceptions\DebugTraceableTrait; +use CodeIgniter\Exceptions\ExceptionInterface; use RuntimeException; /** diff --git a/system/Cache/Exceptions/ExceptionInterface.php b/system/Cache/Exceptions/ExceptionInterface.php index 6a271d1..3713011 100644 --- a/system/Cache/Exceptions/ExceptionInterface.php +++ b/system/Cache/Exceptions/ExceptionInterface.php @@ -16,6 +16,8 @@ * of all framework-related exceptions. * * catch (\CodeIgniter\Cache\Exceptions\ExceptionInterface) { ... } + * + * @deprecated 4.1.2 */ interface ExceptionInterface { diff --git a/system/Cache/Handlers/BaseHandler.php b/system/Cache/Handlers/BaseHandler.php index 10c2b3e..a5de428 100644 --- a/system/Cache/Handlers/BaseHandler.php +++ b/system/Cache/Handlers/BaseHandler.php @@ -13,6 +13,8 @@ use Closure; use CodeIgniter\Cache\CacheInterface; +use Exception; +use InvalidArgumentException; /** * Base class for cache handling @@ -20,6 +22,56 @@ abstract class BaseHandler implements CacheInterface { /** + * Reserved characters that cannot be used in a key or tag. + * From https://github.com/symfony/cache-contracts/blob/c0446463729b89dd4fa62e9aeecc80287323615d/ItemInterface.php#L43 + */ + 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.'); + } + if (strpbrk($key, self::RESERVED_CHARACTERS) !== false) + { + throw new InvalidArgumentException('Cache key contains reserved characters ' . self::RESERVED_CHARACTERS); + } + + // 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 @@ -41,4 +93,18 @@ return $value; } + + //-------------------------------------------------------------------- + + /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @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 index b888a98..1a0f414 100644 --- a/system/Cache/Handlers/DummyHandler.php +++ b/system/Cache/Handlers/DummyHandler.php @@ -23,7 +23,6 @@ */ public function initialize() { - // Nothing to see here... } //-------------------------------------------------------------------- @@ -33,7 +32,7 @@ * * @param string $key Cache item name * - * @return mixed + * @return null */ public function get(string $key) { @@ -49,7 +48,7 @@ * @param integer $ttl Time to live * @param Closure $callback Callback return value * - * @return mixed + * @return null */ public function remember(string $key, int $ttl, Closure $callback) { @@ -65,7 +64,7 @@ * @param mixed $value The data to save * @param integer $ttl Time To Live, in seconds (default 60) * - * @return mixed + * @return boolean Success or failure */ public function save(string $key, $value, int $ttl = 60) { @@ -79,7 +78,7 @@ * * @param string $key Cache item name * - * @return boolean + * @return boolean Success or failure */ public function delete(string $key) { @@ -89,12 +88,26 @@ //-------------------------------------------------------------------- /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @return integer The number of deleted items + */ + public function deleteMatching(string $pattern) + { + return 0; + } + + //-------------------------------------------------------------------- + + /** * Performs atomic incrementation of a raw stored value. * * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return boolean */ public function increment(string $key, int $offset = 1) { @@ -109,7 +122,7 @@ * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return boolean */ public function decrement(string $key, int $offset = 1) { @@ -121,7 +134,7 @@ /** * Will delete all items in the entire cache. * - * @return boolean + * @return boolean Success or failure */ public function clean() { @@ -136,7 +149,7 @@ * The information returned and the structure of the data * varies depending on the handler. * - * @return mixed + * @return null */ public function getCacheInfo() { @@ -150,7 +163,7 @@ * * @param string $key Cache item name. * - * @return mixed + * @return null */ public function getMetaData(string $key) { @@ -168,6 +181,4 @@ { return true; } - - //-------------------------------------------------------------------- } diff --git a/system/Cache/Handlers/FileHandler.php b/system/Cache/Handlers/FileHandler.php index e1cb24c..91afb21 100644 --- a/system/Cache/Handlers/FileHandler.php +++ b/system/Cache/Handlers/FileHandler.php @@ -13,6 +13,7 @@ use CodeIgniter\Cache\Exceptions\CacheException; use Config\Cache; +use Throwable; /** * File system cache handler @@ -20,11 +21,9 @@ class FileHandler extends BaseHandler { /** - * Prefixed to all cache names. - * - * @var string + * Maximum key length. */ - protected $prefix; + public const MAX_KEY_LENGTH = 255; /** * Where to store cached files on the disk. @@ -70,7 +69,7 @@ } $this->mode = $config->file['mode'] ?? 0640; - $this->prefix = (string) $config->prefix; + $this->prefix = $config->prefix; } //-------------------------------------------------------------------- @@ -80,7 +79,6 @@ */ public function initialize() { - // Not to see here... } //-------------------------------------------------------------------- @@ -94,8 +92,7 @@ */ public function get(string $key) { - $key = $this->prefix . $key; - + $key = static::validateKey($key, $this->prefix); $data = $this->getItem($key); return is_array($data) ? $data['data'] : null; @@ -110,11 +107,11 @@ * @param mixed $value The data to save * @param integer $ttl Time To Live, in seconds (default 60) * - * @return mixed + * @return boolean Success or failure */ public function save(string $key, $value, int $ttl = 60) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); $contents = [ 'time' => time(), @@ -124,7 +121,16 @@ if ($this->writeFile($this->path . $key, serialize($contents))) { - chmod($this->path . $key, $this->mode); + try + { + chmod($this->path . $key, $this->mode); + } + // @codeCoverageIgnoreStart + catch (Throwable $e) + { + log_message('debug', 'Failed to set mode on cache file: ' . $e->getMessage()); + } + // @codeCoverageIgnoreEnd return true; } @@ -139,11 +145,11 @@ * * @param string $key Cache item name * - * @return boolean + * @return boolean Success or failure */ public function delete(string $key) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); return is_file($this->path . $key) && unlink($this->path . $key); } @@ -151,17 +157,40 @@ //-------------------------------------------------------------------- /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @return integer The number of deleted items + */ + 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; + } + + //-------------------------------------------------------------------- + + /** * Performs atomic incrementation of a raw stored value. * * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return boolean */ public function increment(string $key, int $offset = 1) { - $key = $this->prefix . $key; - + $key = static::validateKey($key, $this->prefix); $data = $this->getItem($key); if ($data === false) @@ -189,12 +218,11 @@ * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return boolean */ public function decrement(string $key, int $offset = 1) { - $key = $this->prefix . $key; - + $key = static::validateKey($key, $this->prefix); $data = $this->getItem($key); if ($data === false) @@ -219,7 +247,7 @@ /** * Will delete all items in the entire cache. * - * @return boolean + * @return boolean Success or failure */ public function clean() { @@ -234,7 +262,7 @@ * The information returned and the structure of the data * varies depending on the handler. * - * @return mixed + * @return array|false */ public function getCacheInfo() { @@ -248,36 +276,47 @@ * * @param string $key Cache item name. * - * @return mixed + * @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) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); if (! is_file($this->path . $key)) { - return false; + return false; // This will return null in a future release } $data = @unserialize(file_get_contents($this->path . $key)); - if (is_array($data)) + if (! is_array($data) || ! isset($data['ttl'])) { - $mtime = filemtime($this->path . $key); - - if (! isset($data['ttl'])) - { - return false; - } - - return [ - 'expire' => $mtime + $data['ttl'], - 'mtime' => $mtime, - 'data' => $data['data'], - ]; + return false; // This will return null in a future release } - return false; + // Consider expired items as missing + $expire = $data['time'] + $data['ttl']; + + // @phpstan-ignore-next-line + if ($data['ttl'] > 0 && time() > $expire) + { + // If the file is still there then remove it + if (is_file($this->path . $key)) + { + unlink($this->path . $key); + } + + return false; // This will return null in a future release + } + + return [ + 'expire' => $expire, + 'mtime' => filemtime($this->path . $key), + 'data' => $data['data'], + ]; } //-------------------------------------------------------------------- @@ -298,26 +337,26 @@ * Does the heavy lifting of actually retrieving the file and * verifying it's age. * - * @param string $key + * @param string $filename * * @return boolean|mixed */ - protected function getItem(string $key) + protected function getItem(string $filename) { - if (! is_file($this->path . $key)) + if (! is_file($this->path . $filename)) { return false; } - $data = unserialize(file_get_contents($this->path . $key)); + $data = unserialize(file_get_contents($this->path . $filename)); // @phpstan-ignore-next-line if ($data['ttl'] > 0 && time() > $data['time'] + $data['ttl']) { // If the file is still there then remove it - if (is_file($this->path . $key)) + if (is_file($this->path . $filename)) { - unlink($this->path . $key); + unlink($this->path . $filename); } return false; @@ -489,6 +528,8 @@ $returnedValues = explode(',', $returnedValues); } + $fileInfo = []; + foreach ($returnedValues as $key) { switch ($key) @@ -520,8 +561,6 @@ } } - return $fileInfo; // @phpstan-ignore-line + return $fileInfo; } - - //-------------------------------------------------------------------- } diff --git a/system/Cache/Handlers/MemcachedHandler.php b/system/Cache/Handlers/MemcachedHandler.php index ea1a9e3..7452330 100644 --- a/system/Cache/Handlers/MemcachedHandler.php +++ b/system/Cache/Handlers/MemcachedHandler.php @@ -23,13 +23,6 @@ class MemcachedHandler extends BaseHandler { /** - * Prefixed to all cache names. - * - * @var string - */ - protected $prefix; - - /** * The memcached object * * @var Memcached|Memcache @@ -57,7 +50,7 @@ */ public function __construct(Cache $config) { - $this->prefix = (string) $config->prefix; + $this->prefix = $config->prefix; if (! empty($config)) { @@ -166,7 +159,7 @@ */ public function get(string $key) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); if ($this->memcached instanceof Memcached) { @@ -202,11 +195,11 @@ * @param mixed $value The data to save * @param integer $ttl Time To Live, in seconds (default 60) * - * @return mixed + * @return boolean Success or failure */ public function save(string $key, $value, int $ttl = 60) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); if (! $this->config['raw']) { @@ -238,11 +231,11 @@ * * @param string $key Cache item name * - * @return boolean + * @return boolean Success or failure */ public function delete(string $key) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); return $this->memcached->delete($key); } @@ -250,12 +243,26 @@ //-------------------------------------------------------------------- /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @throws Exception + */ + 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.'); + } + + //-------------------------------------------------------------------- + + /** * Performs atomic incrementation of a raw stored value. * * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return integer|false */ public function increment(string $key, int $offset = 1) { @@ -264,7 +271,7 @@ return false; } - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); // @phpstan-ignore-next-line return $this->memcached->increment($key, $offset, $offset, 60); @@ -278,7 +285,7 @@ * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return integer|false */ public function decrement(string $key, int $offset = 1) { @@ -287,7 +294,7 @@ return false; } - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); //FIXME: third parameter isn't other handler actions. // @phpstan-ignore-next-line @@ -299,7 +306,7 @@ /** * Will delete all items in the entire cache. * - * @return boolean + * @return boolean Success or failure */ public function clean() { @@ -314,7 +321,7 @@ * The information returned and the structure of the data * varies depending on the handler. * - * @return mixed + * @return array|false */ public function getCacheInfo() { @@ -328,24 +335,29 @@ * * @param string $key Cache item name. * - * @return mixed + * @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) { - $key = $this->prefix . $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; + return false; // This will return null in a future release } - list($data, $time, $ttl) = $stored; + list($data, $time, $limit) = $stored; + + // Calculate the remaining time to live from the original limit + $ttl = time() - $time - $limit; return [ - 'expire' => $time + $ttl, + 'expire' => $limit > 0 ? $time + $limit : null, 'mtime' => $time, 'data' => $data, ]; @@ -360,6 +372,6 @@ */ public function isSupported(): bool { - return (extension_loaded('memcached') || extension_loaded('memcache')); + return extension_loaded('memcached') || extension_loaded('memcache'); } } diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 145d560..1892e81 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -15,6 +15,7 @@ use Config\Cache; use Exception; use Predis\Client; +use Predis\Collection\Iterator\Keyspace; /** * Predis cache handler @@ -22,13 +23,6 @@ class PredisHandler extends BaseHandler { /** - * Prefixed to all cache names. - * - * @var string - */ - protected $prefix; - - /** * Default config * * @var array @@ -57,7 +51,7 @@ */ public function __construct(Cache $config) { - $this->prefix = (string) $config->prefix; + $this->prefix = $config->prefix; if (isset($config->redis)) { @@ -100,10 +94,13 @@ */ public function get(string $key) { + $key = static::validateKey($key); + $data = array_combine([ '__ci_type', '__ci_value', - ], $this->redis->hmget($key, ['__ci_type', '__ci_value']) + ], + $this->redis->hmget($key, ['__ci_type', '__ci_value']) ); if (! isset($data['__ci_type'], $data['__ci_value']) || $data['__ci_value'] === false) @@ -137,10 +134,12 @@ * @param mixed $value The data to save * @param integer $ttl Time To Live, in seconds (default 60) * - * @return mixed + * @return boolean Success or failure */ public function save(string $key, $value, int $ttl = 60) { + $key = static::validateKey($key); + switch ($dataType = gettype($value)) { case 'array': @@ -175,11 +174,34 @@ * * @param string $key Cache item name * - * @return boolean + * @return boolean Success or failure */ public function delete(string $key) { - return ($this->redis->del($key) === 1); + $key = static::validateKey($key); + + return $this->redis->del($key) === 1; + } + + //-------------------------------------------------------------------- + + /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @return integer The number of deleted items + */ + public function deleteMatching(string $pattern) + { + $matchedKeys = []; + + foreach (new Keyspace($this->redis, $pattern) as $key) + { + $matchedKeys[] = $key; + } + + return $this->redis->del($matchedKeys); } //-------------------------------------------------------------------- @@ -190,10 +212,12 @@ * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return integer */ public function increment(string $key, int $offset = 1) { + $key = static::validateKey($key); + return $this->redis->hincrby($key, 'data', $offset); } @@ -205,10 +229,12 @@ * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return integer */ public function decrement(string $key, int $offset = 1) { + $key = static::validateKey($key); + return $this->redis->hincrby($key, 'data', -$offset); } @@ -217,7 +243,7 @@ /** * Will delete all items in the entire cache. * - * @return boolean + * @return boolean Success or failure */ public function clean() { @@ -232,7 +258,7 @@ * The information returned and the structure of the data * varies depending on the handler. * - * @return mixed + * @return array */ public function getCacheInfo() { @@ -246,17 +272,23 @@ * * @param string $key Cache item name. * - * @return mixed + * @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). */ 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(); + $ttl = $this->redis->ttl($key); + return [ - 'expire' => $time + $this->redis->ttl($key), + 'expire' => $ttl > 0 ? time() + $ttl : null, 'mtime' => $time, 'data' => $data['__ci_value'], ]; diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index a7fdcb9..f8c1387 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -22,13 +22,6 @@ class RedisHandler extends BaseHandler { /** - * Prefixed to all cache names. - * - * @var string - */ - protected $prefix; - - /** * Default config * * @var array @@ -57,7 +50,7 @@ */ public function __construct(Cache $config) { - $this->prefix = (string) $config->prefix; + $this->prefix = $config->prefix; if (! empty($config)) { @@ -72,7 +65,7 @@ */ public function __destruct() { - if ($this->redis) // @phpstan-ignore-line + if (isset($this->redis)) { $this->redis->close(); } @@ -134,8 +127,7 @@ */ public function get(string $key) { - $key = $this->prefix . $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) @@ -169,11 +161,11 @@ * @param mixed $value The data to save * @param integer $ttl Time To Live, in seconds (default 60) * - * @return mixed + * @return boolean Success or failure */ public function save(string $key, $value, int $ttl = 60) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); switch ($dataType = gettype($value)) { @@ -212,13 +204,46 @@ * * @param string $key Cache item name * - * @return boolean + * @return boolean Success or failure */ public function delete(string $key) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); - return ($this->redis->del($key) === 1); + return $this->redis->del($key) === 1; + } + + //-------------------------------------------------------------------- + + /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @return integer The number of deleted items + */ + public function deleteMatching(string $pattern) + { + $matchedKeys = []; + $iterator = null; + + do + { + // Scan for some keys + $keys = $this->redis->scan($iterator, $pattern); + + // Redis may return empty results, so protect against that + if ($keys !== false) + { + foreach ($keys as $key) + { + $matchedKeys[] = $key; + } + } + } + while ($iterator > 0); + + return $this->redis->del($matchedKeys); } //-------------------------------------------------------------------- @@ -229,11 +254,11 @@ * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return integer */ public function increment(string $key, int $offset = 1) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); return $this->redis->hIncrBy($key, 'data', $offset); } @@ -246,11 +271,11 @@ * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return integer */ public function decrement(string $key, int $offset = 1) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); return $this->redis->hIncrBy($key, 'data', -$offset); } @@ -260,7 +285,7 @@ /** * Will delete all items in the entire cache. * - * @return boolean + * @return boolean Success or failure */ public function clean() { @@ -275,7 +300,7 @@ * The information returned and the structure of the data * varies depending on the handler. * - * @return mixed + * @return array */ public function getCacheInfo() { @@ -289,19 +314,22 @@ * * @param string $key Cache item name. * - * @return mixed + * @return array|null + * Returns null if the item does not exist, otherwise array + * with at least the 'expire' key for absolute epoch expiry (or null). */ public function getMetaData(string $key) { - $key = $this->prefix . $key; - + $key = static::validateKey($key, $this->prefix); $value = $this->get($key); if ($value !== null) { $time = time(); + $ttl = $this->redis->ttl($key); + return [ - 'expire' => $time + $this->redis->ttl($key), + 'expire' => $ttl > 0 ? time() + $ttl : null, 'mtime' => $time, 'data' => $value, ]; @@ -321,6 +349,4 @@ { return extension_loaded('redis'); } - - //-------------------------------------------------------------------- } diff --git a/system/Cache/Handlers/WincacheHandler.php b/system/Cache/Handlers/WincacheHandler.php index 2ad1238..daf041a 100644 --- a/system/Cache/Handlers/WincacheHandler.php +++ b/system/Cache/Handlers/WincacheHandler.php @@ -12,43 +12,32 @@ namespace CodeIgniter\Cache\Handlers; use Config\Cache; +use Exception; /** * Cache handler for WinCache from Microsoft & IIS. - * Windows-only, so not testable on travis-ci. - * Unusable methods flagged for code coverage ignoring. + * + * @codeCoverageIgnore */ class WincacheHandler extends BaseHandler { /** - * Prefixed to all cache names. - * - * @var string - */ - protected $prefix; - - //-------------------------------------------------------------------- - - /** * Constructor. * * @param Cache $config */ public function __construct(Cache $config) { - $this->prefix = (string) $config->prefix; + $this->prefix = $config->prefix; } //-------------------------------------------------------------------- /** * Takes care of any handler-specific setup that must be done. - * - * @codeCoverageIgnore */ public function initialize() { - // Nothing to see here... } //-------------------------------------------------------------------- @@ -59,18 +48,16 @@ * @param string $key Cache item name * * @return mixed - * - * @codeCoverageIgnore */ public function get(string $key) { - $key = $this->prefix . $key; - + $key = static::validateKey($key, $this->prefix); $success = false; - $data = wincache_ucache_get($key, $success); + + $data = wincache_ucache_get($key, $success); // Success returned by reference from wincache_ucache_get() - return ($success) ? $data : null; + return $success ? $data : null; } //-------------------------------------------------------------------- @@ -82,13 +69,11 @@ * @param mixed $value The data to save * @param integer $ttl Time To Live, in seconds (default 60) * - * @return mixed - * - * @codeCoverageIgnore + * @return boolean Success or failure */ public function save(string $key, $value, int $ttl = 60) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); return wincache_ucache_set($key, $value, $ttl); } @@ -100,13 +85,11 @@ * * @param string $key Cache item name * - * @return boolean - * - * @codeCoverageIgnore + * @return boolean Success or failure */ public function delete(string $key) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); return wincache_ucache_delete($key); } @@ -114,23 +97,32 @@ //-------------------------------------------------------------------- /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @throws Exception + */ + 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.'); + } + + //-------------------------------------------------------------------- + + /** * Performs atomic incrementation of a raw stored value. * * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed - * - * @codeCoverageIgnore + * @return integer|false */ public function increment(string $key, int $offset = 1) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); - $success = false; - $value = wincache_ucache_inc($key, $offset, $success); - - return ($success === true) ? $value : false; // @phpstan-ignore-line + return wincache_ucache_inc($key, $offset); } //-------------------------------------------------------------------- @@ -141,18 +133,13 @@ * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed - * - * @codeCoverageIgnore + * @return integer|false */ public function decrement(string $key, int $offset = 1) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); - $success = false; - $value = wincache_ucache_dec($key, $offset, $success); - - return ($success === true) ? $value : false; // @phpstan-ignore-line + return wincache_ucache_dec($key, $offset); } //-------------------------------------------------------------------- @@ -160,9 +147,7 @@ /** * Will delete all items in the entire cache. * - * @return boolean - * - * @codeCoverageIgnore + * @return boolean Success or failure */ public function clean() { @@ -177,9 +162,7 @@ * The information returned and the structure of the data * varies depending on the handler. * - * @return mixed - * - * @codeCoverageIgnore + * @return array|false */ public function getCacheInfo() { @@ -193,13 +176,14 @@ * * @param string $key Cache item name. * - * @return mixed - * - * @codeCoverageIgnore + * @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) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); if ($stored = wincache_ucache_info(false, $key)) { @@ -208,14 +192,14 @@ $hitcount = $stored['ucache_entries'][1]['hitcount']; return [ - 'expire' => $ttl - $age, + 'expire' => $ttl > 0 ? time() + $ttl : null, 'hitcount' => $hitcount, 'age' => $age, 'ttl' => $ttl, ]; } - return false; + return false; // This will return null in a future release } //-------------------------------------------------------------------- @@ -227,8 +211,6 @@ */ public function isSupported(): bool { - return (extension_loaded('wincache') && ini_get('wincache.ucenabled')); + return extension_loaded('wincache') && ini_get('wincache.ucenabled'); } - - //-------------------------------------------------------------------- } diff --git a/system/CodeIgniter.php b/system/CodeIgniter.php index d3f0117..56a01dc 100644 --- a/system/CodeIgniter.php +++ b/system/CodeIgniter.php @@ -44,7 +44,12 @@ /** * The current version of CodeIgniter Framework */ - const CI_VERSION = '4.1.1'; + const CI_VERSION = '4.1.2'; + + /** + * @var string + */ + private const MIN_PHP_VERSION = '7.3'; /** * App startup time. @@ -138,8 +143,6 @@ */ protected $useSafeOutput = false; - //-------------------------------------------------------------------- - /** * Constructor. * @@ -147,12 +150,21 @@ */ public function __construct(App $config) { + if (version_compare(PHP_VERSION, self::MIN_PHP_VERSION, '<')) + { + // @codeCoverageIgnoreStart + $message = extension_loaded('intl') + ? lang('Core.invalidPhpVersion', [self::MIN_PHP_VERSION, PHP_VERSION]) + : sprintf('Your PHP version must be %s or higher to run CodeIgniter. Current version: %s', self::MIN_PHP_VERSION, PHP_VERSION); + + exit($message); + // @codeCoverageIgnoreEnd + } + $this->startTime = microtime(true); $this->config = $config; } - //-------------------------------------------------------------------- - /** * Handles some basic app and environment setup. */ @@ -168,9 +180,7 @@ // Run this check for manual installations if (! is_file(COMPOSER_PATH)) { - // @codeCoverageIgnoreStart - $this->resolvePlatformExtensions(); - // @codeCoverageIgnoreEnd + $this->resolvePlatformExtensions(); // @codeCoverageIgnore } // Set default locale on the server @@ -183,20 +193,17 @@ if (! CI_DEBUG) { - // @codeCoverageIgnoreStart - Kint::$enabled_mode = false; - // @codeCoverageIgnoreEnd + Kint::$enabled_mode = false; // @codeCoverageIgnore } } - //-------------------------------------------------------------------- - /** * Checks system for missing required PHP extensions. * - * @return void * @throws FrameworkException * + * @return void + * * @codeCoverageIgnore */ protected function resolvePlatformExtensions() @@ -219,14 +226,12 @@ } } - if ($missingExtensions) + if ($missingExtensions !== []) { throw FrameworkException::forMissingExtension(implode(', ', $missingExtensions)); } } - //-------------------------------------------------------------------- - /** * Initializes Kint */ @@ -326,6 +331,7 @@ $this->response->pretend($this->useSafeOutput)->send(); $this->callExit(EXIT_SUCCESS); + return; } try @@ -343,6 +349,7 @@ $this->sendResponse(); $this->callExit(EXIT_SUCCESS); + return; } catch (PageNotFoundException $e) { @@ -395,7 +402,7 @@ $filters->enableFilter($routeFilter, 'after'); } - $uri = $this->request instanceof CLIRequest ? $this->request->getPath() : $this->request->getUri()->getPath(); + $uri = $this->determinePath(); // Never run filters when running through Spark cli if (! defined('SPARKED')) @@ -500,18 +507,7 @@ protected function detectEnvironment() { // Make sure ENVIRONMENT isn't already set by other means. - if (! defined('ENVIRONMENT')) - { - // running under Continuous Integration server? - if (getenv('CI') !== false) - { - define('ENVIRONMENT', 'testing'); - } - else - { - define('ENVIRONMENT', $_SERVER['CI_ENVIRONMENT'] ?? 'production'); - } - } + defined('ENVIRONMENT') || define('ENVIRONMENT', $_SERVER['CI_ENVIRONMENT'] ?? 'production'); // @codeCoverageIgnore } //-------------------------------------------------------------------- @@ -667,7 +663,7 @@ $output = $cachedResponse['output']; // Clear all default headers - foreach ($this->response->headers() as $key => $val) + foreach (array_keys($this->response->headers()) as $key) { $this->response->removeHeader($key); } @@ -841,8 +837,7 @@ return $this->path; } - // @phpstan-ignore-next-line - return (is_cli() && ! (ENVIRONMENT === 'testing')) ? $this->request->getPath() : $this->request->uri->getPath(); + return method_exists($this->request, 'getPath') ? $this->request->getPath() : $this->request->getUri()->getPath(); } //-------------------------------------------------------------------- @@ -905,7 +900,7 @@ */ protected function createController() { - $class = new $this->controller(); // @phpstan-ignore-line + $class = new $this->controller(); $class->initController($this->request, $this->response, Services::logger()); $this->benchmark->stop('controller_constructor'); @@ -991,13 +986,10 @@ } // @codeCoverageIgnoreEnd } - else + // When testing, one is for phpunit, another is for test case. + elseif (ob_get_level() > 2) { - // When testing, one is for phpunit, another is for test case. - if (ob_get_level() > 2) - { - ob_end_flush(); - } + ob_end_flush(); // @codeCoverageIgnore } throw PageNotFoundException::forPageNotFound(ENVIRONMENT !== 'production' || is_cli() ? $e->getMessage() : ''); diff --git a/system/Commands/Database/CreateDatabase.php b/system/Commands/Database/CreateDatabase.php index b302500..d6eaef1 100644 --- a/system/Commands/Database/CreateDatabase.php +++ b/system/Commands/Database/CreateDatabase.php @@ -86,16 +86,23 @@ $name = CLI::prompt('Database name', null, 'required'); // @codeCoverageIgnore } - $db = Database::connect(); - try { + /** + * @var Database $config + */ + $config = config('Database'); + + // 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) { - $config = config('Database'); - $group = ENVIRONMENT === 'testing' ? 'tests' : $config->defaultGroup; - $ext = $params['ext'] ?? CLI::getOption('ext') ?? 'db'; + $ext = $params['ext'] ?? CLI::getOption('ext') ?? 'db'; if (! in_array($ext, ['db', 'sqlite'], true)) { @@ -125,33 +132,26 @@ unset($dbName); } - // Connect to new SQLite3 to create new database, - // then reset the altered Config\Database instance + // Connect to new SQLite3 to create new database $db = Database::connect(null, false); $db->connect(); - Factories::reset('config'); if (! is_file($db->getDatabase()) && $name !== ':memory:') { // @codeCoverageIgnoreStart CLI::error('Database creation failed.', 'light_gray', 'red'); CLI::newLine(); - return; // @codeCoverageIgnoreEnd } } - else + elseif (! Database::forge()->createDatabase($name)) { - if (! Database::forge()->createDatabase($name)) - { - // @codeCoverageIgnoreStart - CLI::error('Database creation failed.', 'light_gray', 'red'); - CLI::newLine(); - - return; - // @codeCoverageIgnoreEnd - } + // @codeCoverageIgnoreStart + CLI::error('Database creation failed.', 'light_gray', 'red'); + CLI::newLine(); + return; + // @codeCoverageIgnoreEnd } CLI::write("Database \"{$name}\" successfully created.", 'green'); @@ -161,5 +161,10 @@ { $this->showError($e); } + finally + { + // Reset the altered config no matter what happens. + Factories::reset('config'); + } } } diff --git a/system/Commands/Database/MigrateStatus.php b/system/Commands/Database/MigrateStatus.php index 89b5493..e660d02 100644 --- a/system/Commands/Database/MigrateStatus.php +++ b/system/Commands/Database/MigrateStatus.php @@ -90,7 +90,7 @@ // Collection of migration status $status = []; - foreach ($namespaces as $namespace => $path) + foreach (array_keys($namespaces) as $namespace) { if (ENVIRONMENT !== 'testing') { diff --git a/system/Commands/Generators/CommandGenerator.php b/system/Commands/Generators/CommandGenerator.php index 11bef84..9d0c0f4 100644 --- a/system/Commands/Generators/CommandGenerator.php +++ b/system/Commands/Generators/CommandGenerator.php @@ -84,6 +84,7 @@ $this->directory = 'Commands'; $this->template = 'command.tpl.php'; + $this->classNameLang = 'CLI.generator.className.command'; $this->execute($params); } @@ -101,7 +102,6 @@ $type = $this->getOption('type'); $command = is_string($command) ? $command : 'command:name'; - $group = is_string($group) ? $group : 'CodeIgniter'; $type = is_string($type) ? $type : 'basic'; if (! in_array($type, ['basic', 'generator'], true)) @@ -112,9 +112,9 @@ // @codeCoverageIgnoreEnd } - if ($type === 'generator') + if (! is_string($group)) { - $group = 'Generators'; + $group = $type === 'generator' ? 'Generators' : 'CodeIgniter'; } return $this->parseTemplate( diff --git a/system/Commands/Generators/ConfigGenerator.php b/system/Commands/Generators/ConfigGenerator.php index af6f343..00d520d 100644 --- a/system/Commands/Generators/ConfigGenerator.php +++ b/system/Commands/Generators/ConfigGenerator.php @@ -80,6 +80,7 @@ $this->directory = 'Config'; $this->template = 'config.tpl.php'; + $this->classNameLang = 'CLI.generator.className.config'; $this->execute($params); } diff --git a/system/Commands/Generators/ControllerGenerator.php b/system/Commands/Generators/ControllerGenerator.php index ff56853..b2ecbd7 100644 --- a/system/Commands/Generators/ControllerGenerator.php +++ b/system/Commands/Generators/ControllerGenerator.php @@ -83,6 +83,7 @@ $this->directory = 'Controllers'; $this->template = 'controller.tpl.php'; + $this->classNameLang = 'CLI.generator.className.controller'; $this->execute($params); } diff --git a/system/Commands/Generators/EntityGenerator.php b/system/Commands/Generators/EntityGenerator.php index 7a56131..2a3cff0 100644 --- a/system/Commands/Generators/EntityGenerator.php +++ b/system/Commands/Generators/EntityGenerator.php @@ -12,7 +12,6 @@ namespace CodeIgniter\Commands\Generators; use CodeIgniter\CLI\BaseCommand; -use CodeIgniter\CLI\CLI; use CodeIgniter\CLI\GeneratorTrait; /** @@ -81,6 +80,7 @@ $this->directory = 'Entities'; $this->template = 'entity.tpl.php'; + $this->classNameLang = 'CLI.generator.className.entity'; $this->execute($params); } } diff --git a/system/Commands/Generators/FilterGenerator.php b/system/Commands/Generators/FilterGenerator.php index 57baa94..677337d 100644 --- a/system/Commands/Generators/FilterGenerator.php +++ b/system/Commands/Generators/FilterGenerator.php @@ -12,7 +12,6 @@ namespace CodeIgniter\Commands\Generators; use CodeIgniter\CLI\BaseCommand; -use CodeIgniter\CLI\CLI; use CodeIgniter\CLI\GeneratorTrait; /** @@ -81,6 +80,7 @@ $this->directory = 'Filters'; $this->template = 'filter.tpl.php'; + $this->classNameLang = 'CLI.generator.className.filter'; $this->execute($params); } } diff --git a/system/Commands/Generators/MigrateCreate.php b/system/Commands/Generators/MigrateCreate.php index 546e343..a4b36ac 100644 --- a/system/Commands/Generators/MigrateCreate.php +++ b/system/Commands/Generators/MigrateCreate.php @@ -17,7 +17,7 @@ /** * Deprecated class for the migration creation command. * - * @deprecated Use make:command instead. + * @deprecated Use make:migration instead. * * @codeCoverageIgnore */ @@ -67,8 +67,8 @@ * @var array */ protected $options = [ - '-n' => 'Set root namespace. Defaults to APP_NAMESPACE', - '--force' => 'Force overwrite existing files.', + '--namespace' => 'Set root namespace. Defaults to APP_NAMESPACE', + '--force' => 'Force overwrite existing files.', ]; /** diff --git a/system/Commands/Generators/MigrationGenerator.php b/system/Commands/Generators/MigrationGenerator.php index a7c5959..fb2f0ed 100644 --- a/system/Commands/Generators/MigrationGenerator.php +++ b/system/Commands/Generators/MigrationGenerator.php @@ -89,6 +89,7 @@ $params[0] = "_create_{$table}_table"; } + $this->classNameLang = 'CLI.generator.className.migration'; $this->execute($params); } diff --git a/system/Commands/Generators/ModelGenerator.php b/system/Commands/Generators/ModelGenerator.php index e9fdbfe..b391da7 100644 --- a/system/Commands/Generators/ModelGenerator.php +++ b/system/Commands/Generators/ModelGenerator.php @@ -84,6 +84,7 @@ $this->directory = 'Models'; $this->template = 'model.tpl.php'; + $this->classNameLang = 'CLI.generator.className.model'; $this->execute($params); } diff --git a/system/Commands/Generators/SeederGenerator.php b/system/Commands/Generators/SeederGenerator.php index d9c34ef..aaa2a65 100644 --- a/system/Commands/Generators/SeederGenerator.php +++ b/system/Commands/Generators/SeederGenerator.php @@ -80,6 +80,7 @@ $this->directory = 'Database\Seeds'; $this->template = 'seeder.tpl.php'; + $this->classNameLang = 'CLI.generator.className.seeder'; $this->execute($params); } } diff --git a/system/Commands/Generators/SessionMigrationGenerator.php b/system/Commands/Generators/SessionMigrationGenerator.php index 10b4a72..eec42fd 100644 --- a/system/Commands/Generators/SessionMigrationGenerator.php +++ b/system/Commands/Generators/SessionMigrationGenerator.php @@ -18,7 +18,7 @@ /** * Generates a migration file for database sessions. * - * @deprecated Use make:migration --session instead. + * @deprecated Use `make:migration --session` instead. * * @codeCoverageIgnore */ diff --git a/system/Commands/Generators/ValidationGenerator.php b/system/Commands/Generators/ValidationGenerator.php index 24cd37b..13fe3e2 100644 --- a/system/Commands/Generators/ValidationGenerator.php +++ b/system/Commands/Generators/ValidationGenerator.php @@ -81,6 +81,7 @@ $this->directory = 'Validation'; $this->template = 'validation.tpl.php'; + $this->classNameLang = 'CLI.generator.className.validation'; $this->execute($params); } } diff --git a/system/Commands/Generators/Views/entity.tpl.php b/system/Commands/Generators/Views/entity.tpl.php index 7c563be..8de289f 100644 --- a/system/Commands/Generators/Views/entity.tpl.php +++ b/system/Commands/Generators/Views/entity.tpl.php @@ -2,7 +2,7 @@ namespace {namespace}; -use CodeIgniter\Entity; +use CodeIgniter\Entity\Entity; class {class} extends Entity { diff --git a/system/Commands/Generators/Views/model.tpl.php b/system/Commands/Generators/Views/model.tpl.php index 255c56d..042bbc8 100644 --- a/system/Commands/Generators/Views/model.tpl.php +++ b/system/Commands/Generators/Views/model.tpl.php @@ -12,7 +12,7 @@ protected $useAutoIncrement = true; protected $insertID = 0; protected $returnType = '{return}'; - protected $useSoftDelete = false; + protected $useSoftDeletes = false; protected $protectFields = true; protected $allowedFields = []; diff --git a/system/Commands/ListCommands.php b/system/Commands/ListCommands.php index a5b6ea1..94b13de 100644 --- a/system/Commands/ListCommands.php +++ b/system/Commands/ListCommands.php @@ -124,9 +124,7 @@ CLI::write($output); } - end($groups); - - if ($group !== key($groups)) + if ($group !== array_key_last($groups)) { CLI::newLine(); } @@ -140,7 +138,7 @@ */ protected function listSimple(array $commands) { - foreach ($commands as $title => $command) + foreach (array_keys($commands) as $title) { CLI::write($title); } diff --git a/system/Common.php b/system/Common.php index 9f27e55..acdc668 100644 --- a/system/Common.php +++ b/system/Common.php @@ -11,6 +11,9 @@ use CodeIgniter\Cache\CacheInterface; 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; @@ -226,6 +229,46 @@ } } +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 + * + * @return Cookie + */ + 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 Cookie[] $cookies If `getGlobal` is false, this is passed to CookieStore's constructor + * @param boolean $getGlobal If false, creates a new instance of CookieStore + * + * @return CookieStore + */ + function cookies(array $cookies = [], bool $getGlobal = true): CookieStore + { + if ($getGlobal) + { + return Services::response()->getCookieStore(); + } + + return new CookieStore($cookies); + } +} + if (! function_exists('csrf_token')) { /** @@ -433,14 +476,7 @@ throw new InvalidArgumentException('Invalid escape context provided.'); } - if ($context === 'attr') - { - $method = 'escapeHtmlAttr'; - } - else - { - $method = 'escape' . ucfirst($context); - } + $method = $context === 'attr' ? 'escapeHtmlAttr' : 'escape' . ucfirst($context); static $escaper; if (! $escaper) @@ -882,12 +918,9 @@ } // If the result was serialized array or string, then unserialize it for use... - if (is_string($value)) + if (is_string($value) && (strpos($value, 'a:') === 0 || strpos($value, 's:') === 0)) { - if (strpos($value, 'a:') === 0 || strpos($value, 's:') === 0) - { - $value = unserialize($value); - } + $value = unserialize($value); } return $escape === false ? $value : esc($value, $escape); @@ -1021,7 +1054,7 @@ * - $timer = \CodeIgniter\Config\Services::timer(); * * @param string $name - * @param array ...$params + * @param mixed ...$params * * @return mixed */ @@ -1036,8 +1069,8 @@ /** * Always returns a new instance of the class. * - * @param string $name - * @param array|null $params + * @param string $name + * @param mixed ...$params * * @return mixed */ @@ -1133,7 +1166,7 @@ foreach ($attributes as $key => $val) { - $atts .= ($js) ? $key . '=' . esc($val, 'js') . ',' : ' ' . $key . '="' . esc($val, 'attr') . '"'; + $atts .= ($js) ? $key . '=' . esc($val, 'js') . ',' : ' ' . $key . '="' . esc($val) . '"'; } return rtrim($atts, ','); @@ -1238,3 +1271,75 @@ ->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 + */ +// @codeCoverageIgnoreStart +if (! function_exists('class_basename')) +{ + /** + * Get the class "basename" of the given object / class. + * + * @param string|object $class + * @return string + */ + function class_basename($class) + { + $class = is_object($class) ? get_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 + */ + function class_uses_recursive($class) + { + if (is_object($class)) + { + $class = get_class($class); + } + + $results = []; + + // @phpstan-ignore-next-line + 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 + */ + function trait_uses_recursive($trait) + { + $traits = class_uses($trait) ?: []; + + foreach ($traits as $trait) + { + $traits += trait_uses_recursive($trait); + } + + return $traits; + } +} +// @codeCoverageIgnoreEnd diff --git a/system/ComposerScripts.php b/system/ComposerScripts.php index aae11ba..612cb85 100644 --- a/system/ComposerScripts.php +++ b/system/ComposerScripts.php @@ -11,210 +11,170 @@ namespace CodeIgniter; -use ReflectionClass; -use ReflectionException; +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; /** - * ComposerScripts - * - * These scripts are used by Composer during installs and updates + * 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 + * download. * * @codeCoverageIgnore + * + * @internal */ -class ComposerScripts +final class ComposerScripts { /** - * Base path to use. + * Path to the ThirdParty directory. * * @var string */ - protected static $basePath = 'ThirdParty/'; + private static $path = __DIR__ . '/ThirdParty/'; /** - * After composer install/update, this is called to move - * the bare-minimum required files for our dependencies - * to appropriate locations. + * Direct dependencies of CodeIgniter to copy + * contents to `system/ThirdParty/`. * - * @throws ReflectionException + * @var array> + */ + private static $dependencies = [ + 'kint-src' => [ + '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' => [ + 'from' => __DIR__ . '/../vendor/laminas/laminas-escaper/src/', + 'to' => __DIR__ . '/ThirdParty/Escaper/', + ], + 'psr-log' => [ + 'from' => __DIR__ . '/../vendor/psr/log/Psr/Log/', + 'to' => __DIR__ . '/ThirdParty/PSR/Log/', + ], + ]; + + /** + * This static method is called by Composer after every update event, + * i.e., `composer install`, `composer update`, `composer remove`. + * + * @return void */ public static function postUpdate() { - static::moveEscaper(); - static::moveKint(); - } + self::recursiveDelete(self::$path); - //-------------------------------------------------------------------- - - /** - * Move a file. - * - * @param string $source - * @param string $destination - * - * @return boolean - */ - protected static function moveFile(string $source, string $destination): bool - { - $source = realpath($source); - - if (empty($source)) + foreach (self::$dependencies as $dependency) { - // @codeCoverageIgnoreStart - die('Cannot move file. Source path invalid.'); - // @codeCoverageIgnoreEnd + self::recursiveMirror($dependency['from'], $dependency['to']); } - if (! is_file($source)) + self::copyKintInitFiles(); + self::recursiveDelete(self::$dependencies['psr-log']['to'] . 'Test/'); + } + + /** + * Recursively remove the contents of the previous `system/ThirdParty`. + * + * @param string $directory + * + * @return void + */ + private static function recursiveDelete(string $directory): void + { + if (! is_dir($directory)) { - return false; + echo sprintf('Cannot recursively delete "%s" as it does not exist.', $directory); } - return copy($source, $destination); - } - - //-------------------------------------------------------------------- - - /** - * Determine file path of a class. - * - * @param string $class - * - * @return string - * @throws ReflectionException - */ - protected static function getClassFilePath(string $class) - { - $reflector = new ReflectionClass($class); - - return $reflector->getFileName(); - } - - //-------------------------------------------------------------------- - - /** - * A recursive remove directory method. - * - * @param string $dir - */ - protected static function removeDir($dir) - { - if (is_dir($dir)) + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator( + new RecursiveDirectoryIterator(rtrim($directory, '\\/'), FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ) as $file) { - $objects = scandir($dir); - foreach ($objects as $object) + $path = $file->getPathname(); + + if ($file->isDir()) { - if ($object !== '.' && $object !== '..') - { - if (filetype($dir . '/' . $object) === 'dir') - { - static::removeDir($dir . '/' . $object); - } - else - { - unlink($dir . '/' . $object); - } - } + @rmdir($path); } - reset($objects); - rmdir($dir); - } - } - - protected static function copyDir($source, $dest) - { - $dir = opendir($source); - @mkdir($dest); - - while (false !== ($file = readdir($dir))) - { - if (($file !== '.') && ($file !== '..')) + else { - if (is_dir($source . '/' . $file)) - { - static::copyDir($source . '/' . $file, $dest . '/' . $file); - } - else - { - copy($source . '/' . $file, $dest . '/' . $file); - } - } - } - - closedir($dir); - } - - /** - * Moves the Laminas Escaper files into our base repo so that it's - * available for packaged releases where the users don't user Composer. - * - * @throws ReflectionException - */ - public static function moveEscaper() - { - if (class_exists('\\Laminas\\Escaper\\Escaper') && is_file(static::getClassFilePath('\\Laminas\\Escaper\\Escaper'))) - { - $base = basename(__DIR__) . '/' . static::$basePath . 'Escaper'; - - foreach ([$base, $base . '/Exception'] as $path) - { - if (! is_dir($path)) - { - mkdir($path, 0755); - } - } - - $files = [ - static::getClassFilePath('\\Laminas\\Escaper\\Exception\\ExceptionInterface') => $base . '/Exception/ExceptionInterface.php', - static::getClassFilePath('\\Laminas\\Escaper\\Exception\\InvalidArgumentException') => $base . '/Exception/InvalidArgumentException.php', - static::getClassFilePath('\\Laminas\\Escaper\\Exception\\RuntimeException') => $base . '/Exception/RuntimeException.php', - static::getClassFilePath('\\Laminas\\Escaper\\Escaper') => $base . '/Escaper.php', - ]; - - foreach ($files as $source => $dest) - { - if (! static::moveFile($source, $dest)) - { - // @codeCoverageIgnoreStart - die('Error moving: ' . $source); - // @codeCoverageIgnoreEnd - } + @unlink($path); } } } - //-------------------------------------------------------------------- + /** + * Recursively copy the files and directories of the origin directory + * into the target directory, i.e. "mirror" its contents. + * + * @param string $originDir + * @param string $targetDir + * + * @return void + */ + 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); + } + + @mkdir($targetDir, 0755, true); + + $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); + } + } + } /** - * Moves the Kint file into our base repo so that it's - * available for packaged releases where the users don't user Composer. + * Copy Kint's init files into `system/ThirdParty/Kint/` + * + * @return void */ - public static function moveKint() + private static function copyKintInitFiles(): void { - $dir = 'vendor/kint-php/kint/src'; + $originDir = self::$dependencies['kint-src']['from'] . '../'; + $targetDir = self::$dependencies['kint-src']['to']; - if (is_dir($dir)) + foreach (['init.php', 'init_helpers.php'] as $kintInit) { - $base = basename(__DIR__) . '/' . static::$basePath . 'Kint'; - - // Remove the contents of the previous Kint folder, if any. - if (is_dir($base)) - { - static::removeDir($base); - } - - // Create Kint if it doesn't exist already - if (! is_dir($base)) - { - mkdir($base, 0755); - } - - static::copyDir($dir, $base); - static::copyDir($dir . '/../resources', $base . '/resources'); - copy($dir . '/../init.php', $base . '/init.php'); - copy($dir . '/../init_helpers.php', $base . '/init_helpers.php'); + @copy($originDir . $kintInit, $targetDir . $kintInit); } } } diff --git a/system/Config/AutoloadConfig.php b/system/Config/AutoloadConfig.php index 8a74697..a90d99b 100644 --- a/system/Config/AutoloadConfig.php +++ b/system/Config/AutoloadConfig.php @@ -12,7 +12,7 @@ namespace CodeIgniter\Config; /** - * AUTOLOADER + * AUTOLOADER CONFIGURATION * * This file defines the namespaces and class maps so the Autoloader * can find the files as needed. @@ -32,7 +32,7 @@ * 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 + * @var array */ public $psr4 = []; @@ -46,12 +46,24 @@ * searched for within one or more directories as they would if they * were being autoloaded through a namespace. * - * @var array + * @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 array + */ + public $files = []; + + /** + * ------------------------------------------------------------------- * Namespaces * ------------------------------------------------------------------- * This maps the locations of any namespaces in your application to @@ -61,7 +73,7 @@ * Do not change the name of the CodeIgniter namespace or your application * will break. * - * @var array + * @var array */ protected $corePsr4 = [ 'CodeIgniter' => SYSTEMPATH, @@ -78,7 +90,7 @@ * searched for within one or more directories as they would if they * were being autoloaded through a namespace. * - * @var array + * @var array */ protected $coreClassmap = [ 'Psr\Log\AbstractLogger' => SYSTEMPATH . 'ThirdParty/PSR/Log/AbstractLogger.php', @@ -92,7 +104,15 @@ 'Laminas\Escaper\Escaper' => SYSTEMPATH . 'ThirdParty/Escaper/Escaper.php', ]; - //-------------------------------------------------------------------- + /** + * ------------------------------------------------------------------- + * Core Files + * ------------------------------------------------------------------- + * List of files from the framework to be autoloaded early. + * + * @var array + */ + protected $coreFiles = []; /** * Constructor. @@ -111,5 +131,6 @@ $this->psr4 = array_merge($this->corePsr4, $this->psr4); $this->classmap = array_merge($this->coreClassmap, $this->classmap); + $this->files = array_merge($this->coreFiles, $this->files); } } diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index 4245a74..f5a08b5 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -61,6 +61,8 @@ { static::$moduleConfig = config('Modules'); + $this->registerProperties(); + $properties = array_keys(get_object_vars($this)); $prefix = static::class; $slashAt = strrpos($prefix, '\\'); @@ -84,8 +86,6 @@ } } } - - $this->registerProperties(); } /** @@ -102,29 +102,22 @@ { if (is_array($property)) { - foreach ($property as $key => $val) + foreach (array_keys($property) as $key) { $this->initEnvValue($property[$key], "{$name}.{$key}", $prefix, $shortPrefix); } } - else + elseif (($value = $this->getEnvValue($name, $prefix, $shortPrefix)) !== false && ! is_null($value)) { - if (($value = $this->getEnvValue($name, $prefix, $shortPrefix)) !== false) + if ($value === 'false') { - if (! is_null($value)) - { - if ($value === 'false') - { - $value = false; - } - elseif ($value === 'true') - { - $value = true; - } - - $property = is_bool($value) ? $value : trim($value, '\'"'); - } + $value = false; } + elseif ($value === 'true') + { + $value = true; + } + $property = is_bool($value) ? $value : trim($value, '\'"'); } return $property; } @@ -141,6 +134,7 @@ protected function getEnvValue(string $property, string $prefix, string $shortPrefix) { $shortPrefix = ltrim($shortPrefix, '\\'); + switch (true) { case array_key_exists("{$shortPrefix}.{$property}", $_ENV): @@ -152,7 +146,9 @@ case array_key_exists("{$prefix}.{$property}", $_SERVER): return $_SERVER["{$prefix}.{$property}"]; default: - $value = getenv($property); + $value = getenv("{$shortPrefix}.{$property}"); + $value = $value === false ? getenv("{$prefix}.{$property}") : $value; + return $value === false ? null : $value; } } diff --git a/system/Config/BaseService.php b/system/Config/BaseService.php index bdcb269..21985e9 100644 --- a/system/Config/BaseService.php +++ b/system/Config/BaseService.php @@ -13,8 +13,62 @@ use CodeIgniter\Autoloader\Autoloader; use CodeIgniter\Autoloader\FileLocator; +use CodeIgniter\Cache\CacheInterface; +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\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\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\Throttle\Throttler; +use CodeIgniter\Typography\Typography; +use CodeIgniter\Validation\Validation; +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\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\Pager as ConfigPager; +use Config\Services as AppServices; +use Config\Toolbar as ConfigToolbar; +use Config\Validation as ConfigValidation; +use Config\View as ConfigView; /** * Services Configuration file. @@ -30,8 +84,46 @@ * 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 CURLRequest curlrequest($options = [], ResponseInterface $response = null, App $config = null, $getShared = true) + * @method static Email email($config = null, $getShared = true) + * @method static EncrypterInterface encrypter(Encryption $config = null, $getShared = false) + * @method static Exceptions exceptions(ConfigExceptions $config = null, IncomingRequest $request = null, Response $response = 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 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 View renderer($viewPath = null, ConfigView $config = null, $getShared = true) + * @method static IncomingRequest request(App $config = null, $getShared = true) + * @method static Response response(App $config = null, $getShared = true) + * @method static RedirectResponse redirectresponse(App $config = null, $getShared = true) + * @method static RouteCollection routes($getShared = true) + * @method static Router router(RouteCollectionInterface $routes = null, Request $request = null, $getShared = true) + * @method static Security security(App $config = null, $getShared = true) + * @method static Session session(App $config = null, $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 URI uri($uri = null, $getShared = true) + * @method static Validation validation(ConfigValidation $config = null, $getShared = true) + * @method static Cell viewcell($getShared = true) + * @method static Typography typography($getShared = true) */ class BaseService { @@ -78,7 +170,7 @@ * $key must be a name matching a service. * * @param string $key - * @param array ...$params + * @param mixed ...$params * * @return mixed */ @@ -95,9 +187,9 @@ if (! isset(static::$instances[$key])) { // Make sure $getShared is false - array_push($params, false); + $params[] = false; - static::$instances[$key] = static::$key(...$params); + static::$instances[$key] = AppServices::$key(...$params); } return static::$instances[$key]; @@ -213,6 +305,16 @@ } /** + * Resets any mock and shared instances for a single service. + * + * @param string $name + */ + public static function resetSingle(string $name) + { + unset(static::$mocks[$name], static::$instances[$name]); + } + + /** * Inject mock object for testing. * * @param string $name diff --git a/system/Config/DotEnv.php b/system/Config/DotEnv.php index bb7c4cc..75140f0 100644 --- a/system/Config/DotEnv.php +++ b/system/Config/DotEnv.php @@ -51,7 +51,7 @@ { $vars = $this->parse(); - return ($vars === null ? false : true); + return $vars !== null; } //-------------------------------------------------------------------- @@ -243,7 +243,7 @@ if (strpos($value, '$') !== false) { $value = preg_replace_callback( - '/\${([a-zA-Z0-9_]+)}/', + '/\${([a-zA-Z0-9_\.]+)}/', function ($matchedPatterns) { $nestedVariable = $this->getVariable($matchedPatterns[1]); diff --git a/system/Config/Factories.php b/system/Config/Factories.php index b4c6877..9222e5e 100644 --- a/system/Config/Factories.php +++ b/system/Config/Factories.php @@ -11,6 +11,7 @@ namespace CodeIgniter\Config; +use CodeIgniter\Model; use Config\Services; /** @@ -21,7 +22,7 @@ * large performance boost and helps keep code clean of lengthy * instantiation checks. * - * @method static \CodeIgniter\Model models(...$arguments) + * @method static Model models(...$arguments) * @method static \Config\BaseConfig config(...$arguments) */ class Factories @@ -102,19 +103,23 @@ if (isset(self::$basenames[$options['component']][$basename])) { $class = self::$basenames[$options['component']][$basename]; - } - else - { - // Try to locate the class - if (! $class = self::locateClass($options, $name)) - { - return null; - } - self::$instances[$options['component']][$class] = new $class(...$arguments); - self::$basenames[$options['component']][$basename] = $class; + // Need to verify if the shared instance matches the request + if (self::verifyInstanceOf($options, $class)) + { + return self::$instances[$options['component']][$class]; + } } + // Try to locate the class + if (! $class = self::locateClass($options, $name)) + { + return null; + } + + self::$instances[$options['component']][$class] = new $class(...$arguments); + self::$basenames[$options['component']][$basename] = $class; + return self::$instances[$options['component']][$class]; } @@ -162,17 +167,13 @@ { return null; } - $files = [$file]; } // No namespace? Search for it - else + // Check all namespaces, prioritizing App and modules + elseif (! $files = $locator->search($options['path'] . DIRECTORY_SEPARATOR . $name)) { - // Check all namespaces, prioritizing App and modules - if (! $files = $locator->search($options['path'] . DIRECTORY_SEPARATOR . $name)) - { - return null; - } + return null; } // Check all files for a valid class @@ -254,16 +255,11 @@ return self::$options[$component]; } - // Handle Config as a special case to prevent logic loops - if ($component === 'config') - { - $values = self::$configOptions; - } - // Load values from the best Factory configuration (will include Registrars) - else - { - $values = config('Factory')->$component ?? []; - } + $values = $component === 'config' + // 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 ?? []; return self::setOptions($component, $values); } diff --git a/system/Config/Services.php b/system/Config/Services.php index 6f8b4ba..fe6e6a3 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -14,6 +14,7 @@ use CodeIgniter\Cache\CacheFactory; use CodeIgniter\Cache\CacheInterface; use CodeIgniter\CLI\Commands; +use CodeIgniter\CodeIgniter; use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Database\MigrationRunner; use CodeIgniter\Debug\Exceptions; @@ -67,6 +68,7 @@ use Config\Toolbar as ToolbarConfig; use Config\Validation as ValidationConfig; use Config\View as ViewConfig; +use Config\Services as AppServices; /** * Services Configuration file. @@ -134,6 +136,26 @@ //-------------------------------------------------------------------- /** + * CodeIgniter, the core of the framework. + * + * @param App|null $config + * @param boolean $getShared + * + * @return CodeIgniter + */ + public static function codeigniter(App $config = null, bool $getShared = true) + { + if ($getShared) + { + return static::getSharedInstance('codeigniter', $config); + } + + $config = $config ?? config('App'); + + return new CodeIgniter($config); + } + + /** * The commands utility for running and working with CLI commands. * * @param boolean $getShared @@ -187,7 +209,7 @@ * @param EmailConfig|array|null $config * @param boolean $getShared * - * @return Email|mixed + * @return Email */ public static function email($config = null, bool $getShared = true) { @@ -254,8 +276,8 @@ } $config = $config ?? config('Exceptions'); - $request = $request ?? static::request(); - $response = $response ?? static::response(); + $request = $request ?? AppServices::request(); + $response = $response ?? AppServices::response(); return new Exceptions($config, $request, $response); } @@ -282,7 +304,7 @@ $config = $config ?? config('Filters'); - return new Filters($config, static::request(), static::response()); + return new Filters($config, AppServices::request(), AppServices::response()); } //-------------------------------------------------------------------- @@ -395,7 +417,7 @@ } // Use '?:' for empty string check - $locale = $locale ?: static::request()->getLocale(); + $locale = $locale ?: AppServices::request()->getLocale(); return new Language($locale); } @@ -462,7 +484,7 @@ return static::getSharedInstance('negotiator', $request); } - $request = $request ?? static::request(); + $request = $request ?? AppServices::request(); return new Negotiate($request); } @@ -486,7 +508,7 @@ } $config = $config ?? config('Pager'); - $view = $view ?? static::renderer(); + $view = $view ?? AppServices::renderer(); return new Pager($config, $view); } @@ -512,7 +534,7 @@ $viewPath = $viewPath ?: config('Paths')->viewDirectory; $config = $config ?? config('View'); - return new Parser($config, $viewPath, static::locator(), CI_DEBUG, static::logger()); + return new Parser($config, $viewPath, AppServices::locator(), CI_DEBUG, AppServices::logger()); } //-------------------------------------------------------------------- @@ -538,7 +560,7 @@ $viewPath = $viewPath ?: config('Paths')->viewDirectory; $config = $config ?? config('View'); - return new View($config, $viewPath, static::locator(), CI_DEBUG, static::logger()); + return new View($config, $viewPath, AppServices::locator(), CI_DEBUG, AppServices::logger()); } //-------------------------------------------------------------------- @@ -562,7 +584,7 @@ return new IncomingRequest( $config, - static::uri(), + AppServices::uri(), 'php://input', new UserAgent() ); @@ -609,7 +631,7 @@ $config = $config ?? config('App'); $response = new RedirectResponse($config); - $response->setProtocolVersion(static::request()->getProtocolVersion()); + $response->setProtocolVersion(AppServices::request()->getProtocolVersion()); return $response; } @@ -631,7 +653,7 @@ return static::getSharedInstance('routes'); } - return new RouteCollection(static::locator(), config('Modules')); + return new RouteCollection(AppServices::locator(), config('Modules')); } //-------------------------------------------------------------------- @@ -653,8 +675,8 @@ return static::getSharedInstance('router', $routes, $request); } - $routes = $routes ?? static::routes(); - $request = $request ?? static::request(); + $routes = $routes ?? AppServices::routes(); + $request = $request ?? AppServices::request(); return new Router($routes, $request); } @@ -677,7 +699,7 @@ return static::getSharedInstance('security', $config); } - $config = $config ?? config('Security') ?? config('App'); + $config = $config ?? config('App'); return new Security($config); } @@ -700,10 +722,10 @@ } $config = $config ?? config('App'); - $logger = static::logger(); + $logger = AppServices::logger(); $driverName = $config->sessionDriver; - $driver = new $driverName($config, static::request()->getIPAddress()); + $driver = new $driverName($config, AppServices::request()->getIPAddress()); $driver->setLogger($logger); $session = new Session($driver, $config); @@ -734,7 +756,7 @@ return static::getSharedInstance('throttler'); } - return new Throttler(static::cache()); + return new Throttler(AppServices::cache()); } //-------------------------------------------------------------------- @@ -818,7 +840,7 @@ $config = $config ?? config('Validation'); - return new Validation($config, static::renderer()); + return new Validation($config, AppServices::renderer()); } //-------------------------------------------------------------------- @@ -838,7 +860,7 @@ return static::getSharedInstance('viewcell'); } - return new Cell(static::cache()); + return new Cell(AppServices::cache()); } //-------------------------------------------------------------------- diff --git a/system/Controller.php b/system/Controller.php index 3707557..c397f6d 100644 --- a/system/Controller.php +++ b/system/Controller.php @@ -130,6 +130,8 @@ * Handles "auto-loading" helper files. * * @deprecated Use `helper` function instead of using this method. + * + * @codeCoverageIgnore */ protected function loadHelpers() { diff --git a/system/Cookie/CloneableCookieInterface.php b/system/Cookie/CloneableCookieInterface.php new file mode 100644 index 0000000..ea9fa9a --- /dev/null +++ b/system/Cookie/CloneableCookieInterface.php @@ -0,0 +1,125 @@ + + * + * 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. + * + * @param string $prefix + * + * @return static + */ + public function withPrefix(string $prefix = ''); + + /** + * Creates a new Cookie with a new name. + * + * @param string $name + * + * @return static + */ + public function withName(string $name); + + /** + * Creates a new Cookie with new value. + * + * @param string $value + * + * @return static + */ + public function withValue(string $value); + + /** + * Creates a new Cookie with a new cookie expires time. + * + * @param DateTimeInterface|integer|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 + */ + public function withNeverExpiring(); + + /** + * Creates a new Cookie with a new path on the server the cookie is available. + * + * @param string|null $path + * + * @return static + */ + public function withPath(?string $path); + + /** + * Creates a new Cookie with a new domain the cookie is available. + * + * @param string|null $domain + * + * @return static + */ + public function withDomain(?string $domain); + + /** + * Creates a new Cookie with a new "Secure" attribute. + * + * @param boolean $secure + * + * @return static + */ + public function withSecure(bool $secure = true); + + /** + * Creates a new Cookie with a new "HttpOnly" attribute + * + * @param boolean $httponly + * + * @return static + */ + public function withHTTPOnly(bool $httponly = true); + + /** + * Creates a new Cookie with a new "SameSite" attribute. + * + * @param string $samesite + * + * @return static + */ + public function withSameSite(string $samesite); + + /** + * Creates a new Cookie with URL encoding option updated. + * + * @param boolean $raw + * + * @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..f6ebf37 --- /dev/null +++ b/system/Cookie/Cookie.php @@ -0,0 +1,838 @@ + + * + * 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 Config\Cookie as CookieConfig; +use DateTimeInterface; +use InvalidArgumentException; +use LogicException; + +/** + * 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 + * ``` + */ +class Cookie implements ArrayAccess, CloneableCookieInterface +{ + /** + * @var string + */ + protected $prefix = ''; + + /** + * @var string + */ + protected $name; + + /** + * @var string + */ + protected $value; + + /** + * @var integer + */ + protected $expires; + + /** + * @var string + */ + protected $path = '/'; + + /** + * @var string + */ + protected $domain = ''; + + /** + * @var boolean + */ + protected $secure = false; + + /** + * @var boolean + */ + protected $httponly = true; + + /** + * @var string + */ + protected $samesite = self::SAMESITE_LAX; + + /** + * @var boolean + */ + protected $raw = false; + + /** + * Default attributes for a Cookie object. The keys here are the + * lowercase attribute names. Do not camelCase! + * + * @var array + */ + private static $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. + * + * @var string + * + * @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 $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. + * + * @param CookieConfig|array $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. + * + * @param string $cookie + * @param boolean $raw + * + * @throws CookieException + * + * @return static + */ + 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 (strpos($part, '=') !== false) + { + list($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() + (int) $options['max-age']; + unset($options['max-age']); + } + + // to retain backward compatibility with previous versions' fallback + $prefix = $options['prefix'] ?: self::$defaults['prefix']; + $path = $options['path'] ?: self::$defaults['path']; + $domain = $options['domain'] ?: self::$defaults['domain']; + $secure = $options['secure'] ?: self::$defaults['secure']; + $httponly = $options['httponly'] ?: self::$defaults['httponly']; + $samesite = $options['samesite'] ?: self::$defaults['samesite']; + + $this->validateName($name, $options['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 = $options['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(); + } + + /** + * {@inheritDoc} + */ + public function getMaxAge(): int + { + $maxAge = $this->expires - time(); + + 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; + } + + /** + * {@inheritDoc} + */ + public function withNeverExpiring() + { + $cookie = clone $this; + + $cookie->expires = time() + 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 = $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 + * + * @return boolean + */ + public function offsetExists($offset) + { + return $offset === 'expire' ? true : property_exists($this, $offset); + } + + /** + * Offset to retrieve. + * + * @param string $offset + * + * @throws InvalidArgumentException + * + * @return mixed + */ + 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 mixed $value + * + * @throws LogicException + * + * @return void + */ + public function offsetSet($offset, $value) + { + 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 + * + * @return void + */ + public function offsetUnset($offset) + { + 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() + { + $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|integer|string $expires + * + * @return integer + */ + 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. + * + * @param string $name + * @param boolean $raw + * + * @throws CookieException + * + * @return void + */ + 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. + * + * @param string $prefix + * @param boolean $secure + * @param string $path + * @param string $domain + * + * @throws CookieException + * + * @return void + */ + protected function validatePrefix(string $prefix, bool $secure, string $path, string $domain): void + { + if (strpos($prefix, '__Secure-') === 0 && ! $secure) + { + throw CookieException::forInvalidSecurePrefix(); + } + + if (strpos($prefix, '__Host-') === 0 && (! $secure || $domain !== '' || $path !== '/')) + { + throw CookieException::forInvalidHostPrefix(); + } + } + + /** + * Validates the `SameSite` to be within the allowed types. + * + * @param string $samesite + * @param boolean $secure + * + * @throws CookieException + * + * @return void + * + * @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..1dddb13 --- /dev/null +++ b/system/Cookie/CookieInterface.php @@ -0,0 +1,208 @@ + + * + * 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). + * + * @var string + */ + 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. + * + * @var string + */ + public const SAMESITE_STRICT = 'strict'; + + /** + * RFC 6265 allowed values for the "SameSite" attribute. + * + * @var string[] + * + * @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. + * + * @var string + * + * @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. + * + * @return string + */ + public function getId(): string; + + /** + * Gets the cookie prefix. + * + * @return string + */ + public function getPrefix(): string; + + /** + * Gets the cookie name. + * + * @return string + */ + public function getName(): string; + + /** + * Gets the cookie name prepended with the prefix, if any. + * + * @return string + */ + public function getPrefixedName(): string; + + /** + * Gets the cookie value. + * + * @return string + */ + public function getValue(): string; + + /** + * Gets the time in Unix timestamp the cookie expires. + * + * @return integer + */ + public function getExpiresTimestamp(): int; + + /** + * Gets the formatted expires time. + * + * @return string + */ + public function getExpiresString(): string; + + /** + * Checks if the cookie is expired. + * + * @return boolean + */ + public function isExpired(): bool; + + /** + * Gets the "Max-Age" cookie attribute. + * + * @return integer + */ + public function getMaxAge(): int; + + /** + * Gets the "Path" cookie attribute. + * + * @return string + */ + public function getPath(): string; + + /** + * Gets the "Domain" cookie attribute. + * + * @return string + */ + 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. + * + * @return boolean + */ + public function isSecure(): bool; + + /** + * Gets the "HttpOnly" cookie attribute. + * + * Checks if JavaScript is forbidden from accessing the cookie. + * + * @return boolean + */ + public function isHTTPOnly(): bool; + + /** + * Gets the "SameSite" cookie attribute. + * + * @return string + */ + public function getSameSite(): string; + + /** + * Checks if the cookie should be sent with no URL encoding. + * + * @return boolean + */ + 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. + * + * @return string + */ + 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..f34b816 --- /dev/null +++ b/system/Cookie/CookieStore.php @@ -0,0 +1,305 @@ + + * + * 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 + */ +class CookieStore implements Countable, IteratorAggregate +{ + /** + * The cookie collection. + * + * @var array + */ + protected $cookies = []; + + /** + * Creates a CookieStore from an array of `Set-Cookie` headers. + * + * @param string[] $headers + * @param boolean $raw + * + * @throws CookieException + * + * @return static + */ + public static function fromCookieHeaders(array $headers, bool $raw = false) + { + /** + * @var Cookie[] $cookies + */ + $cookies = array_filter(array_map(function (string $header) use ($raw) { + try + { + return Cookie::fromHeaderString($header, $raw); + } + catch (CookieException $e) + { + log_message('error', $e->getMessage()); + return false; + } + }, $headers)); + + return new static($cookies); + } + + /** + * @param Cookie[] $cookies + * + * @throws CookieException + */ + final public function __construct(array $cookies) + { + $this->validateCookies($cookies); + + foreach ($cookies as $cookie) + { + $this->cookies[$cookie->getId()] = $cookie; + } + } + + /** + * Checks if a `Cookie` object identified by name and + * prefix is present in the collection. + * + * @param string $name + * @param string $prefix + * @param string|null $value + * + * @return boolean + */ + public function has(string $name, string $prefix = '', string $value = null): bool + { + $name = $prefix . $name; + + foreach ($this->cookies as $cookie) + { + if ($cookie->getPrefixedName() !== $name) + { + continue; + } + + if ($value === null) + { + return true; // for BC + } + + return $cookie->getValue() === $value; + } + + return false; + } + + /** + * Retrieves an instance of `Cookie` identified by a name and prefix. + * This throws an exception if not found. + * + * @param string $name + * @param string $prefix + * + * @throws CookieException + * + * @return Cookie + */ + public function get(string $name, string $prefix = ''): Cookie + { + $name = $prefix . $name; + + foreach ($this->cookies as $cookie) + { + if ($cookie->getPrefixedName() === $name) + { + return $cookie; + } + } + + throw CookieException::forUnknownCookieInstance([$name, $prefix]); + } + + /** + * Store a new cookie and return a new collection. The original collection + * is left unchanged. + * + * @param Cookie $cookie + * + * @return static + */ + public function put(Cookie $cookie) + { + $store = clone $this; + + $store->cookies[$cookie->getId()] = $cookie; + + return $store; + } + + /** + * Removes a cookie from a collection and returns an updated collection. + * The original collection is left unchanged. + * + * Removing a cookie from the store **DOES NOT** delete it from the browser. + * If you intend to delete a cookie *from the browser*, you must put an empty + * value cookie with the same name to the store. + * + * @param string $name + * @param string $prefix + * + * @return static + */ + public function remove(string $name, string $prefix = '') + { + $default = Cookie::setDefaults(); + + $id = implode(';', [$prefix . $name, $default['path'], $default['domain']]); + + $store = clone $this; + + foreach (array_keys($store->cookies) as $index) + { + if ($index === $id) + { + unset($store->cookies[$index]); + } + } + + return $store; + } + + /** + * Dispatches all cookies in store. + * + * @return void + */ + public function dispatch(): void + { + foreach ($this->cookies as $cookie) + { + $name = $cookie->getPrefixedName(); + $value = $cookie->getValue(); + $options = $cookie->getOptions(); + + if ($cookie->isRaw()) + { + $this->setRawCookie($name, $value, $options); + } + else + { + $this->setCookie($name, $value, $options); + } + } + + $this->clear(); + } + + /** + * Returns all cookie instances in store. + * + * @return array + */ + public function display(): array + { + return $this->cookies; + } + + /** + * Clears the cookie collection. + * + * @return void + */ + public function clear(): void + { + $this->cookies = []; + } + + /** + * Gets the Cookie count in this collection. + * + * @return integer + */ + public function count(): int + { + return count($this->cookies); + } + + /** + * Gets the iterator for the cookie collection. + * + * @return Traversable + */ + public function getIterator() + { + return new ArrayIterator($this->cookies); + } + + /** + * Validates all cookies passed to be instances of Cookie. + * + * @param array $cookies + * + * @throws CookieException + * + * @return void + */ + protected function validateCookies(array $cookies): void + { + foreach ($cookies as $index => $cookie) + { + $type = is_object($cookie) ? get_class($cookie) : gettype($cookie); + + if (! $cookie instanceof Cookie) + { + throw CookieException::forInvalidCookieInstance([static::class, Cookie::class, $type, $index]); + } + } + } + + /** + * Extracted call to `setrawcookie()` in order to run unit tests on it. + * + * @codeCoverageIgnore + * + * @param string $name + * @param string $value + * @param array $options + * + * @return void + */ + protected function setRawCookie(string $name, string $value, array $options): void + { + setrawcookie($name, $value, $options); + } + + /** + * Extracted call to `setcookie()` in order to run unit tests on it. + * + * @codeCoverageIgnore + * + * @param string $name + * @param string $value + * @param array $options + * + * @return void + */ + protected function setCookie(string $name, string $value, array $options): void + { + setcookie($name, $value, $options); + } +} diff --git a/system/Cookie/Exceptions/CookieException.php b/system/Cookie/Exceptions/CookieException.php new file mode 100644 index 0000000..31cf305 --- /dev/null +++ b/system/Cookie/Exceptions/CookieException.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Cookie\Exceptions; + +use CodeIgniter\Exceptions\FrameworkException; + +/** + * CookieException is thrown for invalid cookies initialization and management. + */ +class CookieException extends FrameworkException +{ + /** + * Thrown for invalid type given for the "Expires" attribute. + * + * @param string $type + * + * @return static + */ + public static function forInvalidExpiresTime(string $type) + { + return new static(lang('Cookie.invalidExpiresTime', [$type])); + } + + /** + * Thrown when the value provided for "Expires" is invalid. + * + * @return static + */ + public static function forInvalidExpiresValue() + { + return new static(lang('Cookie.invalidExpiresValue')); + } + + /** + * Thrown when the cookie name contains invalid characters per RFC 2616. + * + * @param string $name + * + * @return static + */ + public static function forInvalidCookieName(string $name) + { + return new static(lang('Cookie.invalidCookieName', [$name])); + } + + /** + * Thrown when the cookie name is empty. + * + * @return static + */ + public static function forEmptyCookieName() + { + return new static(lang('Cookie.emptyCookieName')); + } + + /** + * Thrown when using the `__Secure-` prefix but the `Secure` attribute + * is not set to true. + * + * @return static + */ + public static function forInvalidSecurePrefix() + { + return new static(lang('Cookie.invalidSecurePrefix')); + } + + /** + * Thrown when using the `__Host-` prefix but the `Secure` flag is not + * set, the `Domain` is set, and the `Path` is not `/`. + * + * @return static + */ + public static function forInvalidHostPrefix() + { + return new static(lang('Cookie.invalidHostPrefix')); + } + + /** + * Thrown when the `SameSite` attribute given is not of the valid types. + * + * @param string $sameSite + * + * @return static + */ + public static function forInvalidSameSite(string $sameSite) + { + return new static(lang('Cookie.invalidSameSite', [$sameSite])); + } + + /** + * Thrown when the `SameSite` attribute is set to `None` but the `Secure` + * attribute is not set. + * + * @return static + */ + public static function forInvalidSameSiteNone() + { + return new static(lang('Cookie.invalidSameSiteNone')); + } + + /** + * Thrown when the `CookieStore` class is filled with invalid Cookie objects. + * + * @param array $data + * + * @return static + */ + public static function forInvalidCookieInstance(array $data) + { + return new static(lang('Cookie.invalidCookieInstance', $data)); + } + + /** + * Thrown when the queried Cookie object does not exist in the cookie collection. + * + * @param string[] $data + * + * @return static + */ + public static function forUnknownCookieInstance(array $data) + { + return new static(lang('Cookie.unknownCookieInstance', $data)); + } +} diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 003d56f..127b918 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -22,8 +22,6 @@ * Provides the core Query Builder methods. * Database-specific Builders might need to override * certain methods to make them work. - * - * @mixin \CodeIgniter\Model */ class BaseBuilder { @@ -151,7 +149,7 @@ /** * A reference to the database connection. * - * @var ConnectionInterface + * @var BaseConnection */ protected $db; @@ -247,7 +245,6 @@ ]; //-------------------------------------------------------------------- - /** * Constructor * @@ -263,6 +260,9 @@ throw new DatabaseException('A table must be specified when creating a new Query Builder.'); } + /** + * @var BaseConnection $db + */ $this->db = $db; $this->tableName = $tableName; @@ -281,11 +281,10 @@ } //-------------------------------------------------------------------- - /** * Returns the current database connection * - * @return ConnectionInterface + * @return ConnectionInterface|BaseConnection */ public function db(): ConnectionInterface { @@ -372,7 +371,10 @@ } // If the escape value was not set, we will base it on the global setting - is_bool($escape) || $escape = $this->db->protectIdentifiers; + if (! is_bool($escape)) + { + $escape = $this->db->protectIdentifiers; + } foreach ($select as $val) { @@ -656,7 +658,10 @@ // in the protectIdentifiers to know whether to add a table prefix $this->trackAliases($table); - is_bool($escape) || $escape = $this->db->protectIdentifiers; + if (! is_bool($escape)) + { + $escape = $this->db->protectIdentifiers; + } if (! $this->hasOperator($cond)) { @@ -776,7 +781,10 @@ } // If the escape value was not set will base it on the global setting - is_bool($escape) || $escape = $this->db->protectIdentifiers; + if (! is_bool($escape)) + { + $escape = $this->db->protectIdentifiers; + } foreach ($key as $k => $v) { @@ -1036,7 +1044,10 @@ // @codeCoverageIgnoreEnd } - is_bool($escape) || $escape = $this->db->protectIdentifiers; + if (! is_bool($escape)) + { + $escape = $this->db->protectIdentifiers; + } $ok = $key; @@ -1329,14 +1340,12 @@ */ protected function _like_statement(?string $prefix, string $column, ?string $not, string $bind, bool $insensitiveSearch = false): string { - $likeStatement = "{$prefix} {$column} {$not} LIKE :{$bind}:"; - if ($insensitiveSearch === true) { - $likeStatement = "{$prefix} LOWER({$column}) {$not} LIKE :{$bind}:"; + return "{$prefix} LOWER({$column}) {$not} LIKE :{$bind}:"; } - return $likeStatement; + return "{$prefix} {$column} {$not} LIKE :{$bind}:"; } //-------------------------------------------------------------------- @@ -1546,7 +1555,10 @@ */ public function groupBy($by, bool $escape = null) { - is_bool($escape) || $escape = $this->db->protectIdentifiers; + if (! is_bool($escape)) + { + $escape = $this->db->protectIdentifiers; + } if (is_string($by)) { @@ -1638,7 +1650,10 @@ $direction = in_array($direction, ['ASC', 'DESC'], true) ? ' ' . $direction : ''; } - is_bool($escape) || $escape = $this->db->protectIdentifiers; + if (! is_bool($escape)) + { + $escape = $this->db->protectIdentifiers; + } if ($escape === false) { @@ -2005,7 +2020,6 @@ } //-------------------------------------------------------------------- - /** * Get_Where * @@ -2082,9 +2096,8 @@ { throw new DatabaseException('insertBatch() called with no data'); } - // @codeCoverageIgnoreStart - return false; - // @codeCoverageIgnoreEnd + + return false; // @codeCoverageIgnore } $this->setInsertBatch($set, '', $escape); @@ -2100,7 +2113,7 @@ if ($this->testMode) { - ++ $affectedRows; + ++$affectedRows; } else { @@ -2174,6 +2187,7 @@ ksort($row); // puts $row in the same order as our keys $clean = []; + foreach ($row as $k => $value) { $clean[] = ':' . $this->setBind($k, $value, $escape) . ':'; @@ -2236,7 +2250,7 @@ * * @throws DatabaseException * - * @return BaseResult|Query|false + * @return Query|boolean */ public function insert(array $set = null, bool $escape = null) { @@ -2380,7 +2394,7 @@ * Groups tables in FROM clauses if needed, so there is no confusion * about operator precedence. * - * Note: This is only used (and overridden) by MySQL and CUBRID. + * Note: This is only used (and overridden) by MySQL and SQLSRV. * * @return string */ @@ -2467,7 +2481,7 @@ $result = $this->db->query($sql, $this->binds, false); - if ($result->resultID !== false) + if ($result !== false) { // Clear our binds so we don't eat up memory $this->binds = []; @@ -2686,7 +2700,10 @@ return null; } - is_bool($escape) || $escape = $this->db->protectIdentifiers; + if (! is_bool($escape)) + { + $escape = $this->db->protectIdentifiers; + } foreach ($key as $v) { @@ -2816,7 +2833,7 @@ * @param integer $limit The limit clause * @param boolean $resetData * - * @return mixed + * @return string|boolean * @throws DatabaseException */ public function delete($where = '', int $limit = null, bool $resetData = true) @@ -3042,16 +3059,14 @@ */ protected function compileIgnore(string $statement) { - $sql = ''; - if ($this->QBIgnore && isset($this->supportedIgnoreStatements[$statement]) ) { - $sql = trim($this->supportedIgnoreStatements[$statement]) . ' '; + return trim($this->supportedIgnoreStatements[$statement]) . ' '; } - return $sql; + return ''; } //-------------------------------------------------------------------- @@ -3274,7 +3289,7 @@ $i = 0; foreach ($out[$val] as $data) { - $array[$i ++][$val] = $data; + $array[$i++][$val] = $data; } } } @@ -3480,16 +3495,17 @@ if (! array_key_exists($key, $this->bindsKeyCount)) { - $this->bindsKeyCount[$key] = 0; + $this->bindsKeyCount[$key] = 1; } + $count = $this->bindsKeyCount[$key]++; - $this->binds[$key . $count] = [ + $this->binds[$key . '.' . $count] = [ $value, $escape, ]; - return $key . $count; + return $key . '.' . $count; } //-------------------------------------------------------------------- diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index e5a312a..a37ffaa 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -342,6 +342,7 @@ //-------------------------------------------------------------------- $this->connectTime = microtime(true); + $connectionErrors = []; try { @@ -350,6 +351,7 @@ } catch (Throwable $e) { + $connectionErrors[] = sprintf('Main connection [%s]: %s', $this->DBDriver, $e->getMessage()); log_message('error', 'Error connecting to the database: ' . $e->getMessage()); } @@ -360,7 +362,7 @@ if (! empty($this->failover) && is_array($this->failover)) { // Go over all the failovers - foreach ($this->failover as $failover) + foreach ($this->failover as $index => $failover) { // Replace the current settings with those of the failover foreach ($failover as $key => $val) @@ -378,6 +380,7 @@ } catch (Throwable $e) { + $connectionErrors[] = sprintf('Failover #%d [%s]: %s', ++$index, $this->DBDriver, $e->getMessage()); log_message('error', 'Error connecting to the database: ' . $e->getMessage()); } @@ -392,7 +395,11 @@ // We still don't have a connection? if (! $this->connID) { - throw new DatabaseException('Unable to connect to the database.'); + throw new DatabaseException(sprintf( + 'Unable to connect to the database.%s%s', + PHP_EOL, + implode(PHP_EOL, $connectionErrors) + )); } } @@ -605,7 +612,7 @@ * @param boolean $setEscapeFlags * @param string $queryClass * - * @return BaseResult|Query|false + * @return BaseResult|Query|boolean * * @todo BC set $queryClass default as null in 4.1 */ @@ -618,7 +625,6 @@ $this->initialize(); } - $resultClass = str_replace('Connection', 'Result', get_class($this)); /** * @var Query $query */ @@ -675,7 +681,7 @@ Events::trigger('DBQuery', $query); } - return new $resultClass($this->connID, $this->resultID); + return false; } $query->setDuration($startTime); @@ -689,7 +695,20 @@ // If $pretend is true, then we just want to return // the actual query object here. There won't be // any results to return. - return $this->pretend ? $query : new $resultClass($this->connID, $this->resultID); + if ($this->pretend) + { + return $query; + } + + // resultID is not false, so it must be successful + if ($this->isWriteType($sql)) + { + return true; + } + + // query is not write-type, so it must be read-type query; return QueryResult + $resultClass = str_replace('Connection', 'Result', get_class($this)); + return new $resultClass($this->connID, $this->resultID); } //-------------------------------------------------------------------- @@ -931,7 +950,6 @@ abstract protected function _transRollback(): bool; //-------------------------------------------------------------------- - /** * Returns an instance of the query builder for this connection. * @@ -953,7 +971,6 @@ } //-------------------------------------------------------------------- - /** * Creates a prepared statement with the database that can then * be used to execute multiple statements against. Within the @@ -1319,13 +1336,12 @@ //-------------------------------------------------------------------- /** - * DB Prefix - * * Prepends a database prefix if one exists in configuration * * @param string $table the table * * @return string + * * @throws DatabaseException */ public function prefixTable(string $table = ''): string @@ -1749,6 +1765,19 @@ //-------------------------------------------------------------------- /** + * Determines if the statement is a write-type query or not. + * + * @param string $sql + * @return boolean + */ + public function isWriteType($sql): bool + { + return (bool) preg_match('/^\s*"?(SET|INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|TRUNCATE|LOAD|COPY|ALTER|RENAME|GRANT|REVOKE|LOCK|UNLOCK|REINDEX|MERGE)\s/i', $sql); + } + + //-------------------------------------------------------------------- + + /** * Returns the last error code and message. * * Must return an array with keys 'code' and 'message': diff --git a/system/Database/BasePreparedQuery.php b/system/Database/BasePreparedQuery.php index e191646..00f477b 100644 --- a/system/Database/BasePreparedQuery.php +++ b/system/Database/BasePreparedQuery.php @@ -56,7 +56,6 @@ protected $db; //-------------------------------------------------------------------- - /** * Constructor. * @@ -90,7 +89,7 @@ $sql = preg_replace('/:[^\s,)]+/', '?', $sql); /** - * @var \CodeIgniter\Database\Query $query + * @var Query $query */ $query = new $queryClass($this->db); @@ -119,7 +118,6 @@ abstract public function _prepare(string $sql, array $options = []); //-------------------------------------------------------------------- - /** * Takes a new set of data and runs it against the currently * prepared query. Upon success, will return a Results object. diff --git a/system/Database/BaseResult.php b/system/Database/BaseResult.php index d7991e9..c1bdecc 100644 --- a/system/Database/BaseResult.php +++ b/system/Database/BaseResult.php @@ -11,7 +11,7 @@ namespace CodeIgniter\Database; -use CodeIgniter\Entity; +use CodeIgniter\Entity\Entity; /** * Class BaseResult @@ -288,7 +288,10 @@ if (! is_numeric($n)) { // We cache the row data for subsequent uses - is_array($this->rowData) || $this->rowData = $this->getRowArray(); + if (! is_array($this->rowData)) + { + $this->rowData = $this->getRowArray(); + } // array_key_exists() instead of isset() to allow for NULL values if (empty($this->rowData) || ! array_key_exists($n, $this->rowData)) diff --git a/system/Database/BaseUtils.php b/system/Database/BaseUtils.php index ffb471c..9922133 100644 --- a/system/Database/BaseUtils.php +++ b/system/Database/BaseUtils.php @@ -49,7 +49,6 @@ protected $repairTable = false; //-------------------------------------------------------------------- - /** * Class constructor * @@ -120,7 +119,7 @@ * Optimize Table * * @param string $tableName - * @return mixed + * @return boolean * @throws DatabaseException */ public function optimizeTable(string $tableName) @@ -135,13 +134,8 @@ } $query = $this->db->query(sprintf($this->optimizeTable, $this->db->escapeIdentifiers($tableName))); - if ($query !== false) - { - $query = $query->getResultArray(); - return current($query); - } - return false; + return $query !== false; } //-------------------------------------------------------------------- @@ -226,7 +220,6 @@ } //-------------------------------------------------------------------- - /** * Generate CSV from a query result object * @@ -263,7 +256,6 @@ } //-------------------------------------------------------------------- - /** * Generate XML data from a query result object * @@ -344,7 +336,7 @@ // Did the user submit any preferences? If so set them.... if (! empty($params)) { - foreach ($prefs as $key => $val) + foreach (array_keys($prefs) as $key) { if (isset($params[$key])) { diff --git a/system/Database/Config.php b/system/Database/Config.php index 0b658f5..b84067d 100644 --- a/system/Database/Config.php +++ b/system/Database/Config.php @@ -36,7 +36,6 @@ static protected $factory; //-------------------------------------------------------------------- - /** * Creates the default * @@ -119,7 +118,6 @@ } //-------------------------------------------------------------------- - /** * Returns a new instance of the Database Utilities class. * @@ -135,7 +133,6 @@ } //-------------------------------------------------------------------- - /** * Returns a new instance of the Database Seeder. * diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php index b9b40ba..43806ad 100644 --- a/system/Database/ConnectionInterface.php +++ b/system/Database/ConnectionInterface.php @@ -90,8 +90,10 @@ /** * Returns the last error encountered by this connection. + * Must return this format: ['code' => string|int, 'message' => string] + * intval(code) === 0 means "no error". * - * @return array + * @return array */ public function error(): array; @@ -114,7 +116,6 @@ public function getVersion(): string; //-------------------------------------------------------------------- - /** * Orchestrates a query against the database. Queries must use * Database\Statement objects to store the query and build it. @@ -126,7 +127,7 @@ * @param string $sql * @param mixed ...$binds * - * @return mixed + * @return BaseResult|Query|boolean */ public function query(string $sql, $binds = null); @@ -144,7 +145,6 @@ public function simpleQuery(string $sql); //-------------------------------------------------------------------- - /** * Returns an instance of the query builder for this connection. * @@ -191,4 +191,12 @@ public function callFunction(string $functionName, ...$params); //-------------------------------------------------------------------- + + /** + * Determines if the statement is a write-type query or not. + * + * @param string $sql + * @return boolean + */ + public function isWriteType($sql): bool; } diff --git a/system/Database/Database.php b/system/Database/Database.php index 7e76326..e173907 100644 --- a/system/Database/Database.php +++ b/system/Database/Database.php @@ -23,7 +23,7 @@ /** * Maintains an array of the instances of all connections that have * been created. - * + * * Helps to keep track of all open connections for performance * monitoring, logging, etc. * @@ -41,9 +41,9 @@ * @param string $alias * * @return mixed - * + * * @throws InvalidArgumentException - * + * * @internal param bool $useBuilder */ public function load(array $params = [], string $alias = '') @@ -72,7 +72,6 @@ } //-------------------------------------------------------------------- - /** * Creates a Forge instance for the current database type. * @@ -92,7 +91,6 @@ } //-------------------------------------------------------------------- - /** * Creates a Utils instance for the current database type. * @@ -119,7 +117,7 @@ * @param array $params * * @return array - * + * * @throws InvalidArgumentException */ protected function parseDSN(array $params): array diff --git a/system/Database/Forge.php b/system/Database/Forge.php index 44488a5..a8975f1 100644 --- a/system/Database/Forge.php +++ b/system/Database/Forge.php @@ -150,6 +150,8 @@ * NULL value representation in CREATE/ALTER TABLE statements * * @var string + * + * @internal Used for marking nullable fields. Not covered by BC promise. */ protected $null = ''; @@ -161,7 +163,6 @@ protected $default = ' DEFAULT '; //-------------------------------------------------------------------- - /** * Constructor. * @@ -173,7 +174,6 @@ } //-------------------------------------------------------------------- - /** * Provides access to the forge's current database connection. * @@ -240,11 +240,11 @@ } catch (Throwable $e) { + // @phpstan-ignore-next-line if ($this->db->DBDebug) { throw new DatabaseException('Unable to create the specified database.', 0, $e); } - return false; // @codeCoverageIgnore } } @@ -524,7 +524,7 @@ if (($result = $this->db->query($sql)) !== false) { - if (! isset($this->db->dataCache['table_names'][$table])) + if (isset($this->db->dataCache['table_names']) && ! in_array($table, $this->db->dataCache['table_names'], true)) { $this->db->dataCache['table_names'][] = $table; } @@ -774,7 +774,10 @@ public function addColumn(string $table, $field): bool { // Work-around for literal column definitions - is_array($field) || $field = [$field]; // @phpstan-ignore-line + if (! is_array($field)) + { + $field = [$field]; + } foreach (array_keys($field) as $k) { @@ -793,9 +796,9 @@ return false; } - for ($i = 0, $c = count($sqls); $i < $c; $i++) + foreach ($sqls as $sql) { - if ($this->db->query($sqls[$i]) === false) + if ($this->db->query($sql) === false) { return false; } @@ -845,7 +848,10 @@ public function modifyColumn(string $table, $field): bool { // Work-around for literal column definitions - is_array($field) || $field = [$field]; // @phpstan-ignore-line + if (! is_array($field)) + { + $field = [$field]; + } foreach (array_keys($field) as $k) { @@ -871,9 +877,9 @@ if ($sqls !== null) { - for ($i = 0, $c = count($sqls); $i < $c; $i++) + foreach ($sqls as $sql) { - if ($this->db->query($sqls[$i]) === false) + if ($this->db->query($sql) === false) { return false; } diff --git a/system/Database/Migration.php b/system/Database/Migration.php index c0874ba..670c278 100644 --- a/system/Database/Migration.php +++ b/system/Database/Migration.php @@ -40,7 +40,6 @@ protected $forge; //-------------------------------------------------------------------- - /** * Constructor. * diff --git a/system/Database/MigrationRunner.php b/system/Database/MigrationRunner.php index eeb204f..861fd72 100644 --- a/system/Database/MigrationRunner.php +++ b/system/Database/MigrationRunner.php @@ -738,20 +738,24 @@ */ protected function addHistory($migration, int $batch) { - $this->db->table($this->table) - ->insert([ - 'version' => $migration->version, - 'class' => $migration->class, - 'group' => $this->group, - 'namespace' => $migration->namespace, - 'time' => time(), - 'batch' => $batch, - ]); + $this->db->table($this->table)->insert([ + 'version' => $migration->version, + 'class' => $migration->class, + 'group' => $this->group, + 'namespace' => $migration->namespace, + 'time' => time(), + 'batch' => $batch, + ]); if (is_cli()) { - $this->cliMessages[] = "\t" . CLI::color(lang('Migrations.added'), - 'yellow') . "($migration->namespace) " . $migration->version . '_' . $migration->class; + $this->cliMessages[] = sprintf( + "\t%s(%s) %s_%s", + CLI::color(lang('Migrations.added'), 'yellow'), + $migration->namespace, + $migration->version, + $migration->class + ); } } @@ -770,8 +774,13 @@ if (is_cli()) { - $this->cliMessages[] = "\t" . CLI::color(lang('Migrations.removed'), - 'yellow') . "($history->namespace) " . $history->version . '_' . $this->getMigrationName($history->class); + $this->cliMessages[] = sprintf( + "\t%s(%s) %s_%s", + CLI::color(lang('Migrations.removed'), 'yellow'), + $history->namespace, + $history->version, + $history->class + ); } } diff --git a/system/Database/ModelFactory.php b/system/Database/ModelFactory.php index 47a326a..6900fa3 100644 --- a/system/Database/ModelFactory.php +++ b/system/Database/ModelFactory.php @@ -17,6 +17,8 @@ * Returns new or shared Model instances * * @deprecated Use CodeIgniter\Config\Factories::models() + * + * @codeCoverageIgnore */ class ModelFactory { diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 5916db8..e3c2838 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -121,11 +121,26 @@ { $ssl = []; - empty($this->encrypt['ssl_key']) || $ssl['key'] = $this->encrypt['ssl_key']; - empty($this->encrypt['ssl_cert']) || $ssl['cert'] = $this->encrypt['ssl_cert']; - empty($this->encrypt['ssl_ca']) || $ssl['ca'] = $this->encrypt['ssl_ca']; - empty($this->encrypt['ssl_capath']) || $ssl['capath'] = $this->encrypt['ssl_capath']; - empty($this->encrypt['ssl_cipher']) || $ssl['cipher'] = $this->encrypt['ssl_cipher']; + if (! empty($this->encrypt['ssl_key'])) + { + $ssl['key'] = $this->encrypt['ssl_key']; + } + if (! empty($this->encrypt['ssl_cert'])) + { + $ssl['cert'] = $this->encrypt['ssl_cert']; + } + if (! empty($this->encrypt['ssl_ca'])) + { + $ssl['ca'] = $this->encrypt['ssl_ca']; + } + if (! empty($this->encrypt['ssl_capath'])) + { + $ssl['capath'] = $this->encrypt['ssl_capath']; + } + if (! empty($this->encrypt['ssl_cipher'])) + { + $ssl['cipher'] = $this->encrypt['ssl_cipher']; + } if (! empty($ssl)) { @@ -142,7 +157,7 @@ // // https://secure.php.net/ChangeLog-5.php#5.6.16 // https://bugs.php.net/bug.php?id=68344 - elseif (defined('MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT') && version_compare($this->mysqli->client_info, '5.6', '>=')) + elseif (defined('MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT') && version_compare($this->mysqli->client_info, 'mysqlnd 5.6', '>=')) { $clientFlags += MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT; } @@ -163,7 +178,7 @@ ) { // Prior to version 5.7.3, MySQL silently downgrades to an unencrypted connection if SSL setup fails - if (($clientFlags & MYSQLI_CLIENT_SSL) && version_compare($this->mysqli->client_info, '5.7.3', '<=') + if (($clientFlags & MYSQLI_CLIENT_SSL) && version_compare($this->mysqli->client_info, 'mysqlnd 5.7.3', '<=') && empty($this->mysqli->query("SHOW STATUS LIKE 'ssl_cipher'") ->fetch_object()->Value) ) @@ -315,7 +330,8 @@ } catch (mysqli_sql_exception $e) { - log_message('error', $e); + log_message('error', $e->getMessage()); + if ($this->DBDebug) { throw $e; @@ -529,14 +545,7 @@ } elseif ($index['Non_unique']) { - if ($index['Index_type'] === 'SPATIAL') - { - $type = 'SPATIAL'; - } - else - { - $type = 'INDEX'; - } + $type = $index['Index_type'] === 'SPATIAL' ? 'SPATIAL' : 'INDEX'; } else { @@ -630,12 +639,10 @@ /** * Returns the last error code and message. + * Must return this format: ['code' => string|int, 'message' => string] + * intval(code) === 0 means "no error". * - * Must return an array with keys 'code' and 'message': - * - * return ['code' => null, 'message' => null); - * - * @return array + * @return array */ public function error(): array { diff --git a/system/Database/MySQLi/Forge.php b/system/Database/MySQLi/Forge.php index 6251054..4bb6e22 100644 --- a/system/Database/MySQLi/Forge.php +++ b/system/Database/MySQLi/Forge.php @@ -88,8 +88,10 @@ * NULL value representation in CREATE/ALTER TABLE statements * * @var string + * + * @internal */ - protected $_null = 'NULL'; + protected $null = 'NULL'; //-------------------------------------------------------------------- @@ -235,8 +237,10 @@ continue; } - // @phpstan-ignore-next-line - is_array($this->keys[$i]) || $this->keys[$i] = [$this->keys[$i]]; + if (! is_array($this->keys[$i])) + { + $this->keys[$i] = [$this->keys[$i]]; + } $unique = in_array($i, $this->uniqueKeys, true) ? 'UNIQUE ' : ''; diff --git a/system/Database/MySQLi/Result.php b/system/Database/MySQLi/Result.php index 6ceb923..5488212 100644 --- a/system/Database/MySQLi/Result.php +++ b/system/Database/MySQLi/Result.php @@ -12,7 +12,7 @@ namespace CodeIgniter\Database\MySQLi; use CodeIgniter\Database\BaseResult; -use CodeIgniter\Entity; +use CodeIgniter\Entity\Entity; use stdClass; /** diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 34e953d..d77bcb2 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -174,19 +174,11 @@ $table = $this->QBFrom[0]; - $set = $this->binds; - - // We need to grab out the actual values from - // the way binds are stored with escape flag. - array_walk($set, function (&$item) { - $item = $item[0]; - }); - - $keys = array_keys($set); - $values = array_values($set); + $key = array_key_first($set); + $value = $set[$key]; $builder = $this->db->table($table); - $exists = $builder->where("$keys[0] = $values[0]", null, false)->get()->getFirstRow(); + $exists = $builder->where("$key = $value", null, false)->get()->getFirstRow(); if (empty($exists)) { @@ -195,7 +187,7 @@ else { array_pop($set); - $result = $builder->update($set, "$keys[0] = $values[0]"); + $result = $builder->update($set, "$key = $value"); } unset($builder); @@ -204,7 +196,37 @@ return $result; } - //-------------------------------------------------------------------- + /** + * Insert statement + * + * Generates a platform-specific insert string from the supplied data + * + * @param string $table The table name + * @param array $keys The insert keys + * @param array $unescapedKeys The insert values + * + * @return string + */ + protected function _insert(string $table, array $keys, array $unescapedKeys): string + { + return trim(sprintf('INSERT INTO %s (%s) VALUES (%s) %s', $table, implode(', ', $keys), implode(', ', $unescapedKeys), $this->compileIgnore('insert'))); + } + + /** + * Insert batch statement + * + * Generates a platform-specific insert string from the supplied data. + * + * @param string $table Table name + * @param array $keys INSERT keys + * @param array $values INSERT values + * + * @return string + */ + protected function _insertBatch(string $table, array $keys, array $values): string + { + return trim(sprintf('INSERT INTO %s (%s) VALUES %s %s', $table, implode(', ', $keys), implode(', ', $values), $this->compileIgnore('insert'))); + } /** * Delete diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index ca245ba..010bf48 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -436,18 +436,16 @@ /** * Returns the last error code and message. + * Must return this format: ['code' => string|int, 'message' => string] + * intval(code) === 0 means "no error". * - * Must return an array with keys 'code' and 'message': - * - * return ['code' => null, 'message' => null); - * - * @return array + * @return array */ public function error(): array { return [ 'code' => '', - 'message' => pg_last_error($this->connID), + 'message' => pg_last_error($this->connID) ?: '', ]; } @@ -507,7 +505,10 @@ */ protected function buildDSN() { - $this->DSN === '' || $this->DSN = ''; // @phpstan-ignore-line + if ($this->DSN !== '') + { + $this->DSN = ''; + } // If UNIX sockets are used, we shouldn't set a port if (strpos($this->hostname, '/') !== false) @@ -515,7 +516,10 @@ $this->port = ''; } - $this->hostname === '' || $this->DSN = "host={$this->hostname} "; + if ($this->hostname !== '') + { + $this->DSN = "host={$this->hostname} "; + } if (! empty($this->port) && ctype_digit($this->port)) { @@ -528,11 +532,16 @@ // An empty password is valid! // password must be set to null to ignore it. - - $this->password === null || $this->DSN .= "password='{$this->password}' "; + if ($this->password !== null) + { + $this->DSN .= "password='{$this->password}' "; + } } - $this->database === '' || $this->DSN .= "dbname={$this->database} "; + if ($this->database !== '') + { + $this->DSN .= "dbname={$this->database} "; + } // We don't have these options as elements in our standard configuration // array, but they might be set by parse_url() if the configuration was @@ -600,4 +609,24 @@ } // -------------------------------------------------------------------- + + /** + * Determines if a query is a "write" type. + * + * Overrides BaseConnection::isWriteType, adding additional read query types. + * + * @param string $sql An SQL query string + * @return boolean + */ + public function isWriteType($sql): bool + { + if (preg_match('#^(INSERT|UPDATE).*RETURNING\s.+(\,\s?.+)*$#is', $sql)) + { + return false; + } + + return parent::isWriteType($sql); + } + + // -------------------------------------------------------------------- } diff --git a/system/Database/Postgre/Forge.php b/system/Database/Postgre/Forge.php index 7ede567..ec163f4 100644 --- a/system/Database/Postgre/Forge.php +++ b/system/Database/Postgre/Forge.php @@ -53,8 +53,10 @@ * NULL value representation in CREATE/ALTER TABLE statements * * @var string + * + * @internal */ - protected $_null = 'NULL'; + protected $null = 'NULL'; //-------------------------------------------------------------------- diff --git a/system/Database/Postgre/Result.php b/system/Database/Postgre/Result.php index 3f0dbc5..6964d7a 100644 --- a/system/Database/Postgre/Result.php +++ b/system/Database/Postgre/Result.php @@ -12,7 +12,7 @@ namespace CodeIgniter\Database\Postgre; use CodeIgniter\Database\BaseResult; -use CodeIgniter\Entity; +use CodeIgniter\Entity\Entity; use stdClass; /** diff --git a/system/Database/Query.php b/system/Database/Query.php index 6891ebe..e548dfc 100644 --- a/system/Database/Query.php +++ b/system/Database/Query.php @@ -84,8 +84,6 @@ */ public $db; - //-------------------------------------------------------------------- - /** * BaseQuery constructor. * @@ -96,8 +94,6 @@ $this->db = $db; } - //-------------------------------------------------------------------- - /** * Sets the raw query string to use for this statement. * @@ -133,8 +129,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Will store the variables to bind into the query later. * @@ -160,8 +154,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Returns the final, processed query string after binding, etal * has been performed. @@ -180,8 +172,6 @@ return $this->finalQueryString; } - //-------------------------------------------------------------------- - /** * Records the execution time of the statement using microtime(true) * for it's start and end values. If no end value is present, will @@ -206,8 +196,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Returns the start time in seconds with microseconds. * @@ -226,8 +214,6 @@ return number_format($this->startTime, $decimals); } - //-------------------------------------------------------------------- - /** * Returns the duration of this query during execution, or null if * the query has not been executed yet. @@ -241,8 +227,6 @@ return number_format(($this->endTime - $this->startTime), $decimals); } - //-------------------------------------------------------------------- - /** * Stores the error description that happened for this query. * @@ -259,8 +243,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Reports whether this statement created an error not. * @@ -271,8 +253,6 @@ return ! empty($this->errorString); } - //-------------------------------------------------------------------- - /** * Returns the error code created while executing this statement. * @@ -283,8 +263,6 @@ return $this->errorCode; } - //-------------------------------------------------------------------- - /** * Returns the error message created while executing this statement. * @@ -295,8 +273,6 @@ return $this->errorString; } - //-------------------------------------------------------------------- - /** * Determines if the statement is a write-type query or not. * @@ -304,12 +280,9 @@ */ public function isWriteType(): bool { - return (bool) preg_match( - '/^\s*"?(SET|INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|TRUNCATE|LOAD|COPY|ALTER|RENAME|GRANT|REVOKE|LOCK|UNLOCK|REINDEX)\s/i', $this->originalQueryString); + return $this->db->isWriteType($this->originalQueryString); } - //-------------------------------------------------------------------- - /** * Swaps out one table prefix for a new one. * @@ -327,8 +300,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Returns the original SQL that was passed into the system. * @@ -339,23 +310,22 @@ return $this->originalQueryString; } - //-------------------------------------------------------------------- - /** * Escapes and inserts any binds into the finalQueryString object. * - * @return null|void + * @return void + * + * @see https://regex101.com/r/EUEhay/1 Test */ protected function compileBinds() { $sql = $this->finalQueryString; - $hasNamedBinds = strpos($sql, ':') !== false && strpos($sql, ':=') === false; + $hasNamedBinds = preg_match('/:[a-z\d.)_(]+:/i', $sql) === 1; if (empty($this->binds) || empty($this->bindMarker) - || (strpos($sql, $this->bindMarker) === false - && $hasNamedBinds === false) + || (! $hasNamedBinds && strpos($sql, $this->bindMarker) === false) ) { return; @@ -382,20 +352,11 @@ // We'll need marker length later $ml = strlen($this->bindMarker); - if ($hasNamedBinds) - { - $sql = $this->matchNamedBinds($sql, $binds); - } - else - { - $sql = $this->matchSimpleBinds($sql, $binds, $bindCount, $ml); - } + $sql = $hasNamedBinds ? $this->matchNamedBinds($sql, $binds) : $this->matchSimpleBinds($sql, $binds, $bindCount, $ml); $this->finalQueryString = $sql; } - //-------------------------------------------------------------------- - /** * Match bindings * @@ -426,8 +387,6 @@ return strtr($sql, $replacers); } - //-------------------------------------------------------------------- - /** * Match bindings * @@ -440,7 +399,7 @@ protected function matchSimpleBinds(string $sql, array $binds, int $bindCount, int $ml): string { // Make sure not to replace a chunk inside a string that happens to match the bind marker - if ($c = preg_match_all("/'[^']*'/i", $sql, $matches)) + if ($c = preg_match_all("/'[^']*'/", $sql, $matches)) { $c = preg_match_all('/' . preg_quote($this->bindMarker, '/') . '/i', str_replace($matches[0], str_replace($this->bindMarker, str_repeat(' ', $ml), $matches[0]), $sql, $c), $matches, PREG_OFFSET_CAPTURE); @@ -471,8 +430,6 @@ return $sql; } - //-------------------------------------------------------------------- - /** * Returns string to display in debug toolbar * @@ -515,7 +472,12 @@ ')', ]; - $sql = $this->getQuery(); + if (empty($this->finalQueryString)) + { + $this->compileBinds(); // @codeCoverageIgnore + } + + $sql = $this->finalQueryString; foreach ($highlight as $term) { @@ -534,6 +496,4 @@ { return $this->getQuery(); } - - //-------------------------------------------------------------------- } diff --git a/system/Database/SQLSRV/Builder.php b/system/Database/SQLSRV/Builder.php index be26086..07142bb 100755 --- a/system/Database/SQLSRV/Builder.php +++ b/system/Database/SQLSRV/Builder.php @@ -63,6 +63,25 @@ //-------------------------------------------------------------------- /** + * FROM tables + * + * Groups tables in FROM clauses if needed, so there is no confusion + * about operator precedence. + * + * @return string + */ + protected function _fromTables(): string + { + $from = []; + foreach ($this->QBFrom as $value) + { + $from[] = $this->getFullName($value); + } + + return implode(', ', $from); + } + + /** * Truncate statement * * Generates a platform-specific truncate string from the supplied data @@ -80,6 +99,97 @@ } /** + * JOIN + * + * Generates the JOIN portion of the query + * + * @param string $table + * @param string $cond The join condition + * @param string $type The type of join + * @param boolean $escape Whether not to try to escape identifiers + * + * @return $this + */ + public function join(string $table, string $cond, string $type = '', bool $escape = null) + { + if ($type !== '') + { + $type = strtoupper(trim($type)); + + if (! in_array($type, $this->joinTypes, true)) + { + $type = ''; + } + else + { + $type .= ' '; + } + } + + // Extract any aliases that might exist. We use this information + // in the protectIdentifiers to know whether to add a table prefix + $this->trackAliases($table); + + if (! is_bool($escape)) + { + $escape = $this->db->protectIdentifiers; + } + + if (! $this->hasOperator($cond)) + { + $cond = ' USING (' . ($escape ? $this->db->escapeIdentifiers($cond) : $cond) . ')'; + } + elseif ($escape === false) + { + $cond = ' ON ' . $cond; + } + else + { + // Split multiple conditions + if (preg_match_all('/\sAND\s|\sOR\s/i', $cond, $joints, PREG_OFFSET_CAPTURE)) + { + $conditions = []; + $joints = $joints[0]; + array_unshift($joints, ['', 0]); + + for ($i = count($joints) - 1, $pos = strlen($cond); $i >= 0; $i --) + { + $joints[$i][1] += strlen($joints[$i][0]); // offset + $conditions[$i] = substr($cond, $joints[$i][1], $pos - $joints[$i][1]); + $pos = $joints[$i][1] - strlen($joints[$i][0]); + $joints[$i] = $joints[$i][0]; + } + ksort($conditions); + } + else + { + $conditions = [$cond]; + $joints = ['']; + } + + $cond = ' ON '; + foreach ($conditions as $i => $condition) + { + $operator = $this->getOperator($condition); + + $cond .= $joints[$i]; + $cond .= preg_match("/(\(*)?([\[\]\w\.'-]+)" . preg_quote($operator) . '(.*)/i', $condition, $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $condition; + } + } + + // Do we want to escape the table name? + if ($escape === true) + { + $table = $this->db->protectIdentifiers($table, true, null, false); + } + + // Assemble the JOIN statement + $this->QBJoin[] = $join = $type . 'JOIN ' . $this->getFullName($table) . $cond; + + return $this; + } + + /** * Insert statement * * Generates a platform-specific insert string from the supplied data @@ -189,11 +299,21 @@ */ private function getFullName(string $table): string { + $alias = ''; + + if (strpos($table, ' ') !== false) + { + $alias = explode(' ', $table); + $table = array_shift($alias); + $alias = ' ' . implode(' ', $alias); + } + if ($this->db->escapeChar === '"') { - return '"' . $this->db->getDatabase() . '"."' . $this->db->schema . '"."' . str_replace('"', '', $table) . '"'; + return '"' . $this->db->getDatabase() . '"."' . $this->db->schema . '"."' . str_replace('"', '', $table) . '"' . $alias; } - return '[' . $this->db->getDatabase() . '].[' . $this->db->schema . '].[' . str_replace('"', '', $table) . ']'; + + return '[' . $this->db->getDatabase() . '].[' . $this->db->schema . '].[' . str_replace('"', '', $table) . ']' . str_replace('"', '', $alias); } /** @@ -315,49 +435,51 @@ } // Get the unique field names - $keyFields = array_values(array_flip(array_flip($keyFields))); + $escKeyFields = array_map(function (string $field): string { + return $this->db->protectIdentifiers($field); + }, array_values(array_unique($keyFields))); - // Get the fields out of binds - $set = $this->binds; - array_walk($set, function (&$item, $key) { + // Get the binds + $binds = $this->binds; + array_walk($binds, function (&$item) { $item = $item[0]; }); - // Get the common field and values from the bind data and index fields - $setKeys = array_keys($set); - $common = array_intersect($setKeys, $keyFields); + // Get the common field and values from the keys data and index fields + $common = array_intersect($keys, $escKeyFields); + $bingo = []; - $bingo = []; - foreach ($common as $k => $v) + foreach ($common as $v) { - $bingo[$v] = $set[$v]; + $k = array_search($v, $escKeyFields, true); + + $bingo[$keyFields[$k]] = $binds[trim($values[$k], ':')]; } // Querying existing data $builder = $this->db->table($table); + foreach ($bingo as $k => $v) { $builder->where($k, $v); } + $q = $builder->get()->getResult(); // Delete entries if we find them if ($q !== []) { $delete = $this->db->table($table); + foreach ($bingo as $k => $v) { $delete->where($k, $v); } + $delete->delete(); } - // Key field names are not escaped, so escape them - $escapedKeyFields = array_map(function ($item) { - return $this->db->escapeIdentifiers($item); - }, $keyFields); - - return 'INSERT INTO ' . $this->getFullName($table) . ' (' . implode(',', $keys) . ') VALUES (' . implode(',', $values) . ');'; + return sprintf('INSERT INTO %s (%s) VALUES (%s);', $this->getFullName($table), implode(',', $keys), implode(',', $values)); } /** @@ -481,21 +603,11 @@ $sql = (! $this->QBDistinct) ? 'SELECT ' : 'SELECT DISTINCT '; // SQL Server can't work with select * if group by is specified - if (empty($this->QBSelect) && ! empty($this->QBGroupBy)) + if (empty($this->QBSelect) && ! empty($this->QBGroupBy) && is_array($this->QBGroupBy)) { - if (is_array($this->QBGroupBy)) + foreach ($this->QBGroupBy as $field) { - foreach ($this->QBGroupBy as $field) - { - if (is_array($field)) - { - $this->QBSelect[] = $field['field']; - } - else - { - $this->QBSelect[] = $field; - } - } + $this->QBSelect[] = is_array($field) ? $field['field'] : $field; } } @@ -562,7 +674,10 @@ } // If the escape value was not set will base it on the global setting - is_bool($escape) || $escape = $this->db->protectIdentifiers; + if (! is_bool($escape)) + { + $escape = $this->db->protectIdentifiers; + } foreach ($key as $k => $v) { diff --git a/system/Database/SQLSRV/Connection.php b/system/Database/SQLSRV/Connection.php index 65206c4..c7b6b1d 100755 --- a/system/Database/SQLSRV/Connection.php +++ b/system/Database/SQLSRV/Connection.php @@ -11,6 +11,7 @@ namespace CodeIgniter\Database\SQLSRV; +use CodeIgniter\Database\Query; use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; use Exception; @@ -102,7 +103,10 @@ /** * Connect to the database. * - * @param boolean $persistent + * @param boolean $persistent + * + * @throws DatabaseException + * * @return mixed */ public function connect(bool $persistent = false) @@ -113,9 +117,9 @@ 'UID' => empty($this->username) ? '' : $this->username, 'PWD' => empty($this->password) ? '' : $this->password, 'Database' => $this->database, - 'ConnectionPooling' => ($persistent === true) ? 1 : 0, + 'ConnectionPooling' => $persistent ? 1 : 0, 'CharacterSet' => $charset, - 'Encrypt' => ($this->encrypt === true) ? 1 : 0, + 'Encrypt' => $this->encrypt === true ? 1 : 0, 'ReturnDatesAsStrings' => 1, ]; @@ -126,9 +130,10 @@ unset($connection['UID'], $connection['PWD']); } - if (false !== ($this->connID = sqlsrv_connect($this->hostname, $connection))) + $this->connID = sqlsrv_connect($this->hostname, $connection); + + if ($this->connID !== false) { - /* Disable warnings as errors behavior. */ sqlsrv_configure('WarningsReturnAsErrors', 0); // Determine how identifiers are escaped @@ -137,9 +142,18 @@ $this->_quoted_identifier = empty($query) ? false : (bool) $query[0]->qi; $this->escapeChar = ($this->_quoted_identifier) ? '"' : ['[', ']']; + + return $this->connID; } - return $this->connID; + $errors = []; + + foreach (sqlsrv_errors(SQLSRV_ERR_ERRORS) as $error) + { + $errors[] = preg_replace('/(\[.+\]\[.+\](?:\[.+\])?)(.+)/', '$2', $error['message']); + } + + throw new DatabaseException(implode("\n", $errors)); } /** @@ -398,12 +412,10 @@ /** * Returns the last error code and message. + * Must return this format: ['code' => string|int, 'message' => string] + * intval(code) === 0 means "no error". * - * Must return an array with keys 'code' and 'message': - * - * return ['code' => null, 'message' => null); - * - * @return array + * @return array */ public function error(): array { @@ -504,17 +516,6 @@ } /** - * Determines if a query is a "write" type. - * - * @param string $sql An SQL query string - * @return boolean - */ - public function isWriteType($sql) - { - return (bool) preg_match('/^\s*"?(SET|INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|TRUNCATE|LOAD|COPY|ALTER|RENAME|GRANT|REVOKE|LOCK|UNLOCK|REINDEX|MERGE)\s/i', $sql); - } - - /** * Returns the last error encountered by this connection. * * @return mixed @@ -580,4 +581,27 @@ return isset($info['SQLServerVersion']) ? $this->dataCache['version'] = $info['SQLServerVersion'] : false; } + + // -------------------------------------------------------------------- + + /** + * Determines if a query is a "write" type. + * + * Overrides BaseConnection::isWriteType, adding additional read query types. + * + * @param string $sql An SQL query string + * @return boolean + */ + public function isWriteType($sql): bool + { + if (preg_match('/^\s*"?(EXEC\s*sp_rename)\s/i', $sql)) + { + return true; + } + + return parent::isWriteType($sql); + } + + // -------------------------------------------------------------------- + } diff --git a/system/Database/SQLSRV/Result.php b/system/Database/SQLSRV/Result.php index 500a659..b3ae7f9 100755 --- a/system/Database/SQLSRV/Result.php +++ b/system/Database/SQLSRV/Result.php @@ -12,7 +12,7 @@ namespace CodeIgniter\Database\SQLSRV; use CodeIgniter\Database\BaseResult; -use CodeIgniter\Entity; +use CodeIgniter\Entity\Entity; use stdClass; /** @@ -47,7 +47,7 @@ public function getFieldNames(): array { $fieldNames = []; - foreach (sqlsrv_field_metadata($this->resultID) as $offset => $field) + foreach (sqlsrv_field_metadata($this->resultID) as $field) { $fieldNames[] = $field['Name']; } diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index 04ffafa..3bdf1fa 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -11,6 +11,7 @@ namespace CodeIgniter\Database\SQLite3; +use CodeIgniter\Database\Query; use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; use ErrorException; @@ -425,12 +426,10 @@ /** * Returns the last error code and message. + * Must return this format: ['code' => string|int, 'message' => string] + * intval(code) === 0 means "no error". * - * Must return an array with keys 'code' and 'message': - * - * return ['code' => null, 'message' => null); - * - * @return array + * @return array */ public function error(): array { @@ -491,20 +490,6 @@ //-------------------------------------------------------------------- /** - * Determines if the statement is a write-type query or not. - * - * @return boolean - */ - public function isWriteType($sql): bool - { - return (bool) preg_match( - '/^\s*"?(SET|INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|TRUNCATE|LOAD|COPY|ALTER|RENAME|GRANT|REVOKE|LOCK|UNLOCK|REINDEX)\s/i', - $sql); - } - - //-------------------------------------------------------------------- - - /** * Checks to see if the current install supports Foreign Keys * and has them enabled. * diff --git a/system/Database/SQLite3/Forge.php b/system/Database/SQLite3/Forge.php index 69c0f51..b699411 100644 --- a/system/Database/SQLite3/Forge.php +++ b/system/Database/SQLite3/Forge.php @@ -31,8 +31,10 @@ * NULL value representation in CREATE/ALTER TABLE statements * * @var string + * + * @internal */ - protected $_null = 'NULL'; + protected $null = 'NULL'; //-------------------------------------------------------------------- diff --git a/system/Database/SQLite3/Result.php b/system/Database/SQLite3/Result.php index 457ac68..7d1948b 100644 --- a/system/Database/SQLite3/Result.php +++ b/system/Database/SQLite3/Result.php @@ -14,7 +14,7 @@ use Closure; use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\Exceptions\DatabaseException; -use CodeIgniter\Entity; +use CodeIgniter\Entity\Entity; use stdClass; /** diff --git a/system/Database/SQLite3/Table.php b/system/Database/SQLite3/Table.php index 13d8db1..296810a 100644 --- a/system/Database/SQLite3/Table.php +++ b/system/Database/SQLite3/Table.php @@ -101,12 +101,9 @@ // Remove the prefix, if any, since it's // already been added by the time we get here... $prefix = $this->db->DBPrefix; // @phpstan-ignore-line - if (! empty($prefix)) + if (! empty($prefix) && strpos($table, $prefix) === 0) { - if (strpos($table, $prefix) === 0) - { - $table = substr($table, strlen($prefix)); - } + $table = substr($table, strlen($prefix)); } if (! $this->db->tableExists($this->prefixedTableName)) @@ -304,15 +301,10 @@ foreach ($this->fields as $name => $details) { - // Are we modifying the column? - if (isset($details['new_name'])) - { - $newFields[] = $details['new_name']; - } - else - { - $newFields[] = $name; - } + $newFields[] = isset($details['new_name']) + // Are we modifying the column? + ? $details['new_name'] + : $name; $exFields[] = $name; } diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index bc6e0b3..18e30b2 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -189,13 +189,10 @@ // If we've got an error that hasn't been displayed, then convert // it to an Exception and use the Exception handler to display it // to the user. - if (! is_null($error)) + // Fatal Error? + if (! is_null($error) && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) { - // Fatal Error? - if (in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) - { - $this->exceptionHandler(new ErrorException($error['message'], $error['type'], 0, $error['file'], $error['line'])); - } + $this->exceptionHandler(new ErrorException($error['message'], $error['type'], 0, $error['file'], $error['line'])); } } @@ -296,6 +293,12 @@ */ protected function collectVars(Throwable $exception, int $statusCode): array { + $trace = $exception->getTrace(); + if (! empty($this->config->sensitiveDataInTrace)) + { + $this->maskSensitiveData($trace, $this->config->sensitiveDataInTrace); + } + return [ 'title' => get_class($exception), 'type' => get_class($exception), @@ -303,11 +306,52 @@ 'message' => $exception->getMessage() ?? '(null)', 'file' => $exception->getFile(), 'line' => $exception->getLine(), - 'trace' => $exception->getTrace(), + 'trace' => $trace, ]; } /** + * Mask sensitive data in the trace. + * + * @param array|object $trace + * @param array $keysToMask + * @param string $path + */ + protected function maskSensitiveData(&$trace, array $keysToMask, string $path = '') + { + foreach ($keysToMask as $keyToMask) + { + $explode = explode('/', $keyToMask); + $index = end($explode); + + if (strpos(strrev($path . '/' . $index), strrev($keyToMask)) === 0) + { + if (is_array($trace) && array_key_exists($index, $trace)) + { + $trace[$index] = '******************'; + } + elseif (is_object($trace) && property_exists($trace, $index) && isset($trace->$index)) + { + $trace->$index = '******************'; + } + } + } + + if (! is_iterable($trace) && is_object($trace)) + { + $trace = get_object_vars($trace); + } + + if (is_iterable($trace)) + { + foreach ($trace as $pathKey => $subarray) + { + $this->maskSensitiveData($subarray, $keysToMask, $path . '/' . $pathKey); + } + } + } + + /** * Determines the HTTP status code and the exit status code for this request. * * @param Throwable $exception diff --git a/system/Debug/Toolbar.php b/system/Debug/Toolbar.php index 35d9929..54ace95 100644 --- a/system/Debug/Toolbar.php +++ b/system/Debug/Toolbar.php @@ -18,10 +18,13 @@ use CodeIgniter\Format\JSONFormatter; use CodeIgniter\Format\XMLFormatter; use CodeIgniter\HTTP\DownloadResponse; +use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface; use Config\Services; use Config\Toolbar as ToolbarConfig; +use Kint\Kint; /** * Debug Toolbar @@ -84,6 +87,11 @@ */ public function run(float $startTime, float $totalTime, RequestInterface $request, ResponseInterface $response): string { + /** + * @var IncomingRequest $request + * @var Response $response + */ + // Data items used within the view. $data['url'] = current_url(); $data['method'] = $request->getMethod(true); @@ -109,7 +117,26 @@ { foreach ($items as $key => $value) { - $varData[esc($key)] = is_string($value) ? esc($value) : '
' . esc(print_r($value, true)) . '
'; + if (is_string($value)) + { + $varData[esc($key)] = esc($value); + } + else + { + $oldKintMode = Kint::$mode_default; + $oldKintCalledFrom = Kint::$display_called_from; + + Kint::$mode_default = Kint::MODE_RICH; + Kint::$display_called_from = false; + + $kint = @Kint::dump($value); + $kint = substr($kint, strpos($kint, '') + 8 ); + + Kint::$mode_default = $oldKintMode; + Kint::$display_called_from = $oldKintCalledFrom; + + $varData[esc($key)] = $kint; + } } } @@ -140,7 +167,7 @@ $data['vars']['post'][esc($name)] = is_array($value) ? '
' . esc(print_r($value, true)) . '
' : esc($value); } - foreach ($request->headers() as $name => $header) + foreach ($request->headers() as $header) { $data['vars']['headers'][esc($header->getName())] = esc($header->getValueLine()); } @@ -295,6 +322,11 @@ */ public function prepare(RequestInterface $request = null, ResponseInterface $response = null) { + /** + * @var IncomingRequest $request + * @var Response $response + */ + if (CI_DEBUG && ! is_cli()) { global $app; @@ -343,12 +375,19 @@ return; } + $oldKintMode = Kint::$mode_default; + Kint::$mode_default = Kint::MODE_RICH; + $kintScript = @Kint::dump(''); + Kint::$mode_default = $oldKintMode; + $kintScript = substr($kintScript, 0, strpos($kintScript, '') + 8 ); + $script = PHP_EOL . '' . '' . '' + . $kintScript . PHP_EOL; if (strpos($response->getBody(), '') !== false) @@ -437,8 +476,8 @@ { $history = new History(); $history->setFiles( - Services::request()->getGet('debugbar_time'), - $this->config->maxHistory + (int) Services::request()->getGet('debugbar_time'), + $this->config->maxHistory ); $data['collectors'][] = $history->getAsArray(); diff --git a/system/Debug/Toolbar/Views/toolbarloader.js.php b/system/Debug/Toolbar/Views/toolbarloader.js.php index af69338..70242b5 100644 --- a/system/Debug/Toolbar/Views/toolbarloader.js.php +++ b/system/Debug/Toolbar/Views/toolbarloader.js.php @@ -72,13 +72,16 @@ realXHR.addEventListener("readystatechange", function() { // Only success responses and URLs that do not contains "debugbar_time" are tracked if (realXHR.readyState === 4 && realXHR.status.toString()[0] === '2' && realXHR.responseURL.indexOf('debugbar_time') === -1) { - var debugbarTime = realXHR.getResponseHeader('Debugbar-Time'); - if (debugbarTime) { - var h2 = document.querySelector('#ci-history > h2'); - if(h2) { - h2.innerHTML = 'History You have new debug data. '; - var badge = document.querySelector('a[data-tab="ci-history"] > span > .badge'); - badge.className += ' active'; + if (realXHR.getAllResponseHeaders().indexOf("Debugbar-Time") >= 0) { + var debugbarTime = realXHR.getResponseHeader('Debugbar-Time'); + + if (debugbarTime) { + var h2 = document.querySelector('#ci-history > h2'); + if (h2) { + h2.innerHTML = 'History You have new debug data. '; + var badge = document.querySelector('a[data-tab="ci-history"] > span > .badge'); + badge.className += ' active'; + } } } } diff --git a/system/Email/Email.php b/system/Email/Email.php index 8871054..a3c3b27 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -388,7 +388,10 @@ public function __construct($config = null) { $this->initialize($config); - isset(static::$func_overload) || static::$func_overload = (extension_loaded('mbstring') && ini_get('mbstring.func_overload')); + if (! isset(static::$func_overload)) + { + static::$func_overload = (extension_loaded('mbstring') && ini_get('mbstring.func_overload')); + } } /** @@ -407,7 +410,7 @@ $config = get_object_vars($config); } - foreach (get_class_vars(get_class($this)) as $key => $value) + foreach (array_keys(get_class_vars(get_class($this))) as $key) { if (property_exists($this, $key) && isset($config[$key])) { @@ -506,7 +509,10 @@ } $this->setHeader('From', $name . ' <' . $from . '>'); - isset($returnPath) || $returnPath = $from; + if (! isset($returnPath)) + { + $returnPath = $from; + } $this->setHeader('Return-Path', '<' . $returnPath . '>'); $this->tmpArchive['returnPath'] = $returnPath; @@ -746,15 +752,15 @@ */ public function setAttachmentCID($filename) { - for ($i = 0, $c = count($this->attachments); $i < $c; $i ++) + foreach ($this->attachments as $i => $attachment) { - if ($this->attachments[$i]['name'][0] === $filename) + if ($attachment['name'][0] === $filename) { $this->attachments[$i]['multipart'] = 'related'; - $this->attachments[$i]['cid'] = uniqid(basename($this->attachments[$i]['name'][0]) . '@', true); + $this->attachments[$i]['cid'] = uniqid(basename($attachment['name'][0]) . '@', true); - return $this->attachments[$i]['cid']; + return $attachment['cid']; } } @@ -912,7 +918,10 @@ { $this->protocol = strtolower($this->protocol); - in_array($this->protocol, $this->protocols, true) || $this->protocol = 'mail'; // @phpstan-ignore-line + if (! in_array($this->protocol, $this->protocols, true)) + { + $this->protocol = 'mail'; + } return $this->protocol; } @@ -924,7 +933,10 @@ */ protected function getEncoding() { - in_array($this->encoding, $this->bitDepths, true) || $this->encoding = '8bit'; // @phpstan-ignore-line + if (! in_array($this->encoding, $this->bitDepths, true)) + { + $this->encoding = '8bit'; + } foreach ($this->baseCharsets as $charset) { @@ -1155,7 +1167,7 @@ // Trim the word down $temp .= static::substr($line, 0, $charlim - 1); - $line = static::substr($line, $charlim - 1); + $line = static::substr($line, $charlim - 1); } while (static::strlen($line) > $charlim); @@ -1199,13 +1211,10 @@ */ protected function writeHeaders() { - if ($this->protocol === 'mail') + if ($this->protocol === 'mail' && isset($this->headers['Subject'])) { - if (isset($this->headers['Subject'])) - { - $this->subject = $this->headers['Subject']; - unset($this->headers['Subject']); - } + $this->subject = $this->headers['Subject']; + unset($this->headers['Subject']); } reset($this->headers); @@ -1253,7 +1262,7 @@ if ($this->getProtocol() === 'mail') { $this->headerStr .= $hdr; - $this->finalBody = $this->body; + $this->finalBody = $this->body; } else { @@ -1304,7 +1313,7 @@ case 'plain-attach': $boundary = uniqid('B_ATC_', true); - $hdr .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; + $hdr .= 'Content-Type: multipart/mixed; boundary="' . $boundary . '"'; if ($this->getProtocol() === 'mail') { @@ -1328,8 +1337,8 @@ if ($this->attachmentsHaveMultipart('mixed')) { - $atcBoundary = uniqid('B_ATC_', true); - $hdr .= 'Content-Type: multipart/mixed; boundary="' . $atcBoundary . '"'; + $atcBoundary = uniqid('B_ATC_', true); + $hdr .= 'Content-Type: multipart/mixed; boundary="' . $atcBoundary . '"'; $lastBoundary = $atcBoundary; } @@ -1417,27 +1426,28 @@ */ protected function appendAttachments(&$body, $boundary, $multipart = null) { - for ($i = 0, $c = count($this->attachments); $i < $c; $i++) + foreach ($this->attachments as $attachment) { - if (isset($multipart) && $this->attachments[$i]['multipart'] !== $multipart) + if (isset($multipart) && $attachment['multipart'] !== $multipart) { continue; } - - $name = isset($this->attachments[$i]['name'][1]) ? $this->attachments[$i]['name'][1] : basename($this->attachments[$i]['name'][0]); - + $name = isset($attachment['name'][1]) ? $attachment['name'][1] : basename($attachment['name'][0]); $body .= '--' . $boundary . $this->newline - . 'Content-Type: ' . $this->attachments[$i]['type'] . '; name="' . $name . '"' . $this->newline - . 'Content-Disposition: ' . $this->attachments[$i]['disposition'] . ';' . $this->newline + . 'Content-Type: ' . $attachment['type'] . '; name="' . $name . '"' . $this->newline + . 'Content-Disposition: ' . $attachment['disposition'] . ';' . $this->newline . 'Content-Transfer-Encoding: base64' . $this->newline - . (empty($this->attachments[$i]['cid']) ? '' : 'Content-ID: <' . $this->attachments[$i]['cid'] . '>' . $this->newline) + . (empty($attachment['cid']) ? '' : 'Content-ID: <' . $attachment['cid'] . '>' . $this->newline) . $this->newline - . $this->attachments[$i]['content'] . $this->newline; + . $attachment['content'] . $this->newline; } // $name won't be set if no attachments were appended, // and therefore a boundary wouldn't be necessary - empty($name) || $body .= '--' . $boundary . '--'; + if (! empty($name)) + { + $body .= '--' . $boundary . '--'; + } } /** @@ -1600,7 +1610,7 @@ if ((static::strlen($temp) + static::strlen($char)) >= 76) { $output .= $temp . $escape . $this->CRLF; - $temp = ''; + $temp = ''; } // Add the character to our temporary line @@ -1663,11 +1673,14 @@ } // We might already have this set for UTF-8 - isset($chars) || $chars = static::strlen($str); + if (! isset($chars)) + { + $chars = static::strlen($str); + } $output = '=?' . $this->charset . '?Q?'; - for ($i = 0, $length = static::strlen($output); $i < $chars; $i ++) // @phpstan-ignore-line + for ($i = 0, $length = static::strlen($output); $i < $chars; $i ++) { $chr = ($this->charset === 'UTF-8' && extension_loaded('iconv')) ? '=' . implode('=', str_split(strtoupper(bin2hex(iconv_substr($str, $i, 1, $this->charset))), 2)) : '=' . strtoupper(bin2hex($str[$i])); @@ -1775,8 +1788,8 @@ if ($i === $float) { $chunk[] = static::substr($set, 1); - $float += $this->BCCBatchSize; - $set = ''; + $float += $this->BCCBatchSize; + $set = ''; } if ($i === $c - 1) @@ -1815,7 +1828,10 @@ { $this->finalBody = preg_replace_callback( '/\{unwrap\}(.*?)\{\/unwrap\}/si', - [$this, 'removeNLCallback'], + [ + $this, + 'removeNLCallback', + ], $this->finalBody ); } @@ -1931,14 +1947,7 @@ // so this needs to be assigned to a variable $from = $this->cleanEmail($this->headers['From']); - if ($this->validateEmailForShell($from)) - { - $from = '-f ' . $from; - } - else - { - $from = ''; - } + $from = $this->validateEmailForShell($from) ? '-f ' . $from : ''; // is popen() enabled? if (! function_usable('popen') || false === ($fp = @popen($this->mailPath . ' -oi ' . $from . ' -t', 'w'))) @@ -2367,11 +2376,23 @@ // Determine which parts of our raw data needs to be printed $rawData = ''; - is_array($include) || $include = [$include]; // @phpstan-ignore-line + if (! is_array($include)) + { + $include = [$include]; + } - in_array('headers', $include, true) && $rawData = htmlspecialchars($this->headerStr) . "\n"; - in_array('subject', $include, true) && $rawData .= htmlspecialchars($this->subject) . "\n"; - in_array('body', $include, true) && $rawData .= htmlspecialchars($this->finalBody); + if (in_array('headers', $include, true)) + { + $rawData = htmlspecialchars($this->headerStr) . "\n"; + } + if (in_array('subject', $include, true)) + { + $rawData .= htmlspecialchars($this->subject) . "\n"; + } + if (in_array('body', $include, true)) + { + $rawData .= htmlspecialchars($this->finalBody); + } return $msg . ($rawData === '' ? '' : '
' . $rawData . '
'); } diff --git a/system/Encryption/Handlers/OpenSSLHandler.php b/system/Encryption/Handlers/OpenSSLHandler.php index 012f900..3c6c3ef 100644 --- a/system/Encryption/Handlers/OpenSSLHandler.php +++ b/system/Encryption/Handlers/OpenSSLHandler.php @@ -47,14 +47,7 @@ // Allow key override if ($params) { - if (is_array($params) && isset($params['key'])) - { - $this->key = $params['key']; - } - else - { - $this->key = $params; - } + $this->key = is_array($params) && isset($params['key']) ? $params['key'] : $params; } if (empty($this->key)) @@ -90,14 +83,7 @@ // Allow key override if ($params) { - if (is_array($params) && isset($params['key'])) - { - $this->key = $params['key']; - } - else - { - $this->key = $params; - } + $this->key = is_array($params) && isset($params['key']) ? $params['key'] : $params; } if (empty($this->key)) diff --git a/system/Encryption/Handlers/SodiumHandler.php b/system/Encryption/Handlers/SodiumHandler.php index 01645c1..49b49f5 100644 --- a/system/Encryption/Handlers/SodiumHandler.php +++ b/system/Encryption/Handlers/SodiumHandler.php @@ -87,7 +87,7 @@ // Extract info from encrypted data $nonce = self::substr($data, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $ciphertext = self::substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null); + $ciphertext = self::substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // decrypt data $data = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key); diff --git a/system/Entity.php b/system/Entity.php index b9edfb1..e1a2be7 100644 --- a/system/Entity.php +++ b/system/Entity.php @@ -11,640 +11,13 @@ namespace CodeIgniter; -use CodeIgniter\Exceptions\CastException; -use CodeIgniter\I18n\Time; -use DateTime; -use Exception; -use JsonSerializable; -use ReflectionException; -use stdClass; +use CodeIgniter\Entity\Entity as CoreEntity; /** * Entity encapsulation, for use with CodeIgniter\Model + * + * @deprecated use CodeIgniter\Entity\Entity class instead */ -class Entity implements JsonSerializable +class Entity extends CoreEntity { - /** - * Maps names used in sets and gets against unique - * names within the class, allowing independence from - * database column names. - * - * Example: - * $datamap = [ - * 'db_name' => 'class_name' - * ]; - */ - protected $datamap = []; - - protected $dates = [ - 'created_at', - 'updated_at', - 'deleted_at', - ]; - - /** - * Array of field names and the type of value to cast them as - * when they are accessed. - */ - protected $casts = []; - - /** - * Holds the current values of all class vars. - * - * @var array - */ - protected $attributes = []; - - /** - * Holds original copies of all class vars so - * we can determine what's actually been changed - * and not accidentally write nulls where we shouldn't. - * - * @var array - */ - protected $original = []; - - /** - * Holds info whenever properties have to be casted - * - * @var boolean - **/ - private $_cast = true; - - /** - * Allows filling in Entity parameters during construction. - * - * @param array|null $data - */ - public function __construct(array $data = null) - { - $this->syncOriginal(); - - $this->fill($data); - } - - /** - * Takes an array of key/value pairs and sets them as - * class properties, using any `setCamelCasedProperty()` methods - * that may or may not exist. - * - * @param array $data - * - * @return $this - */ - public function fill(array $data = null) - { - if (! is_array($data)) - { - return $this; - } - - foreach ($data as $key => $value) - { - $this->__set($key, $value); - } - - return $this; - } - - //-------------------------------------------------------------------- - - /** - * General method that will return all public and protected - * values of this entity as an array. All values are accessed - * through the __get() magic method so will have any casts, etc - * applied to them. - * - * @param boolean $onlyChanged If true, only return values that have changed since object creation - * @param boolean $cast If true, properties will be casted. - * @param boolean $recursive If true, inner entities will be casted as array as well. - * - * @return array - * @throws Exception - */ - public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recursive = false): array - { - $this->_cast = $cast; - $return = []; - - $keys = array_keys($this->attributes); - $keys = array_filter($keys, function ($key) { - return strpos($key, '_') !== 0; - }); - - if (is_array($this->datamap)) - { - $keys = array_diff($keys, $this->datamap); - $keys = array_unique(array_merge($keys, array_keys($this->datamap))); - } - - // we need to loop over our properties so that we - // allow our magic methods a chance to do their thing. - foreach ($keys as $key) - { - if ($onlyChanged && ! $this->hasChanged($key)) - { - continue; - } - - $return[$key] = $this->__get($key); - - if ($recursive) - { - if ($return[$key] instanceof Entity) - { - $return[$key] = $return[$key]->toArray($onlyChanged, $cast, $recursive); - } - elseif (is_callable([$return[$key], 'toArray'])) - { - $return[$key] = $return[$key]->toArray(); - } - } - } - - $this->_cast = true; - return $return; - } - - //-------------------------------------------------------------------- - - /** - * Returns the raw values of the current attributes. - * - * @param boolean $onlyChanged If true, only return values that have changed since object creation - * @param boolean $recursive If true, inner entities will be casted as array as well. - * - * @return array - */ - public function toRawArray(bool $onlyChanged = false, bool $recursive = false): array - { - $return = []; - - if (! $onlyChanged) - { - if ($recursive) - { - return array_map(function ($value) use ($onlyChanged, $recursive) { - if ($value instanceof Entity) - { - $value = $value->toRawArray($onlyChanged, $recursive); - } - elseif (is_callable([$value, 'toRawArray'])) - { - $value = $value->toRawArray(); - } - return $value; - }, $this->attributes); - } - - return $this->attributes; - } - - foreach ($this->attributes as $key => $value) - { - if (! $this->hasChanged($key)) - { - continue; - } - - if ($recursive) - { - if ($value instanceof Entity) - { - $value = $value->toRawArray($onlyChanged, $recursive); - } - elseif (is_callable([$value, 'toRawArray'])) - { - $value = $value->toRawArray(); - } - } - - $return[$key] = $value; - } - - return $return; - } - - //-------------------------------------------------------------------- - - /** - * Ensures our "original" values match the current values. - * - * @return $this - */ - public function syncOriginal() - { - $this->original = $this->attributes; - - return $this; - } - - /** - * Checks a property to see if it has changed since the entity was created. - * Or, without a parameter, checks if any properties have changed. - * - * @param string $key - * - * @return boolean - */ - public function hasChanged(string $key = null): bool - { - // If no parameter was given then check all attributes - if ($key === null) - { - return $this->original !== $this->attributes; - } - - // Key doesn't exist in either - if (! array_key_exists($key, $this->original) && ! array_key_exists($key, $this->attributes)) - { - return false; - } - - // It's a new element - if (! array_key_exists($key, $this->original) && array_key_exists($key, $this->attributes)) - { - return true; - } - - return $this->original[$key] !== $this->attributes[$key]; - } - - /** - * Magic method to allow retrieval of protected and private - * class properties either by their name, or through a `getCamelCasedProperty()` - * method. - * - * Examples: - * - * $p = $this->my_property - * $p = $this->getMyProperty() - * - * @param string $key - * - * @return mixed - * @throws Exception - */ - public function __get(string $key) - { - $key = $this->mapProperty($key); - $result = null; - - // Convert to CamelCase for the method - $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); - - // if a set* method exists for this key, - // use that method to insert this value. - if (method_exists($this, $method)) - { - $result = $this->$method(); - } - - // Otherwise return the protected property - // if it exists. - elseif (array_key_exists($key, $this->attributes)) - { - $result = $this->attributes[$key]; - } - - // Do we need to mutate this into a date? - if (in_array($key, $this->dates, true)) - { - $result = $this->mutateDate($result); - } - // Or cast it as something? - elseif ($this->_cast && ! empty($this->casts[$key])) - { - $result = $this->castAs($result, $this->casts[$key]); - } - - return $result; - } - - //-------------------------------------------------------------------- - - /** - * Magic method to all protected/private class properties to be easily set, - * either through a direct access or a `setCamelCasedProperty()` method. - * - * Examples: - * - * $this->my_property = $p; - * $this->setMyProperty() = $p; - * - * @param string $key - * @param mixed|null $value - * - * @return $this - * @throws Exception - */ - public function __set(string $key, $value = null) - { - $key = $this->mapProperty($key); - - // Check if the field should be mutated into a date - if (in_array($key, $this->dates, true)) - { - $value = $this->mutateDate($value); - } - - $isNullable = false; - $castTo = false; - - if (array_key_exists($key, $this->casts)) - { - $isNullable = strpos($this->casts[$key], '?') === 0; - $castTo = $isNullable ? substr($this->casts[$key], 1) : $this->casts[$key]; - } - - if (! $isNullable || ! is_null($value)) - { - // CSV casts need to be imploded. - if ($castTo === 'csv') - { - $value = implode(',', $value); - } - - // Array casting requires that we serialize the value - // when setting it so that it can easily be stored - // back to the database. - if ($castTo === 'array') - { - $value = serialize($value); - } - - // JSON casting requires that we JSONize the value - // when setting it so that it can easily be stored - // back to the database. - if (($castTo === 'json' || $castTo === 'json-array') && function_exists('json_encode')) - { - $value = json_encode($value, JSON_UNESCAPED_UNICODE); - - if (json_last_error() !== JSON_ERROR_NONE) - { - throw CastException::forInvalidJsonFormatException(json_last_error()); - } - } - } - - // if a set* method exists for this key, - // use that method to insert this value. - // *) should be outside $isNullable check - SO maybe wants to do sth with null value automatically - $method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); - if (method_exists($this, $method)) - { - $this->$method($value); - - return $this; - } - - // Otherwise, just the value. - // This allows for creation of new class - // properties that are undefined, though - // they cannot be saved. Useful for - // grabbing values through joins, - // assigning relationships, etc. - $this->attributes[$key] = $value; - - return $this; - } - - //-------------------------------------------------------------------- - - /** - * Unsets an attribute property. - * - * @param string $key - * - * @throws ReflectionException - */ - public function __unset(string $key) - { - unset($this->attributes[$key]); - } - - //-------------------------------------------------------------------- - - /** - * Returns true if a property exists names $key, or a getter method - * exists named like for __get(). - * - * @param string $key - * - * @return boolean - */ - public function __isset(string $key): bool - { - $key = $this->mapProperty($key); - - $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); - - if (method_exists($this, $method)) - { - return true; - } - - return isset($this->attributes[$key]); - } - - /** - * Set raw data array without any mutations - * - * @param array $data - * @return $this - */ - public function setAttributes(array $data) - { - $this->attributes = $data; - $this->syncOriginal(); - return $this; - } - - //-------------------------------------------------------------------- - - /** - * Checks the datamap to see if this column name is being mapped, - * and returns the mapped name, if any, or the original name. - * - * @param string $key - * - * @return mixed|string - */ - protected function mapProperty(string $key) - { - if (empty($this->datamap)) - { - return $key; - } - - if (! empty($this->datamap[$key])) - { - return $this->datamap[$key]; - } - - return $key; - } - - //-------------------------------------------------------------------- - - /** - * Converts the given string|timestamp|DateTime|Time instance - * into a \CodeIgniter\I18n\Time object. - * - * @param mixed $value - * - * @return Time|mixed - * @throws Exception - */ - protected function mutateDate($value) - { - if ($value instanceof Time) - { - return $value; - } - - if ($value instanceof DateTime) - { - return Time::instance($value); - } - - if (is_numeric($value)) - { - return Time::createFromTimestamp($value); - } - - if (is_string($value)) - { - return Time::parse($value); - } - - return $value; - } - - //-------------------------------------------------------------------- - - /** - * Provides the ability to cast an item as a specific data type. - * Add ? at the beginning of $type (i.e. ?string) to get NULL instead of casting $value if $value === null - * - * @param mixed $value - * @param string $type - * - * @return mixed - * @throws Exception - */ - protected function castAs($value, string $type) - { - if (strpos($type, '?') === 0) - { - if ($value === null) - { - return null; - } - $type = substr($type, 1); - } - - switch($type) - { - case 'int': - case 'integer': // alias for 'integer' - $value = (int) $value; - break; - case 'float': - $value = (float) $value; - break; - case 'double': - $value = (double) $value; - break; - case 'string': - $value = (string) $value; - break; - case 'bool': - case 'boolean': // alias for 'boolean' - $value = (bool) $value; - break; - case 'csv': - $value = explode(',', $value); - break; - case 'object': - $value = (object) $value; - break; - case 'array': - if (is_string($value) && (strpos($value, 'a:') === 0 || strpos($value, 's:') === 0)) - { - $value = unserialize($value); - } - - $value = (array) $value; - break; - case 'json': - $value = $this->castAsJson($value); - break; - case 'json-array': - $value = $this->castAsJson($value, true); - break; - case 'datetime': - return $this->mutateDate($value); - case 'timestamp': - return strtotime($value); - } - - return $value; - } - - //-------------------------------------------------------------------- - - /** - * Cast as JSON - * - * @param mixed $value - * @param boolean $asArray - * - * @return mixed - * @throws CastException - */ - private function castAsJson($value, bool $asArray = false) - { - $tmp = ! is_null($value) ? ($asArray ? [] : new stdClass) : null; - if (function_exists('json_decode')) - { - if ((is_string($value) && strlen($value) > 1 && in_array($value[0], ['[', '{', '"'], true)) || is_numeric($value)) - { - $tmp = json_decode($value, $asArray); - - if (json_last_error() !== JSON_ERROR_NONE) - { - throw CastException::forInvalidJsonFormatException(json_last_error()); - } - } - } - return $tmp; - } - - /** - * Support for json_encode() - * - * @return array|mixed - * @throws Exception - */ - public function jsonSerialize() - { - return $this->toArray(); - } - - /** - * Change the value of the private $_cast property - * - * @param boolean|null $cast - * @return boolean|Entity - */ - public function cast(bool $cast = null) - { - if (null === $cast) - { - return $this->_cast; - } - $this->_cast = $cast; - return $this; - } } diff --git a/system/Entity/Cast/ArrayCast.php b/system/Entity/Cast/ArrayCast.php new file mode 100644 index 0000000..0a5397f --- /dev/null +++ b/system/Entity/Cast/ArrayCast.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +/** + * Class ArrayCast + */ +class ArrayCast extends BaseCast +{ + /** + * @inheritDoc + */ + public static function get($value, array $params = []): array + { + if (is_string($value) && (strpos($value, 'a:') === 0 || strpos($value, 's:') === 0)) + { + $value = unserialize($value); + } + + return (array) $value; + } + + /** + * @inheritDoc + */ + public static function set($value, array $params = []): string + { + return serialize($value); + } +} diff --git a/system/Entity/Cast/BaseCast.php b/system/Entity/Cast/BaseCast.php new file mode 100644 index 0000000..bee3145 --- /dev/null +++ b/system/Entity/Cast/BaseCast.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\Entity\Cast; + +/** + * Class BaseCast + */ +abstract class BaseCast implements CastInterface +{ + /** + * Get + * + * @param mixed $value Data + * @param array $params Additional param + * + * @return mixed + */ + public static function get($value, array $params = []) + { + return $value; + } + + /** + * Set + * + * @param mixed $value Data + * @param array $params Additional param + * + * @return mixed + */ + public static function set($value, array $params = []) + { + return $value; + } +} diff --git a/system/Entity/Cast/BooleanCast.php b/system/Entity/Cast/BooleanCast.php new file mode 100644 index 0000000..cb35390 --- /dev/null +++ b/system/Entity/Cast/BooleanCast.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +/** + * Class BooleanCast + */ +class BooleanCast extends BaseCast +{ + /** + * @inheritDoc + */ + public static function get($value, array $params = []): bool + { + return (bool) $value; + } +} diff --git a/system/Entity/Cast/CSVCast.php b/system/Entity/Cast/CSVCast.php new file mode 100644 index 0000000..3fd84b8 --- /dev/null +++ b/system/Entity/Cast/CSVCast.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +/** + * Class CSVCast + */ +class CSVCast extends BaseCast +{ + /** + * @inheritDoc + */ + public static function get($value, array $params = []): array + { + return explode(',', $value); + } + + /** + * @inheritDoc + */ + public static function set($value, array $params = []): string + { + return implode(',', $value); + } +} diff --git a/system/Entity/Cast/CastInterface.php b/system/Entity/Cast/CastInterface.php new file mode 100644 index 0000000..66b2d8f --- /dev/null +++ b/system/Entity/Cast/CastInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +/** + * Interface CastInterface + */ +interface CastInterface +{ + /** + * Get + * + * @param mixed $value Data + * @param array $params Additional param + * + * @return mixed + */ + public static function get($value, array $params = []); + + /** + * Set + * + * @param mixed $value Data + * @param array $params Additional param + * + * @return mixed + */ + public static function set($value, array $params = []); +} diff --git a/system/Entity/Cast/DatetimeCast.php b/system/Entity/Cast/DatetimeCast.php new file mode 100644 index 0000000..18fa0a5 --- /dev/null +++ b/system/Entity/Cast/DatetimeCast.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +use CodeIgniter\I18n\Time; +use DateTime; +use Exception; + +/** + * Class DatetimeCast + */ +class DatetimeCast extends BaseCast +{ + /** + * @inheritDoc + * + * @throws Exception + */ + public static function get($value, array $params = []) + { + if ($value instanceof Time) + { + return $value; + } + + if ($value instanceof DateTime) + { + return Time::instance($value); + } + + if (is_numeric($value)) + { + return Time::createFromTimestamp($value); + } + + if (is_string($value)) + { + return Time::parse($value); + } + + return $value; + } +} diff --git a/system/Entity/Cast/FloatCast.php b/system/Entity/Cast/FloatCast.php new file mode 100644 index 0000000..2fc2a8e --- /dev/null +++ b/system/Entity/Cast/FloatCast.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +/** + * Class FloatCast + */ +class FloatCast extends BaseCast +{ + /** + * @inheritDoc + */ + public static function get($value, array $params = []): float + { + return (float) $value; + } +} diff --git a/system/Entity/Cast/IntegerCast.php b/system/Entity/Cast/IntegerCast.php new file mode 100644 index 0000000..366d021 --- /dev/null +++ b/system/Entity/Cast/IntegerCast.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +/** + * Class IntegerCast + */ +class IntegerCast extends BaseCast +{ + /** + * @inheritDoc + */ + public static function get($value, array $params = []): int + { + return (int) $value; + } +} diff --git a/system/Entity/Cast/JsonCast.php b/system/Entity/Cast/JsonCast.php new file mode 100644 index 0000000..9c0c2fc --- /dev/null +++ b/system/Entity/Cast/JsonCast.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +use CodeIgniter\Entity\Exceptions\CastException; +use JsonException; +use stdClass; + +/** + * Class JsonCast + */ +class JsonCast extends BaseCast +{ + /** + * @inheritDoc + */ + public static function get($value, array $params = []) + { + $associative = in_array('array', $params, true); + $tmp = ! is_null($value) ? ($associative ? [] : new stdClass) : null; + + if (function_exists('json_decode') + && ((is_string($value) + && strlen($value) > 1 + && in_array($value[0], ['[', '{', '"'], true)) + || is_numeric($value) + ) + ) + { + try + { + $tmp = json_decode($value, $associative, 512, JSON_THROW_ON_ERROR); + } + catch (JsonException $e) + { + throw CastException::forInvalidJsonFormat($e->getCode()); + } + } + + return $tmp; + } + + /** + * @inheritDoc + */ + public static function set($value, array $params = []): string + { + if (function_exists('json_encode')) + { + try + { + $value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } + catch (JsonException $e) + { + throw CastException::forInvalidJsonFormat($e->getCode()); + } + } + + return $value; + } +} diff --git a/system/Entity/Cast/ObjectCast.php b/system/Entity/Cast/ObjectCast.php new file mode 100644 index 0000000..0988abc --- /dev/null +++ b/system/Entity/Cast/ObjectCast.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +/** + * Class ObjectCast + */ +class ObjectCast extends BaseCast +{ + /** + * @inheritDoc + */ + public static function get($value, array $params = []): object + { + return (object) $value; + } +} diff --git a/system/Entity/Cast/StringCast.php b/system/Entity/Cast/StringCast.php new file mode 100644 index 0000000..17c2442 --- /dev/null +++ b/system/Entity/Cast/StringCast.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +/** + * Class StringCast + */ +class StringCast extends BaseCast +{ + /** + * @inheritDoc + */ + public static function get($value, array $params = []): string + { + return (string) $value; + } +} diff --git a/system/Entity/Cast/TimestampCast.php b/system/Entity/Cast/TimestampCast.php new file mode 100644 index 0000000..d87a15f --- /dev/null +++ b/system/Entity/Cast/TimestampCast.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +use CodeIgniter\Entity\Exceptions\CastException; + +/** + * Class TimestampCast + */ +class TimestampCast extends BaseCast +{ + /** + * @inheritDoc + */ + public static function get($value, array $params = []) + { + $value = strtotime($value); + + if ($value === false) + { + throw CastException::forInvalidTimestamp(); + } + + return $value; + } +} diff --git a/system/Entity/Cast/URICast.php b/system/Entity/Cast/URICast.php new file mode 100644 index 0000000..3379988 --- /dev/null +++ b/system/Entity/Cast/URICast.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity\Cast; + +use CodeIgniter\HTTP\URI; + +/** + * Class URICast + */ +class URICast extends BaseCast +{ + /** + * @inheritDoc + */ + public static function get($value, array $params = []): URI + { + return $value instanceof URI ? $value : new URI($value); + } +} diff --git a/system/Entity/Entity.php b/system/Entity/Entity.php new file mode 100644 index 0000000..9ae6d5e --- /dev/null +++ b/system/Entity/Entity.php @@ -0,0 +1,614 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Entity; + +use CodeIgniter\Entity\Cast\ArrayCast; +use CodeIgniter\Entity\Cast\BooleanCast; +use CodeIgniter\Entity\Cast\CastInterface; +use CodeIgniter\Entity\Cast\CSVCast; +use CodeIgniter\Entity\Cast\DatetimeCast; +use CodeIgniter\Entity\Cast\FloatCast; +use CodeIgniter\Entity\Cast\IntegerCast; +use CodeIgniter\Entity\Cast\JsonCast; +use CodeIgniter\Entity\Cast\ObjectCast; +use CodeIgniter\Entity\Cast\StringCast; +use CodeIgniter\Entity\Cast\TimestampCast; +use CodeIgniter\Entity\Cast\URICast; +use CodeIgniter\Entity\Exceptions\CastException; +use CodeIgniter\I18n\Time; +use Exception; +use JsonSerializable; + +/** + * Entity encapsulation, for use with CodeIgniter\Model + */ +class Entity implements JsonSerializable +{ + /** + * Maps names used in sets and gets against unique + * names within the class, allowing independence from + * database column names. + * + * Example: + * $datamap = [ + * 'db_name' => 'class_name' + * ]; + */ + protected $datamap = []; + + protected $dates = [ + 'created_at', + 'updated_at', + 'deleted_at', + ]; + + /** + * Array of field names and the type of value to cast them as when + * they are accessed. + */ + protected $casts = []; + + /** + * Custom convert handlers + * + * @var array + */ + protected $castHandlers = []; + + /** + * Default convert handlers + * + * @var array + */ + private $defaultCastHandlers = [ + 'array' => ArrayCast::class, + 'bool' => BooleanCast::class, + 'boolean' => BooleanCast::class, + 'csv' => CSVCast::class, + 'datetime' => DatetimeCast::class, + 'double' => FloatCast::class, + 'float' => FloatCast::class, + 'int' => IntegerCast::class, + 'integer' => IntegerCast::class, + 'json' => JsonCast::class, + 'object' => ObjectCast::class, + 'string' => StringCast::class, + 'timestamp' => TimestampCast::class, + 'uri' => URICast::class, + ]; + + /** + * Holds the current values of all class vars. + * + * @var array + */ + protected $attributes = []; + + /** + * Holds original copies of all class vars so we can determine + * what's actually been changed and not accidentally write + * nulls where we shouldn't. + * + * @var array + */ + protected $original = []; + + /** + * Holds info whenever properties have to be casted + * + * @var boolean + **/ + private $_cast = true; + + /** + * Allows filling in Entity parameters during construction. + * + * @param array|null $data + */ + public function __construct(array $data = null) + { + $this->syncOriginal(); + + $this->fill($data); + } + + /** + * Takes an array of key/value pairs and sets them as class + * properties, using any `setCamelCasedProperty()` methods + * that may or may not exist. + * + * @param array $data + * + * @return $this + */ + public function fill(array $data = null) + { + if (! is_array($data)) + { + return $this; + } + + foreach ($data as $key => $value) + { + $this->__set($key, $value); + } + + return $this; + } + + /** + * General method that will return all public and protected values + * of this entity as an array. All values are accessed through the + * __get() magic method so will have any casts, etc applied to them. + * + * @param boolean $onlyChanged If true, only return values that have changed since object creation + * @param boolean $cast If true, properties will be casted. + * @param boolean $recursive If true, inner entities will be casted as array as well. + * + * @return array + */ + public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recursive = false): array + { + $this->_cast = $cast; + + $keys = array_filter(array_keys($this->attributes), function ($key) { + return strpos($key, '_') !== 0; + }); + + if (is_array($this->datamap)) + { + $keys = array_unique( + array_merge(array_diff($keys, $this->datamap), array_keys($this->datamap)) + ); + } + + $return = []; + + // Loop over the properties, to allow magic methods to do their thing. + foreach ($keys as $key) + { + if ($onlyChanged && ! $this->hasChanged($key)) + { + continue; + } + + $return[$key] = $this->__get($key); + + if ($recursive) + { + if ($return[$key] instanceof Entity) + { + $return[$key] = $return[$key]->toArray($onlyChanged, $cast, $recursive); + } + elseif (is_callable([$return[$key], 'toArray'])) + { + $return[$key] = $return[$key]->toArray(); + } + } + } + + $this->_cast = true; + + return $return; + } + + /** + * Returns the raw values of the current attributes. + * + * @param boolean $onlyChanged If true, only return values that have changed since object creation + * @param boolean $recursive If true, inner entities will be casted as array as well. + * + * @return array + */ + public function toRawArray(bool $onlyChanged = false, bool $recursive = false): array + { + $return = []; + + if (! $onlyChanged) + { + if ($recursive) + { + return array_map(function ($value) use ($onlyChanged, $recursive) { + if ($value instanceof Entity) + { + $value = $value->toRawArray($onlyChanged, $recursive); + } + elseif (is_callable([$value, 'toRawArray'])) + { + $value = $value->toRawArray(); + } + + return $value; + }, $this->attributes); + } + + return $this->attributes; + } + + foreach ($this->attributes as $key => $value) + { + if (! $this->hasChanged($key)) + { + continue; + } + + if ($recursive) + { + if ($value instanceof Entity) + { + $value = $value->toRawArray($onlyChanged, $recursive); + } + elseif (is_callable([$value, 'toRawArray'])) + { + $value = $value->toRawArray(); + } + } + + $return[$key] = $value; + } + + return $return; + } + + /** + * Ensures our "original" values match the current values. + * + * @return $this + */ + public function syncOriginal() + { + $this->original = $this->attributes; + + return $this; + } + + /** + * Checks a property to see if it has changed since the entity + * was created. Or, without a parameter, checks if any + * properties have changed. + * + * @param string $key + * + * @return boolean + */ + public function hasChanged(string $key = null): bool + { + // If no parameter was given then check all attributes + if (is_null($key)) + { + return $this->original !== $this->attributes; + } + + // Key doesn't exist in either + if (! array_key_exists($key, $this->original) && ! array_key_exists($key, $this->attributes)) + { + return false; + } + + // It's a new element + if (! array_key_exists($key, $this->original) && array_key_exists($key, $this->attributes)) + { + return true; + } + + return $this->original[$key] !== $this->attributes[$key]; + } + + /** + * Set raw data array without any mutations + * + * @param array $data + * + * @return $this + */ + public function setAttributes(array $data) + { + $this->attributes = $data; + + $this->syncOriginal(); + + return $this; + } + + /** + * Checks the datamap to see if this column name is being mapped, + * and returns the mapped name, if any, or the original name. + * + * @param string $key + * + * @return mixed|string + */ + protected function mapProperty(string $key) + { + if (empty($this->datamap)) + { + return $key; + } + + if (! empty($this->datamap[$key])) + { + return $this->datamap[$key]; + } + + return $key; + } + + /** + * Converts the given string|timestamp|DateTime|Time instance + * into the "CodeIgniter\I18n\Time" object. + * + * @param mixed $value + * + * @throws Exception + * + * @return Time|mixed + */ + protected function mutateDate($value) + { + return DatetimeCast::get($value); + } + + /** + * Provides the ability to cast an item as a specific data type. + * Add ? at the beginning of $type (i.e. ?string) to get NULL + * instead of casting $value if $value === null + * + * @param mixed $value Attribute value + * @param string $attribute Attribute name + * @param string $method Allowed to "get" and "set" + * + * @throws CastException + * + * @return mixed + */ + protected function castAs($value, string $attribute, string $method = 'get') + { + if (empty($this->casts[$attribute])) + { + return $value; + } + + $type = $this->casts[$attribute]; + + $isNullable = false; + + if (strpos($type, '?') === 0) + { + $isNullable = true; + + if (is_null($value)) + { + return null; + } + + $type = substr($type, 1); + } + + //In order not to create a separate handler for the + // json-array type, we transform the required one. + $type = $type === 'json-array' ? 'json[array]' : $type; + + if (! in_array($method, ['get', 'set'], true)) + { + throw CastException::forInvalidMethod($method); + } + + $params = []; + + //Attempt to retrieve additional parameters if specified + // type[param, param2,param3] + if (preg_match('/^(.+)\[(.+)\]$/', $type, $matches)) + { + $type = $matches[1]; + $params = array_map('trim', explode(',', $matches[2])); + } + + if ($isNullable) + { + $params[] = 'nullable'; + } + + $type = trim($type, '[]'); + + $handlers = array_merge($this->defaultCastHandlers, $this->castHandlers); + + if (empty($handlers[$type])) + { + return $value; + } + + if (! is_subclass_of($handlers[$type], CastInterface::class)) + { + throw CastException::forInvalidInterface($handlers[$type]); + } + + return $handlers[$type]::$method($value, $params); + } + + /** + * Cast as JSON + * + * @param mixed $value + * @param boolean $asArray + * + * @throws CastException + * + * @return mixed + */ + private function castAsJson($value, bool $asArray = false) + { + return JsonCast::get($value, $asArray ? ['array'] : []); + } + + /** + * Support for json_encode() + * + * @return array|mixed + */ + public function jsonSerialize() + { + return $this->toArray(); + } + + /** + * Change the value of the private $_cast property + * + * @param boolean|null $cast + * + * @return boolean|Entity + */ + public function cast(bool $cast = null) + { + if (is_null($cast)) + { + return $this->_cast; + } + + $this->_cast = $cast; + + return $this; + } + + /** + * Magic method to all protected/private class properties to be + * easily set, either through a direct access or a + * `setCamelCasedProperty()` method. + * + * Examples: + * $this->my_property = $p; + * $this->setMyProperty() = $p; + * + * @param string $key + * @param mixed|null $value + * + * @throws Exception + * + * @return $this + */ + public function __set(string $key, $value = null) + { + $key = $this->mapProperty($key); + + // Check if the field should be mutated into a date + if (in_array($key, $this->dates, true)) + { + $value = $this->mutateDate($value); + } + + $value = $this->castAs($value, $key, 'set'); + + // if a set* method exists for this key, use that method to + // insert this value. should be outside $isNullable check, + // so maybe wants to do sth with null value automatically + $method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); + + if (method_exists($this, $method)) + { + $this->$method($value); + + return $this; + } + + // Otherwise, just the value. This allows for creation of new + // class properties that are undefined, though they cannot be + // saved. Useful for grabbing values through joins, assigning + // relationships, etc. + $this->attributes[$key] = $value; + + return $this; + } + + /** + * Magic method to allow retrieval of protected and private class properties + * either by their name, or through a `getCamelCasedProperty()` method. + * + * Examples: + * $p = $this->my_property + * $p = $this->getMyProperty() + * + * @param string $key + * + * @throws Exception + * + * @return mixed + */ + public function __get(string $key) + { + $key = $this->mapProperty($key); + + $result = null; + + // Convert to CamelCase for the method + $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); + + // if a set* method exists for this key, + // use that method to insert this value. + if (method_exists($this, $method)) + { + $result = $this->$method(); + } + + // Otherwise return the protected property + // if it exists. + elseif (array_key_exists($key, $this->attributes)) + { + $result = $this->attributes[$key]; + } + + // Do we need to mutate this into a date? + if (in_array($key, $this->dates, true)) + { + $result = $this->mutateDate($result); + } + // Or cast it as something? + elseif ($this->_cast) + { + $result = $this->castAs($result, $key); + } + + return $result; + } + + /** + * Returns true if a property exists names $key, or a getter method + * exists named like for __get(). + * + * @param string $key + * + * @return boolean + */ + public function __isset(string $key): bool + { + $key = $this->mapProperty($key); + + $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); + + if (method_exists($this, $method)) + { + return true; + } + + return isset($this->attributes[$key]); + } + + /** + * Unsets an attribute property. + * + * @param string $key + * + * @return void + */ + public function __unset(string $key): void + { + unset($this->attributes[$key]); + } +} diff --git a/system/Entity/Exceptions/CastException.php b/system/Entity/Exceptions/CastException.php new file mode 100644 index 0000000..420d6ba --- /dev/null +++ b/system/Entity/Exceptions/CastException.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\Entity\Exceptions; + +use CodeIgniter\Exceptions\FrameworkException; + +/** + * CastException is thrown for invalid cast initialization and management. + */ +class CastException extends FrameworkException +{ + /** + * Thrown when the cast class does not extends BaseCast. + * + * @param string $class + * + * @return static + */ + public static function forInvalidInterface(string $class) + { + return new static(lang('Cast.baseCastMissing', [$class])); + } + + /** + * Thrown when the Json format is invalid. + * + * @param integer $error + * + * @return static + */ + public static function forInvalidJsonFormat(int $error) + { + switch ($error) + { + case JSON_ERROR_DEPTH: + return new static(lang('Cast.jsonErrorDepth')); + case JSON_ERROR_STATE_MISMATCH: + return new static(lang('Cast.jsonErrorStateMismatch')); + case JSON_ERROR_CTRL_CHAR: + return new static(lang('Cast.jsonErrorCtrlChar')); + case JSON_ERROR_SYNTAX: + return new static(lang('Cast.jsonErrorSyntax')); + case JSON_ERROR_UTF8: + return new static(lang('Cast.jsonErrorUtf8')); + default: + return new static(lang('Cast.jsonErrorUnknown')); + } + } + + /** + * Thrown when the cast method is not `get` or `set`. + * + * @param string $method + * + * @return static + */ + public static function forInvalidMethod(string $method) + { + return new static(lang('Cast.invalidCastMethod', [$method])); + } + + /** + * Thrown when the casting timestamp is not correct timestamp. + * + * @return static + */ + public static function forInvalidTimestamp() + { + return new static(lang('Cast.invalidTimestamp')); + } +} diff --git a/system/Exceptions/CastException.php b/system/Exceptions/CastException.php index df37765..6e4527d 100644 --- a/system/Exceptions/CastException.php +++ b/system/Exceptions/CastException.php @@ -13,6 +13,10 @@ /** * Cast Exceptions. + * + * @deprecated use CodeIgniter\Entity\Exceptions\CastException instead. + * + * @codeCoverageIgnore */ class CastException extends CriticalError { @@ -27,7 +31,7 @@ public static function forInvalidJsonFormatException(int $error) { - switch($error) + switch ($error) { case JSON_ERROR_DEPTH: return new static(lang('Cast.jsonErrorDepth')); diff --git a/system/Exceptions/DebugTraceableTrait.php b/system/Exceptions/DebugTraceableTrait.php index 2a7bc58..660cdcf 100644 --- a/system/Exceptions/DebugTraceableTrait.php +++ b/system/Exceptions/DebugTraceableTrait.php @@ -20,7 +20,7 @@ * @param integer $code * @param Throwable|null $previous */ - public function __construct(string $message = '', int $code = 0, Throwable $previous = null) + final public function __construct(string $message = '', int $code = 0, Throwable $previous = null) { parent::__construct($message, $code, $previous); @@ -28,7 +28,10 @@ if (isset($trace['class']) && $trace['class'] === static::class) { - ['line' => $this->line, 'file' => $this->file] = $trace; + [ + 'line' => $this->line, + 'file' => $this->file, + ] = $trace; } } } diff --git a/system/Exceptions/FrameworkException.php b/system/Exceptions/FrameworkException.php index 9d47b38..55b29a6 100644 --- a/system/Exceptions/FrameworkException.php +++ b/system/Exceptions/FrameworkException.php @@ -40,7 +40,21 @@ public static function forMissingExtension(string $extension) { - return new static(lang('Core.missingExtension', [$extension])); + if (strpos($extension, 'intl') !== false) + { + // @codeCoverageIgnoreStart + $message = sprintf( + 'The framework needs the following extension(s) installed and loaded: %s.', + $extension + ); + // @codeCoverageIgnoreEnd + } + else + { + $message = lang('Core.missingExtension', [$extension]); + } + + return new static($message); } public static function forNoHandlers(string $class) diff --git a/system/Files/File.php b/system/Files/File.php index dbc99a0..88a3b46 100644 --- a/system/Files/File.php +++ b/system/Files/File.php @@ -200,7 +200,7 @@ { $i = end($parts); array_pop($parts); - array_push($parts, ++ $i); + $parts[] = ++ $i; $destination = $info['dirname'] . '/' . implode($delimiter, $parts) . $extension; } else diff --git a/system/Filters/Filters.php b/system/Filters/Filters.php index ef74821..0e96943 100644 --- a/system/Filters/Filters.php +++ b/system/Filters/Filters.php @@ -24,6 +24,42 @@ class Filters { /** + * The original config file + * + * @var FiltersConfig + */ + protected $config; + + /** + * The active IncomingRequest or CLIRequest + * + * @var RequestInterface + */ + protected $request; + + /** + * The active Response instance + * + * @var ResponseInterface + */ + protected $response; + + /** + * Handle to the modules config. + * + * @var Modules + */ + protected $modules; + + /** + * Whether we've done initial processing + * on the filter lists. + * + * @var boolean + */ + protected $initialized = false; + + /** * The processed filters that will * be used to check against. * @@ -46,42 +82,6 @@ ]; /** - * Any arguments to be passed to filtersClass. - * - * @var array - */ - protected $argumentsClass = []; - - /** - * The original config file - * - * @var FiltersConfig - */ - protected $config; - - /** - * The active IncomingRequest or CLIRequest - * - * @var RequestInterface - */ - protected $request; - - /** - * The active Response instance - * - * @var ResponseInterface - */ - protected $response; - - /** - * Whether we've done initial processing - * on the filter lists. - * - * @var boolean - */ - protected $initialized = false; - - /** * Any arguments to be passed to filters. * * @var array @@ -89,11 +89,11 @@ protected $arguments = []; /** - * Handle to the modules config. + * Any arguments to be passed to filtersClass. * - * @var Modules + * @var array */ - protected $modules; + protected $argumentsClass = []; /** * Constructor. @@ -270,6 +270,25 @@ } /** + * Restores instance to its pre-initialized state. + * Most useful for testing so the service can be + * re-initialized to a different path. + * + * @return $this + */ + public function reset(): self + { + $this->initialized = false; + $this->arguments = $this->argumentsClass = []; + $this->filters = $this->filtersClass = [ + 'before' => [], + 'after' => [], + ]; + + return $this; + } + + /** * Returns the processed filters array. * * @return array diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index dbfacbe..35fb535 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -73,7 +73,6 @@ protected $delay = 0.0; //-------------------------------------------------------------------- - /** * Takes an array of options to set the following possible class properties: * @@ -105,7 +104,6 @@ } //-------------------------------------------------------------------- - /** * Sends an HTTP request to the specified $url. If this is a relative * URL, it will be merged with $this->baseURI to form a complete URL. @@ -130,7 +128,6 @@ } //-------------------------------------------------------------------- - /** * Convenience method for sending a GET request. * @@ -145,7 +142,6 @@ } //-------------------------------------------------------------------- - /** * Convenience method for sending a DELETE request. * @@ -160,7 +156,6 @@ } //-------------------------------------------------------------------- - /** * Convenience method for sending a HEAD request. * @@ -175,7 +170,6 @@ } //-------------------------------------------------------------------- - /** * Convenience method for sending an OPTIONS request. * @@ -190,7 +184,6 @@ } //-------------------------------------------------------------------- - /** * Convenience method for sending a PATCH request. * @@ -205,7 +198,6 @@ } //-------------------------------------------------------------------- - /** * Convenience method for sending a POST request. * @@ -220,7 +212,6 @@ } //-------------------------------------------------------------------- - /** * Convenience method for sending a PUT request. * @@ -375,7 +366,6 @@ } //-------------------------------------------------------------------- - /** * Fires the actual cURL request. * @@ -482,7 +472,7 @@ $set = []; - foreach ($headers as $name => $value) + foreach (array_keys($headers) as $name) { $set[] = $name . ': ' . $this->getHeaderLine($name); } diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index 9ebc08d..89538c4 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -78,6 +78,13 @@ * * @var array|string */ + protected $frameSrc = []; + + /** + * Used for security enforcement + * + * @var array|string + */ protected $imageSrc = []; /** @@ -376,6 +383,26 @@ } /** + * Adds a new valid endpoint for valid frame sources. Can be either + * a URI class or a simple string. + * + * @see http://www.w3.org/TR/CSP/#directive-frame-src + * + * @param string|array $uri + * @param boolean|null $explicitReporting + * + * @return $this + */ + public function addFrameSrc($uri, bool $explicitReporting = null) + { + $this->addOption($uri, 'frameSrc', $explicitReporting ?? $this->reportOnly); + + return $this; + } + + //-------------------------------------------------------------------- + + /** * Adds a new valid endpoint for valid image sources. Can be either * a URI class or a simple string. * @@ -660,6 +687,7 @@ 'font-src' => 'fontSrc', 'form-action' => 'formAction', 'frame-ancestors' => 'frameAncestors', + 'frame-src' => 'frameSrc', 'img-src' => 'imageSrc', 'media-src' => 'mediaSrc', 'object-src' => 'objectSrc', @@ -759,16 +787,13 @@ { $reportSources[] = in_array($value, $this->validSources, true) ? "'{$value}'" : $value; } - else + elseif (strpos($value, 'nonce-') === 0) { - if (strpos($value, 'nonce-') === 0) - { - $sources[] = "'{$value}'"; - } - else + $sources[] = "'{$value}'"; + } + else { $sources[] = in_array($value, $this->validSources, true) ? "'{$value}'" : $value; - } } } diff --git a/system/HTTP/DownloadResponse.php b/system/HTTP/DownloadResponse.php index 5787772..97f0bb9 100644 --- a/system/HTTP/DownloadResponse.php +++ b/system/HTTP/DownloadResponse.php @@ -157,13 +157,10 @@ $mime = null; $charset = ''; - if ($this->setMime === true) + if ($this->setMime === true && ($lastDotPosition = strrpos($this->filename, '.')) !== false) { - if (($lastDotPosition = strrpos($this->filename, '.')) !== false) - { - $mime = Mimes::guessTypeFromExtension(substr($this->filename, $lastDotPosition + 1)); - $charset = $this->charset; - } + $mime = Mimes::guessTypeFromExtension(substr($this->filename, $lastDotPosition + 1)); + $charset = $this->charset; } if (! is_string($mime)) @@ -246,7 +243,6 @@ } //-------------------------------------------------------------------- - /** * Sets the Content Type header for this response with the mime type * and, optionally, the charset. diff --git a/system/HTTP/Exceptions/HTTPException.php b/system/HTTP/Exceptions/HTTPException.php index 539b29a..d2d12ab 100644 --- a/system/HTTP/Exceptions/HTTPException.php +++ b/system/HTTP/Exceptions/HTTPException.php @@ -60,9 +60,8 @@ * @param string $errorNum * @param string $error * - * @return \CodeIgniter\HTTP\Exceptions\HTTPException + * @return HTTPException * - * Not testable with travis-ci; we over-ride the method which triggers it * @codeCoverageIgnore */ public static function forCurlError(string $errorNum, string $error) @@ -238,6 +237,10 @@ * @param string $samesite * * @return HTTPException + * + * @deprecated Use `CookieException::forInvalidSameSite()` instead. + * + * @codeCoverageIgnore */ public static function forInvalidSameSiteSetting(string $samesite) { diff --git a/system/HTTP/Files/FileCollection.php b/system/HTTP/Files/FileCollection.php index f5cb9de..717d32a 100644 --- a/system/HTTP/Files/FileCollection.php +++ b/system/HTTP/Files/FileCollection.php @@ -99,14 +99,14 @@ $name = explode('.', $name); $uploadedFile = $this->getValueDotNotationSyntax($name, $this->files); - return (is_array($uploadedFile) && ($uploadedFile[0] instanceof UploadedFile)) ? + return (is_array($uploadedFile) && ($uploadedFile[array_key_first($uploadedFile)] instanceof UploadedFile)) ? $uploadedFile : null; } if (array_key_exists($name, $this->files)) { $uploadedFile = $this->files[$name]; - return (is_array($uploadedFile) && ($uploadedFile[0] instanceof UploadedFile)) ? + return (is_array($uploadedFile) && ($uploadedFile[array_key_first($uploadedFile)] instanceof UploadedFile)) ? $uploadedFile : null; } } diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index 1e33949..560b652 100755 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -14,6 +14,7 @@ use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\Files\FileCollection; use CodeIgniter\HTTP\Files\UploadedFile; +use CodeIgniter\HTTP\URI; use Config\App; use Config\Services; use InvalidArgumentException; @@ -55,13 +56,29 @@ protected $enableCSRF = false; /** - * A \CodeIgniter\HTTP\URI instance. + * The URI for this request. + * + * Note: This WILL NOT match the actual URL in the browser since for + * everything this cares about (and the router, etc) is the portion + * AFTER the script name. So, if hosted in a sub-folder this will + * appear different than actual URL. If you need that use getPath(). * * @var URI */ public $uri; /** + * The detected path (relative to SCRIPT_NAME). + * + * Note: current_url() uses this to build its URI, + * so this becomes the source for the "current URL" + * when working with the share request instance. + * + * @var string|null + */ + protected $path; + + /** * File collection * * @var FileCollection|null @@ -120,11 +137,10 @@ protected $userAgent; //-------------------------------------------------------------------- - /** * Constructor * - * @param object $config + * @param App $config * @param URI $uri * @param string|null $body * @param UserAgent $userAgent @@ -142,25 +158,16 @@ $body = file_get_contents('php://input'); } - $this->body = ! empty($body) ? $body : null; - $this->config = $config; - $this->userAgent = $userAgent; + $this->config = $config; + $this->uri = $uri; + $this->body = ! empty($body) ? $body : null; + $this->userAgent = $userAgent; + $this->validLocales = $config->supportedLocales; parent::__construct($config); $this->populateHeaders(); - - // Get our current URI. - // NOTE: This WILL NOT match the actual URL in the browser since for - // everything this cares about (and the router, etc) is the portion - // AFTER the script name. So, if hosted in a sub-folder this will - // appear different than actual URL. If you need that, use current_url(). - $this->uri = $uri; - $this->detectURI($config->uriProtocol, $config->baseURL); - - $this->validLocales = $config->supportedLocales; - $this->detectLocale($config); } @@ -184,29 +191,293 @@ $this->setLocale($this->negotiate('language', $config->supportedLocales)); } - //-------------------------------------------------------------------- + /** + * Sets up our URI object based on the information we have. This is + * either provided by the user in the baseURL Config setting, or + * determined from the environment as needed. + * + * @param string $protocol + * @param string $baseURL + */ + protected function detectURI(string $protocol, string $baseURL) + { + // Passing the config is unnecessary but left for legacy purposes + $config = clone $this->config; + $config->baseURL = $baseURL; + + $this->setPath($this->detectPath($protocol), $config); + } /** - * Returns the default locale as set in Config\App.php + * Detects the relative path based on + * the URIProtocol Config setting. + * + * @param string $protocol * * @return string */ - public function getDefaultLocale(): string + public function detectPath(string $protocol = ''): string { - return $this->defaultLocale; + if (empty($protocol)) + { + $protocol = 'REQUEST_URI'; + } + + switch ($protocol) + { + case 'REQUEST_URI': + $this->path = $this->parseRequestURI(); + break; + case 'QUERY_STRING': + $this->path = $this->parseQueryString(); + break; + case 'PATH_INFO': + default: + $this->path = $this->fetchGlobal('server', $protocol) ?? $this->parseRequestURI(); + break; + } + + return $this->path; } //-------------------------------------------------------------------- /** - * Gets the current locale, with a fallback to the default - * locale if none is set. + * Will parse the REQUEST_URI and automatically detect the URI from it, + * fixing the query string if necessary. + * + * @return string The URI it found. + */ + protected function parseRequestURI(): string + { + if (! isset($_SERVER['REQUEST_URI'], $_SERVER['SCRIPT_NAME'])) + { + return ''; + } + + // parse_url() returns false if no host is present, but the path or query string + // contains a colon followed by a number. So we attach a dummy host since + // REQUEST_URI does not include the host. This allows us to parse out the query string and path. + $parts = parse_url('http://dummy' . $_SERVER['REQUEST_URI']); + $query = $parts['query'] ?? ''; + $uri = $parts['path'] ?? ''; + + // Strip the SCRIPT_NAME path from the URI + if ($uri !== '' && isset($_SERVER['SCRIPT_NAME'][0]) && pathinfo($_SERVER['SCRIPT_NAME'], PATHINFO_EXTENSION) === 'php') + { + // Compare each segment, dropping them until there is no match + $segments = $keep = explode('/', $uri); + foreach (explode('/', $_SERVER['SCRIPT_NAME']) as $i => $segment) + { + // If these segments are not the same then we're done + if ($segment !== $segments[$i]) + { + break; + } + + array_shift($keep); + } + + $uri = implode('/', $keep); + } + + // This section ensures that even on servers that require the URI to contain the query string (Nginx) a correct + // URI is found, and also fixes the QUERY_STRING getServer var and $_GET array. + if (trim($uri, '/') === '' && strncmp($query, '/', 1) === 0) + { + $query = explode('?', $query, 2); + $uri = $query[0]; + $_SERVER['QUERY_STRING'] = $query[1] ?? ''; + } + else + { + $_SERVER['QUERY_STRING'] = $query; + } + + // Update our globals for values likely to been have changed + parse_str($_SERVER['QUERY_STRING'], $_GET); + $this->populateGlobals('server'); + $this->populateGlobals('get'); + + $uri = URI::removeDotSegments($uri); + + return ($uri === '/' || $uri === '') ? '/' : ltrim($uri, '/'); + } + + /** + * Parse QUERY_STRING + * + * Will parse QUERY_STRING and automatically detect the URI from it. * * @return string */ - public function getLocale(): string + protected function parseQueryString(): string { - return $this->locale ?? $this->defaultLocale; + $uri = $_SERVER['QUERY_STRING'] ?? @getenv('QUERY_STRING'); + + if (trim($uri, '/') === '') + { + return ''; + } + + if (strncmp($uri, '/', 1) === 0) + { + $uri = explode('?', $uri, 2); + $_SERVER['QUERY_STRING'] = $uri[1] ?? ''; + $uri = $uri[0]; + } + + // Update our globals for values likely to been have changed + parse_str($_SERVER['QUERY_STRING'], $_GET); + $this->populateGlobals('server'); + $this->populateGlobals('get'); + + $uri = URI::removeDotSegments($uri); + + return ($uri === '/' || $uri === '') ? '/' : ltrim($uri, '/'); + } + + //-------------------------------------------------------------------- + + /** + * Provides a convenient way to work with the Negotiate class + * for content negotiation. + * + * @param string $type + * @param array $supported + * @param boolean $strictMatch + * + * @return string + */ + public function negotiate(string $type, array $supported, bool $strictMatch = false): string + { + if (is_null($this->negotiator)) + { + $this->negotiator = Services::negotiator($this, true); + } + + switch (strtolower($type)) + { + case 'media': + return $this->negotiator->media($supported, $strictMatch); + case 'charset': + return $this->negotiator->charset($supported); + case 'encoding': + return $this->negotiator->encoding($supported); + case 'language': + return $this->negotiator->language($supported); + } + + throw HTTPException::forInvalidNegotiationType($type); + } + + //-------------------------------------------------------------------- + + /** + * Determines if this request was made from the command line (CLI). + * + * @return boolean + */ + public function isCLI(): bool + { + return is_cli(); + } + + /** + * Test to see if a request contains the HTTP_X_REQUESTED_WITH header. + * + * @return boolean + */ + public function isAJAX(): bool + { + return $this->hasHeader('X-Requested-With') && strtolower($this->header('X-Requested-With')->getValue()) === 'xmlhttprequest'; + } + + /** + * Attempts to detect if the current connection is secure through + * a few different methods. + * + * @return boolean + */ + public function isSecure(): bool + { + if (! empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') + { + return true; + } + + if ($this->hasHeader('X-Forwarded-Proto') && $this->header('X-Forwarded-Proto')->getValue() === 'https') + { + return true; + } + return $this->hasHeader('Front-End-Https') && ! empty($this->header('Front-End-Https')->getValue()) && strtolower($this->header('Front-End-Https')->getValue()) !== 'off'; + } + + //-------------------------------------------------------------------- + + /** + * Sets the relative path and updates the URI object. + * Note: Since current_url() accesses the shared request + * instance, this can be used to change the "current URL" + * for testing. + * + * @param string $path URI path relative to SCRIPT_NAME + * @param App $config Optional alternate config to use + * + * @return $this + */ + public function setPath(string $path, App $config = null) + { + $this->path = $path; + $this->uri->setPath($path); + + $config = $config ?? $this->config; + + // It's possible the user forgot a trailing slash on their + // baseURL, so let's help them out. + $baseURL = $config->baseURL === '' ? $config->baseURL : rtrim($config->baseURL, '/ ') . '/'; + + // Based on our baseURL provided by the developer + // set our current domain name, scheme + if ($baseURL !== '') + { + $this->uri->setScheme(parse_url($baseURL, PHP_URL_SCHEME)); + $this->uri->setHost(parse_url($baseURL, PHP_URL_HOST)); + $this->uri->setPort(parse_url($baseURL, PHP_URL_PORT)); + + // Ensure we have any query vars + $this->uri->setQuery($_SERVER['QUERY_STRING'] ?? ''); + + // Check if the baseURL scheme needs to be coerced into its secure version + if ($config->forceGlobalSecureRequests && $this->uri->getScheme() === 'http') + { + $this->uri->setScheme('https'); + } + } + // @codeCoverageIgnoreStart + elseif (! is_cli()) + { + die('You have an empty or invalid base URL. The baseURL value must be set in Config\App.php, or through the .env file.'); + } + // @codeCoverageIgnoreEnd + + return $this; + } + + /** + * Returns the path relative to SCRIPT_NAME, + * running detection as necessary. + * + * @return string + */ + public function getPath(): string + { + if (is_null($this->path)) + { + $this->detectPath($this->config->uriProtocol); + } + + return $this->path; } //-------------------------------------------------------------------- @@ -233,62 +504,31 @@ return $this; } - //-------------------------------------------------------------------- + /** + * Gets the current locale, with a fallback to the default + * locale if none is set. + * + * @return string + */ + public function getLocale(): string + { + return $this->locale ?? $this->defaultLocale; + } /** - * Determines if this request was made from the command line (CLI). + * Returns the default locale as set in Config\App.php * - * @return boolean + * @return string */ - public function isCLI(): bool + public function getDefaultLocale(): string { - return is_cli(); + return $this->defaultLocale; } //-------------------------------------------------------------------- /** - * Test to see if a request contains the HTTP_X_REQUESTED_WITH header. - * - * @return boolean - */ - public function isAJAX(): bool - { - return $this->hasHeader('X-Requested-With') && strtolower($this->header('X-Requested-With')->getValue()) === 'xmlhttprequest'; - } - - //-------------------------------------------------------------------- - - /** - * Attempts to detect if the current connection is secure through - * a few different methods. - * - * @return boolean - */ - public function isSecure(): bool - { - if (! empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') - { - return true; - } - - if ($this->hasHeader('X-Forwarded-Proto') && $this->header('X-Forwarded-Proto')->getValue() === 'https') - { - return true; - } - - if ($this->hasHeader('Front-End-Https') && ! empty($this->header('Front-End-Https')->getValue()) && strtolower($this->header('Front-End-Https')->getValue()) !== 'off') - { - return true; - } - - return false; - } - - //-------------------------------------------------------------------- - - /** - * Fetch an item from the $_REQUEST object or a JSON input stream. This is the simplest way + * Fetch an item from JSON input stream with fallback to $_REQUEST object. This is the simplest way * to grab data from the request object and can be used in lieu of the * other get* methods in most cases. * @@ -300,7 +540,7 @@ */ public function getVar($index = null, $filter = null, $flags = null) { - if ($this->isJSON()) + if (strpos($this->getHeaderLine('Content-Type'), 'application/json') !== false && ! is_null($this->body)) { if (is_null($index)) { @@ -312,14 +552,13 @@ $output = []; foreach ($index as $key) { - $output[$key] = $this->getJsonVar($key); + $output[$key] = $this->getJsonVar($key, false, $filter, $flags); } return $output; } - return $this->getJsonVar($index); + return $this->getJsonVar($index, false, $filter, $flags); } - return $this->fetchGlobal('request', $index, $filter, $flags); } @@ -348,20 +587,31 @@ /** * Get a specific variable from a JSON input stream * - * @param string $index The variable that you want which can use dot syntax for getting specific values. - * @param boolean $assoc If true, return the result as an associative array. - * + * @param string $index The variable that you want which can use dot syntax for getting specific values. + * @param boolean $assoc If true, return the result as an associative array. + * @param integer|null $filter Filter Constant + * @param array|integer|null $flags Option * @return mixed */ - public function getJsonVar(string $index, bool $assoc = false) + public function getJsonVar(string $index, bool $assoc = false, ?int $filter = null, $flags = null) { helper('array'); $data = dot_array_search($index, $this->getJSON(true)); - if (is_array($data) && ! $assoc) + + if (! is_array($data)) + { + $filter = $filter ?? FILTER_DEFAULT; + $flags = is_array($flags) ? $flags : (is_numeric($flags) ? (int) $flags : 0); + + return filter_var($data, $filter, $flags); + } + + if (! $assoc) { return json_decode(json_encode($data)); } + return $data; } @@ -467,7 +717,6 @@ } //-------------------------------------------------------------------- - /** * Fetch the user agent string * @@ -495,7 +744,7 @@ // data was previously saved, we're done. if (empty($_SESSION['_ci_old_input'])) { - return; + return null; } // Check for the value in the POST array first. @@ -532,10 +781,12 @@ } } - // // return null if requested session key not found - // return null; + // requested session key not found + return null; } + //-------------------------------------------------------------------- + /** * Returns an array of all files that have been uploaded with this * request. Each file is represented by an UploadedFile instance. @@ -552,8 +803,6 @@ return $this->files->all(); // return all files } - //-------------------------------------------------------------------- - /** * Verify if a file exist, by the name of the input field used to upload it, in the collection * of uploaded files and if is have been uploaded with multiple option. @@ -572,8 +821,6 @@ return $this->files->getFileMultiple($fileID); } - //-------------------------------------------------------------------- - /** * Retrieves a single file by the name of the input field used * to upload it. @@ -595,208 +842,6 @@ //-------------------------------------------------------------------- /** - * Sets up our URI object based on the information we have. This is - * either provided by the user in the baseURL Config setting, or - * determined from the environment as needed. - * - * @param string $protocol - * @param string $baseURL - */ - protected function detectURI(string $protocol, string $baseURL) - { - $this->uri->setPath($this->detectPath($protocol)); - - // It's possible the user forgot a trailing slash on their - // baseURL, so let's help them out. - $baseURL = ! empty($baseURL) ? rtrim($baseURL, '/ ') . '/' : $baseURL; - - // Based on our baseURL provided by the developer - // set our current domain name, scheme - if (! empty($baseURL)) - { - $this->uri->setScheme(parse_url($baseURL, PHP_URL_SCHEME)); - $this->uri->setHost(parse_url($baseURL, PHP_URL_HOST)); - $this->uri->setPort(parse_url($baseURL, PHP_URL_PORT)); - - // Ensure we have any query vars - $this->uri->setQuery($_SERVER['QUERY_STRING'] ?? ''); - } - else - { - // @codeCoverageIgnoreStart - if (! is_cli()) - { - die('You have an empty or invalid base URL. The baseURL value must be set in Config\App.php, or through the .env file.'); - } - // @codeCoverageIgnoreEnd - } - } - - //-------------------------------------------------------------------- - - /** - * Based on the URIProtocol Config setting, will attempt to - * detect the path portion of the current URI. - * - * @param string $protocol - * - * @return string - */ - public function detectPath(string $protocol = ''): string - { - if (empty($protocol)) - { - $protocol = 'REQUEST_URI'; - } - - switch ($protocol) - { - case 'REQUEST_URI': - $path = $this->parseRequestURI(); - break; - case 'QUERY_STRING': - $path = $this->parseQueryString(); - break; - case 'PATH_INFO': - default: - $path = $this->fetchGlobal('server', $protocol) ?? $this->parseRequestURI(); - break; - } - - return $path; - } - - //-------------------------------------------------------------------- - - /** - * Provides a convenient way to work with the Negotiate class - * for content negotiation. - * - * @param string $type - * @param array $supported - * @param boolean $strictMatch - * - * @return string - */ - public function negotiate(string $type, array $supported, bool $strictMatch = false): string - { - if (is_null($this->negotiator)) - { - $this->negotiator = Services::negotiator($this, true); - } - - switch (strtolower($type)) - { - case 'media': - return $this->negotiator->media($supported, $strictMatch); - case 'charset': - return $this->negotiator->charset($supported); - case 'encoding': - return $this->negotiator->encoding($supported); - case 'language': - return $this->negotiator->language($supported); - } - - throw HTTPException::forInvalidNegotiationType($type); - } - - //-------------------------------------------------------------------- - - /** - * Will parse the REQUEST_URI and automatically detect the URI from it, - * fixing the query string if necessary. - * - * @return string The URI it found. - */ - protected function parseRequestURI(): string - { - if (! isset($_SERVER['REQUEST_URI'], $_SERVER['SCRIPT_NAME'])) - { - return ''; - } - - // parse_url() returns false if no host is present, but the path or query string - // contains a colon followed by a number. So we attach a dummy host since - // REQUEST_URI does not include the host. This allows us to parse out the query string and path. - $parts = parse_url('http://dummy' . $_SERVER['REQUEST_URI']); - $query = $parts['query'] ?? ''; - $uri = $parts['path'] ?? ''; - - if (isset($_SERVER['SCRIPT_NAME'][0]) && pathinfo($_SERVER['SCRIPT_NAME'], PATHINFO_EXTENSION) === 'php') - { - // strip the script name from the beginning of the URI - if (strpos($uri, $_SERVER['SCRIPT_NAME']) === 0) - { - $uri = (string) substr($uri, strlen($_SERVER['SCRIPT_NAME'])); - } - // if the script is nested, strip the parent folder & script from the URI - elseif (strpos($uri, $_SERVER['SCRIPT_NAME']) > 0) - { - $uri = (string) substr($uri, strpos($uri, $_SERVER['SCRIPT_NAME']) + strlen($_SERVER['SCRIPT_NAME'])); - } - // or if index.php is implied - elseif (strpos($uri, dirname($_SERVER['SCRIPT_NAME'])) === 0) - { - $uri = (string) substr($uri, strlen(dirname($_SERVER['SCRIPT_NAME']))); - } - } - - // This section ensures that even on servers that require the URI to contain the query string (Nginx) a correct - // URI is found, and also fixes the QUERY_STRING getServer var and $_GET array. - if (trim($uri, '/') === '' && strncmp($query, '/', 1) === 0) - { - $query = explode('?', $query, 2); - $uri = $query[0]; - $_SERVER['QUERY_STRING'] = $query[1] ?? ''; - } - else - { - $_SERVER['QUERY_STRING'] = $query; - } - - parse_str($_SERVER['QUERY_STRING'], $_GET); - - if ($uri === '/' || $uri === '') - { - return '/'; - } - - return $this->removeRelativeDirectory($uri); - } - - //-------------------------------------------------------------------- - - /** - * Parse QUERY_STRING - * - * Will parse QUERY_STRING and automatically detect the URI from it. - * - * @return string - */ - protected function parseQueryString(): string - { - $uri = $_SERVER['QUERY_STRING'] ?? @getenv('QUERY_STRING'); - - if (trim($uri, '/') === '') - { - return ''; - } - - if (strncmp($uri, '/', 1) === 0) - { - $uri = explode('?', $uri, 2); - $_SERVER['QUERY_STRING'] = $uri[1] ?? ''; - $uri = $uri[0]; - } - - parse_str($_SERVER['QUERY_STRING'], $_GET); - - return $this->removeRelativeDirectory($uri); - } - - //-------------------------------------------------------------------- - - /** * Remove relative directory (../) and multi slashes (///) * * Do some final cleaning of the URI and return it, currently only used in static::_parse_request_uri() @@ -804,22 +849,13 @@ * @param string $uri * * @return string + * + * @deprecated Use URI::removeDotSegments() directly */ protected function removeRelativeDirectory(string $uri): string { - $uris = []; - $tok = strtok($uri, '/'); - while ($tok !== false) - { - if ((! empty($tok) || $tok === '0') && $tok !== '..') - { - $uris[] = $tok; - } - $tok = strtok('/'); - } + $uri = URI::removeDotSegments($uri); - return implode('/', $uris); + return $uri === '/' ? $uri : ltrim($uri, '/'); } - - // -------------------------------------------------------------------- } diff --git a/system/HTTP/Message.php b/system/HTTP/Message.php index 93dbe3f..91c472c 100644 --- a/system/HTTP/Message.php +++ b/system/HTTP/Message.php @@ -61,6 +61,8 @@ * @return array An array of the request headers * * @deprecated Use Message::headers() to make room for PSR-7 + * + * @codeCoverageIgnore */ public function getHeaders(): array { @@ -76,6 +78,8 @@ * @return array|Header|null * * @deprecated Use Message::header() to make room for PSR-7 + * + * @codeCoverageIgnore */ public function getHeader(string $name) { @@ -132,24 +136,4 @@ { return $this->protocolVersion ?? '1.1'; } - - /** - * Determines if this is a json message based on the Content-Type header - * - * @return boolean - * - * @deprecated Use header calls directly - */ - public function isJSON() - { - if (! $this->hasHeader('Content-Type')) - { - return false; - } - - $header = $this->header('Content-Type')->getValue(); - $parts = explode(';', $header); - - return in_array('application/json', $parts, true); - } } diff --git a/system/HTTP/MessageTrait.php b/system/HTTP/MessageTrait.php index 4bc6683..aea8fe2 100644 --- a/system/HTTP/MessageTrait.php +++ b/system/HTTP/MessageTrait.php @@ -86,7 +86,7 @@ } unset($contentType); - foreach ($_SERVER as $key => $val) + foreach (array_keys($_SERVER) as $key) { if (sscanf($key, 'HTTP_%s', $header) === 1) { diff --git a/system/HTTP/Negotiate.php b/system/HTTP/Negotiate.php index a881031..4dea868 100644 --- a/system/HTTP/Negotiate.php +++ b/system/HTTP/Negotiate.php @@ -32,7 +32,6 @@ protected $request; //-------------------------------------------------------------------- - /** * Constructor * @@ -47,7 +46,6 @@ } //-------------------------------------------------------------------- - /** * Stores the request instance to grab the headers from. * @@ -127,7 +125,7 @@ */ public function encoding(array $supported = []): string { - array_push($supported, 'identity'); + $supported[] = 'identity'; return $this->getBestMatch($supported, $this->request->getHeaderLine('accept-encoding')); } diff --git a/system/HTTP/RedirectResponse.php b/system/HTTP/RedirectResponse.php index 99eb864..50061c8 100644 --- a/system/HTTP/RedirectResponse.php +++ b/system/HTTP/RedirectResponse.php @@ -11,6 +11,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Cookie\CookieStore; use CodeIgniter\HTTP\Exceptions\HTTPException; use Config\Services; @@ -139,10 +140,7 @@ */ public function withCookies() { - foreach (Services::response()->getCookies() as $cookie) - { - $this->cookies[] = $cookie; - } + $this->cookieStore = new CookieStore(Services::response()->getCookies()); return $this; } @@ -157,7 +155,7 @@ */ public function withHeaders() { - foreach (Services::response()->getHeaders() as $name => $header) + foreach (Services::response()->headers() as $name => $header) { $this->setHeader($name, $header->getValue()); } diff --git a/system/HTTP/Request.php b/system/HTTP/Request.php index 9b4c16d..d0d4009 100644 --- a/system/HTTP/Request.php +++ b/system/HTTP/Request.php @@ -52,7 +52,9 @@ */ public function __construct($config = null) { - /** @deprecated $this->proxyIps property will be removed in the future */ + /** + * @deprecated $this->proxyIps property will be removed in the future + */ $this->proxyIPs = $config->proxyIPs; if (empty($this->method)) @@ -75,6 +77,8 @@ * @return boolean * * @deprecated Use Validation instead + * + * @codeCoverageIgnore */ public function isValidIP(string $ip = null, string $which = null): bool { @@ -89,6 +93,8 @@ * @return string * * @deprecated The $upper functionality will be removed and this will revert to its PSR-7 equivalent + * + * @codeCoverageIgnore */ public function getMethod(bool $upper = false): string { @@ -103,6 +109,8 @@ * @return Request * * @deprecated Use withMethod() instead for immutability + * + * @codeCoverageIgnore */ public function setMethod(string $method) { @@ -127,12 +135,12 @@ return $request; } - /** - * Retrieves the URI instance. - * - * @return URI - */ - public function getUri() + /** + * Retrieves the URI instance. + * + * @return URI + */ + public function getUri() { return $this->uri; } diff --git a/system/HTTP/RequestInterface.php b/system/HTTP/RequestInterface.php index 7c46744..6f10dd4 100644 --- a/system/HTTP/RequestInterface.php +++ b/system/HTTP/RequestInterface.php @@ -14,9 +14,9 @@ /** * Expected behavior of an HTTP request * - * @mixin \CodeIgniter\HTTP\IncomingRequest - * @mixin \CodeIgniter\HTTP\CLIRequest - * @mixin \CodeIgniter\HTTP\CURLRequest + * @mixin IncomingRequest + * @mixin CLIRequest + * @mixin CURLRequest */ interface RequestInterface { diff --git a/system/HTTP/RequestTrait.php b/system/HTTP/RequestTrait.php index 7855aad..0f4de37 100644 --- a/system/HTTP/RequestTrait.php +++ b/system/HTTP/RequestTrait.php @@ -240,14 +240,14 @@ * * http://php.net/manual/en/filter.filters.sanitize.php * - * @param string $method Input filter constant - * @param string|array|null $index - * @param integer|null $filter Filter constant - * @param mixed $flags + * @param string $method Input filter constant + * @param string|array|null $index + * @param integer|null $filter Filter constant + * @param array|integer|null $flags Options * * @return mixed */ - public function fetchGlobal($method, $index = null, $filter = null, $flags = null) + public function fetchGlobal(string $method, $index = null, ?int $filter = null, $flags = null) { $method = strtolower($method); @@ -257,10 +257,8 @@ } // Null filters cause null values to return. - if (is_null($filter)) - { - $filter = FILTER_DEFAULT; - } + $filter = $filter ?? FILTER_DEFAULT; + $flags = is_array($flags) ? $flags : (is_numeric($flags) ? (int) $flags : 0); // Return all values when $index is null if (is_null($index)) @@ -319,7 +317,13 @@ } // @phpstan-ignore-next-line - if (is_array($value) && ($filter !== null || $flags !== null)) + if (is_array($value) + && ($filter !== FILTER_DEFAULT + || ((is_numeric($flags) && $flags !== 0) + || is_array($flags) && count($flags) > 0 + ) + ) + ) { // Iterate over array and append filter and flags array_walk_recursive($value, function (&$val) use ($filter, $flags) { diff --git a/system/HTTP/Response.php b/system/HTTP/Response.php index a685e54..c2c1d4d 100644 --- a/system/HTTP/Response.php +++ b/system/HTTP/Response.php @@ -11,8 +11,12 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Cookie\Cookie; +use CodeIgniter\Cookie\CookieStore; +use CodeIgniter\Cookie\Exceptions\CookieException; use CodeIgniter\HTTP\Exceptions\HTTPException; use Config\App; +use Config\ContentSecurityPolicy as CSPConfig; /** * Representation of an outgoing, getServer-side response. @@ -119,7 +123,7 @@ /** * The current status code for this response. * The status code is a 3-digit integer result code of the server's attempt - * to understand and satisfy the request. + * to understand and satisfy the request. * * @var integer */ @@ -134,8 +138,6 @@ */ protected $pretend = false; - //-------------------------------------------------------------------- - /** * Constructor * @@ -150,27 +152,42 @@ $this->noCache(); // We need CSP object even if not enabled to avoid calls to non existing methods - $this->CSP = new ContentSecurityPolicy(new \Config\ContentSecurityPolicy()); + $this->CSP = new ContentSecurityPolicy(new CSPConfig()); - $this->CSPEnabled = $config->CSPEnabled; + $this->CSPEnabled = $config->CSPEnabled; + + //--------------------------------------------------------------------- + // DEPRECATED COOKIE MANAGEMENT + //--------------------------------------------------------------------- $this->cookiePrefix = $config->cookiePrefix; $this->cookieDomain = $config->cookieDomain; $this->cookiePath = $config->cookiePath; $this->cookieSecure = $config->cookieSecure; $this->cookieHTTPOnly = $config->cookieHTTPOnly; - $this->cookieSameSite = $config->cookieSameSite ?? $this->cookieSameSite; + $this->cookieSameSite = $config->cookieSameSite ?? Cookie::SAMESITE_LAX; - if (! in_array(strtolower($this->cookieSameSite), ['', 'none', 'lax', 'strict'], true)) + $config->cookieSameSite = $config->cookieSameSite ?? Cookie::SAMESITE_LAX; + + if (! in_array(strtolower($config->cookieSameSite ?: Cookie::SAMESITE_LAX), Cookie::ALLOWED_SAMESITE_VALUES, true)) { - throw HTTPException::forInvalidSameSiteSetting($this->cookieSameSite); + throw CookieException::forInvalidSameSite($config->cookieSameSite); } + $this->cookieStore = new CookieStore([]); + Cookie::setDefaults(config('Cookie') ?? [ + // @todo Remove this fallback when deprecated `App` members are removed + 'prefix' => $config->cookiePrefix, + 'path' => $config->cookiePath, + 'domain' => $config->cookieDomain, + 'secure' => $config->cookieSecure, + 'httponly' => $config->cookieHTTPOnly, + 'samesite' => $config->cookieSameSite ?? Cookie::SAMESITE_LAX, + ]); + // Default to an HTML Content-Type. Devs can override if needed. $this->setContentType('text/html'); } - //-------------------------------------------------------------------- - /** * Turns "pretend" mode on or off to aid in testing. * Note that this is not a part of the interface so @@ -214,27 +231,30 @@ * @return string * * @deprecated Use getReasonPhrase() + * + * @codeCoverageIgnore */ public function getReason(): string { return $this->getReasonPhrase(); } - /** - * Gets the response reason phrase associated with the status code. - * - * Because a reason phrase is not a required element in a response - * status line, the reason phrase value MAY be null. Implementations MAY - * choose to return the default RFC 7231 recommended reason phrase (or those - * listed in the IANA HTTP Status Code Registry) for the response's - * status code. - * - * @link http://tools.ietf.org/html/rfc7231#section-6 - * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml - * @return string Reason phrase; must return an empty string if none present. - */ - public function getReasonPhrase() - { + /** + * Gets the response reason phrase associated with the status code. + * + * Because a reason phrase is not a required element in a response + * status line, the reason phrase value MAY be null. Implementations MAY + * choose to return the default RFC 7231 recommended reason phrase (or those + * listed in the IANA HTTP Status Code Registry) for the response's + * status code. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * + * @return string Reason phrase; must return an empty string if none present. + */ + public function getReasonPhrase() + { if ($this->reason === '') { return ! empty($this->statusCode) ? static::$statusCodes[$this->statusCode] : ''; diff --git a/system/HTTP/ResponseInterface.php b/system/HTTP/ResponseInterface.php index c43dae2..bfa493b 100644 --- a/system/HTTP/ResponseInterface.php +++ b/system/HTTP/ResponseInterface.php @@ -11,6 +11,7 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Cookie\Cookie; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\Pager\PagerInterface; use DateTime; @@ -28,7 +29,7 @@ * - Headers * - Message body * - * @mixin \CodeIgniter\HTTP\RedirectResponse + * @mixin RedirectResponse */ interface ResponseInterface { @@ -185,6 +186,7 @@ * @see http://tools.ietf.org/html/rfc5988 * * @return Response + * * @todo Recommend moving to Pager */ public function setLink(PagerInterface $pager); @@ -358,7 +360,7 @@ * @param string|null $name * @param string $prefix * - * @return mixed + * @return Cookie[]|Cookie|null */ public function getCookie(string $name = null, string $prefix = ''); @@ -377,7 +379,7 @@ /** * Returns all cookies currently set. * - * @return array + * @return Cookie[] */ public function getCookies(); diff --git a/system/HTTP/ResponseTrait.php b/system/HTTP/ResponseTrait.php index e39ea36..0938c59 100644 --- a/system/HTTP/ResponseTrait.php +++ b/system/HTTP/ResponseTrait.php @@ -11,6 +11,9 @@ namespace CodeIgniter\HTTP; +use CodeIgniter\Cookie\Cookie; +use CodeIgniter\Cookie\CookieStore; +use CodeIgniter\Cookie\Exceptions\CookieException; use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\Pager\PagerInterface; use Config\Services; @@ -24,6 +27,8 @@ * Additional methods to make a PSR-7 Response class * compliant with the framework's own ResponseInterface. * + * @property array $statusCodes + * * @see https://github.com/php-fig/http-message/blob/master/src/ResponseInterface.php */ trait ResponseTrait @@ -43,9 +48,18 @@ public $CSP; /** + * CookieStore instance. + * + * @var CookieStore + */ + protected $cookieStore; + + /** * Set a cookie name prefix if you need to avoid collisions * * @var string + * + * @deprecated Use the dedicated Cookie class instead. */ protected $cookiePrefix = ''; @@ -53,6 +67,8 @@ * Set to .your-domain.com for site-wide cookies * * @var string + * + * @deprecated Use the dedicated Cookie class instead. */ protected $cookieDomain = ''; @@ -60,6 +76,8 @@ * Typically will be a forward slash * * @var string + * + * @deprecated Use the dedicated Cookie class instead. */ protected $cookiePath = '/'; @@ -67,6 +85,8 @@ * Cookie will only be set if a secure HTTPS connection exists. * * @var boolean + * + * @deprecated Use the dedicated Cookie class instead. */ protected $cookieSecure = false; @@ -74,6 +94,8 @@ * Cookie will only be accessible via HTTP(S) (no javascript) * * @var boolean + * + * @deprecated Use the dedicated Cookie class instead. */ protected $cookieHTTPOnly = false; @@ -81,13 +103,17 @@ * Cookie SameSite setting * * @var string + * + * @deprecated Use the dedicated Cookie class instead. */ - protected $cookieSameSite = 'Lax'; + protected $cookieSameSite = Cookie::SAMESITE_LAX; /** * Stores all cookies that were set in the response. * * @var array + * + * @deprecated Use the dedicated Cookie class instead. */ protected $cookies = []; @@ -99,8 +125,6 @@ */ protected $bodyFormat = 'html'; - //-------------------------------------------------------------------- - /** * Return an instance with the specified status code and, optionally, reason phrase. * @@ -134,14 +158,7 @@ $this->statusCode = $code; - if (! empty($reason)) - { - $this->reason = $reason; - } - else - { - $this->reason = static::$statusCodes[$code]; - } + $this->reason = ! empty($reason) ? $reason : static::$statusCodes[$code]; return $this; } @@ -166,8 +183,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Set the Link Header * @@ -176,6 +191,7 @@ * @see http://tools.ietf.org/html/rfc5988 * * @return Response + * * @todo Recommend moving to Pager */ public function setLink(PagerInterface $pager) @@ -204,8 +220,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Sets the Content Type header for this response with the mime type * and, optionally, the charset. @@ -229,8 +243,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Converts the $body into JSON and sets the Content Type header. * @@ -246,8 +258,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Returns the current body, converted to JSON is it isn't already. * @@ -267,8 +277,6 @@ return $body ?: null; } - //-------------------------------------------------------------------- - /** * Converts $body into XML, and sets the correct Content-Type. * @@ -283,8 +291,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Retrieves the current body into XML and returns it. * @@ -303,8 +309,6 @@ return $body; } - //-------------------------------------------------------------------- - /** * Handles conversion of the of the data into the appropriate format, * and sets the correct Content-Type header for our response. @@ -331,7 +335,6 @@ } //-------------------------------------------------------------------- - //-------------------------------------------------------------------- // Cache Control Methods // // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9 @@ -342,20 +345,19 @@ * is not cached by the browsers. * * @return Response + * * @todo Recommend researching these directives, might need: 'private', 'no-transform', 'no-store', 'must-revalidate' + * * @see DownloadResponse::noCache() */ public function noCache() { $this->removeHeader('Cache-control'); - $this->setHeader('Cache-control', ['no-store', 'max-age=0', 'no-cache']); return $this; } - //-------------------------------------------------------------------- - /** * A shortcut method that allows the developer to set all of the * cache-control headers in one method call. @@ -414,8 +416,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Sets the Last-Modified date header. * @@ -442,7 +442,6 @@ } //-------------------------------------------------------------------- - //-------------------------------------------------------------------- // Output Methods //-------------------------------------------------------------------- @@ -471,8 +470,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Sends the headers of this HTTP request to the browser. * @@ -494,11 +491,10 @@ } // HTTP Status - header(sprintf('HTTP/%s %s %s', $this->getProtocolVersion(), $this->getStatusCode(), $this->getReason()), true, - $this->getStatusCode()); + header(sprintf('HTTP/%s %s %s', $this->getProtocolVersion(), $this->getStatusCode(), $this->getReason()), true, $this->getStatusCode()); // Send all of our headers - foreach ($this->getHeaders() as $name => $values) + foreach (array_keys($this->getHeaders()) as $name) { header($name . ': ' . $this->getHeaderLine($name), false, $this->getStatusCode()); } @@ -506,8 +502,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Sends the Body of the message to the browser. * @@ -520,8 +514,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Perform a redirect to a new URL, in two flavors: header or location. * @@ -548,12 +540,9 @@ // override status code for HTTP/1.1 & higher // reference: http://en.wikipedia.org/wiki/Post/Redirect/Get - if (isset($_SERVER['SERVER_PROTOCOL'], $_SERVER['REQUEST_METHOD']) && $this->getProtocolVersion() >= 1.1) + if (isset($_SERVER['SERVER_PROTOCOL'], $_SERVER['REQUEST_METHOD']) && $this->getProtocolVersion() >= 1.1 && $method !== 'refresh') { - if ($method !== 'refresh') - { - $code = ($_SERVER['REQUEST_METHOD'] !== 'GET') ? 303 : ($code === 302 ? 307 : $code); - } + $code = ($_SERVER['REQUEST_METHOD'] !== 'GET') ? 303 : ($code === 302 ? 307 : $code); } switch ($method) @@ -571,8 +560,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Set a cookie * @@ -615,71 +602,35 @@ } } - if ($prefix === '' && $this->cookiePrefix !== '') + if (is_numeric($expire)) { - $prefix = $this->cookiePrefix; + $expire = $expire > 0 ? time() + $expire : 0; } - if ($domain === '' && $this->cookieDomain !== '') - { - $domain = $this->cookieDomain; - } - - if ($path === '/' && $this->cookiePath !== '/') - { - $path = $this->cookiePath; - } - - if ($secure === false && $this->cookieSecure === true) - { - $secure = $this->cookieSecure; - } - - if ($httponly === false && $this->cookieHTTPOnly !== false) - { - $httponly = $this->cookieHTTPOnly; - } - - if (is_null($samesite)) - { - $samesite = $this->cookieSameSite ?? ''; - } - - if (! in_array(strtolower($samesite), ['', 'none', 'lax', 'strict'], true)) - { - throw HTTPException::forInvalidSameSiteSetting($samesite); - } - - if (! is_numeric($expire)) - { - $expire = time() - 86500; - } - else - { - $expire = ($expire > 0) ? time() + $expire : 0; - } - - $cookie = [ - 'name' => $prefix . $name, - 'value' => $value, - 'expires' => $expire, - 'path' => $path, + $cookie = new Cookie($name, $value, [ + 'expires' => $expire ?: 0, 'domain' => $domain, + 'path' => $path, + 'prefix' => $prefix, 'secure' => $secure, 'httponly' => $httponly, - ]; + 'samesite' => $samesite ?? '', + ]); - if ($samesite !== '') - { - $cookie['samesite'] = $samesite; - } - - $this->cookies[] = $cookie; + $this->cookieStore = $this->cookieStore->put($cookie); return $this; } - //-------------------------------------------------------------------- + /** + * Returns the `CookieStore` instance. + * + * @return CookieStore + */ + public function getCookieStore() + { + return $this->cookieStore; + } /** * Checks to see if the Response has a specified cookie or not. @@ -692,29 +643,9 @@ */ public function hasCookie(string $name, string $value = null, string $prefix = ''): bool { - if ($prefix === '' && $this->cookiePrefix !== '') - { - $prefix = $this->cookiePrefix; - } + $prefix = $prefix ?: Cookie::setDefaults()['prefix']; // to retain BC - $name = $prefix . $name; - - foreach ($this->cookies as $cookie) - { - if ($cookie['name'] !== $name) - { - continue; - } - - if ($value === null) - { - return true; - } - - return $cookie['value'] === $value; - } - - return false; + return $this->cookieStore->has($name, $prefix, $value); } /** @@ -723,31 +654,26 @@ * @param string|null $name * @param string $prefix * - * @return mixed + * @return Cookie[]|Cookie|null */ public function getCookie(string $name = null, string $prefix = '') { - // if no name given, return them all - if (empty($name)) + if ((string) $name === '') { - return $this->cookies; + return $this->cookieStore->display(); } - if ($prefix === '' && $this->cookiePrefix !== '') + try { - $prefix = $this->cookiePrefix; + $prefix = $prefix ?: Cookie::setDefaults()['prefix']; // to retain BC + + return $this->cookieStore->get($name, $prefix); } - - $name = $prefix . $name; - - foreach ($this->cookies as $cookie) + catch (CookieException $e) { - if ($cookie['name'] === $name) - { - return $cookie; - } + log_message('error', $e->getMessage()); + return null; } - return null; } /** @@ -762,39 +688,40 @@ */ public function deleteCookie(string $name = '', string $domain = '', string $path = '/', string $prefix = '') { - if (empty($name)) + if ($name === '') { return $this; } - if ($prefix === '' && $this->cookiePrefix !== '') - { - $prefix = $this->cookiePrefix; - } + $prefix = $prefix ?: Cookie::setDefaults()['prefix']; // to retain BC - $prefixedName = $prefix . $name; + $prefixed = $prefix . $name; + $store = $this->cookieStore; + $found = false; - $cookieHasFlag = false; - foreach ($this->cookies as &$cookie) + foreach ($store as $cookie) { - if ($cookie['name'] === $prefixedName) + if ($cookie->getPrefixedName() === $prefixed) { - if (! empty($domain) && $cookie['domain'] !== $domain) + if ($domain !== $cookie->getDomain()) { continue; } - if (! empty($path) && $cookie['path'] !== $path) + + if ($path !== $cookie->getPath()) { continue; } - $cookie['value'] = ''; - $cookie['expires'] = ''; - $cookieHasFlag = true; + + $cookie = $cookie->withValue('')->withExpired(); + $found = true; + + $this->cookieStore = $store->put($cookie); break; } } - if (! $cookieHasFlag) + if (! $found) { $this->setCookie($name, '', '', $domain, $path, $prefix); } @@ -805,11 +732,11 @@ /** * Returns all cookies currently set. * - * @return array + * @return Cookie[] */ public function getCookies() { - return $this->cookies; + return $this->cookieStore->display(); } /** @@ -822,38 +749,7 @@ return; } - foreach ($this->cookies as $params) - { - if (PHP_VERSION_ID < 70300) - { - // For PHP 7.2 we need to use the hacky method of setting SameSite in the path - if (isset($params['samesite']) && in_array(strtolower($params['samesite']), ['none', 'lax', 'strict'], true)) - { - $params['path'] .= '; samesite=' . $params['samesite']; - unset($params['samesite']); - } - - // PHP cannot unpack array with string keys - $params = array_values($params); - setcookie(...$params); - } - else - { - // PHP 7.3 and later have a signature for setcookie() with options array as third argument - // and SameSite is possible to set there - $name = $params['name']; - $value = $params['value']; - unset($params['name'], $params['value']); - - // If samesite is blank string, skip setting the attribute on the cookie - if (isset($params['samesite']) && $params['samesite'] === '') - { - unset($params['samesite']); - } - - setcookie($name, $value, $params); - } - } + $this->cookieStore->dispatch(); } /** diff --git a/system/HTTP/URI.php b/system/HTTP/URI.php index 27d8379..4eb1f39 100644 --- a/system/HTTP/URI.php +++ b/system/HTTP/URI.php @@ -12,7 +12,6 @@ namespace CodeIgniter\HTTP; use CodeIgniter\HTTP\Exceptions\HTTPException; -use Config\App; use InvalidArgumentException; /** @@ -143,6 +142,113 @@ //-------------------------------------------------------------------- /** + * Builds a representation of the string from the component parts. + * + * @param string $scheme + * @param string $authority + * @param string $path + * @param string $query + * @param string $fragment + * + * @return string + */ + public static function createURIString(string $scheme = null, string $authority = null, string $path = null, string $query = null, string $fragment = null): string + { + $uri = ''; + if (! empty($scheme)) + { + $uri .= $scheme . '://'; + } + + if (! empty($authority)) + { + $uri .= $authority; + } + + if (isset($path) && $path !== '') + { + $uri .= substr($uri, -1, 1) !== '/' ? '/' . ltrim($path, '/') : ltrim($path, '/'); + } + + if ($query) + { + $uri .= '?' . $query; + } + + if ($fragment) + { + $uri .= '#' . $fragment; + } + + return $uri; + } + + /** + * Used when resolving and merging paths to correctly interpret and + * remove single and double dot segments from the path per + * RFC 3986 Section 5.2.4 + * + * @see http://tools.ietf.org/html/rfc3986#section-5.2.4 + * + * @param string $path + * + * @return string + * @internal + */ + public static function removeDotSegments(string $path): string + { + if ($path === '' || $path === '/') + { + return $path; + } + + $output = []; + + $input = explode('/', $path); + + if ($input[0] === '') + { + unset($input[0]); + $input = array_values($input); + } + + // This is not a perfect representation of the + // RFC, but matches most cases and is pretty + // much what Guzzle uses. Should be good enough + // for almost every real use case. + foreach ($input as $segment) + { + if ($segment === '..') + { + array_pop($output); + } + elseif ($segment !== '.' && $segment !== '') + { + $output[] = $segment; + } + } + + $output = implode('/', $output); + $output = trim($output, '/ '); + + // Add leading slash if necessary + if (strpos($path, '/') === 0) + { + $output = '/' . $output; + } + + // Add trailing slash if necessary + if ($output !== '/' && substr($path, -1, 1) === '/') + { + $output .= '/'; + } + + return $output; + } + + //-------------------------------------------------------------------- + + /** * Constructor. * * @param string $uri @@ -280,14 +386,11 @@ $authority = $this->getUserInfo() . '@' . $authority; } - if (! empty($this->port) && ! $ignorePort) + // Don't add port if it's a standard port for + // this scheme + if (! empty($this->port) && ! $ignorePort && $this->port !== $this->defaultPorts[$this->scheme]) { - // Don't add port if it's a standard port for - // this scheme - if ($this->port !== $this->defaultPorts[$this->scheme]) - { - $authority .= ':' . $this->port; - } + $authority .= ':' . $this->port; } $this->showPassword = false; @@ -361,7 +464,7 @@ */ public function getHost(): string { - return $this->host; + return $this->host ?? ''; } //-------------------------------------------------------------------- @@ -561,33 +664,45 @@ //-------------------------------------------------------------------- /** - * Allow the URI to be output as a string by simply casting it to a string - * or echoing out. + * Formats the URI as a string. + * + * Warning: For backwards-compatability this method + * assumes URIs with the same host as baseURL should + * be relative to the project's configuration. + * This aspect of __toString() is deprecated and should be avoided. + * + * @return string */ public function __toString(): string { - // If hosted in a sub-folder, we will have additional - // segments that show up prior to the URI path we just - // grabbed from the request, so add it on if necessary. - $config = config(App::class); - $baseUri = new self($config->baseURL); - $basePath = trim($baseUri->getPath(), '/') . '/'; - $path = $this->getPath(); - $trimPath = ltrim($path, '/'); + $path = $this->getPath(); + $scheme = $this->getScheme(); - if ($basePath !== '/' && strpos($trimPath, $basePath) !== 0) - { - $path = $basePath . $trimPath; - } + // Check if this is an internal URI + $config = config('App'); + $baseUri = new self($config->baseURL); - // force https if needed - if ($config->forceGlobalSecureRequests) + // If the hosts matches then assume this should be relative to baseURL + if ($this->getHost() === $baseUri->getHost()) { - $this->setScheme('https'); + // Check for additional segments + $basePath = trim($baseUri->getPath(), '/') . '/'; + $trimPath = ltrim($path, '/'); + + if ($basePath !== '/' && strpos($trimPath, $basePath) !== 0) + { + $path = $basePath . $trimPath; + } + + // Check for forced HTTPS + if ($config->forceGlobalSecureRequests) + { + $scheme = 'https'; + } } return static::createURIString( - $this->getScheme(), $this->getAuthority(), $path, // Absolute URIs should use a "/" for an empty path + $scheme, $this->getAuthority(), $path, // Absolute URIs should use a "/" for an empty path $this->getQuery(), $this->getFragment() ); } @@ -595,51 +710,7 @@ //-------------------------------------------------------------------- /** - * Builds a representation of the string from the component parts. - * - * @param string $scheme - * @param string $authority - * @param string $path - * @param string $query - * @param string $fragment - * - * @return string - */ - public static function createURIString(string $scheme = null, string $authority = null, string $path = null, string $query = null, string $fragment = null): string - { - $uri = ''; - if (! empty($scheme)) - { - $uri .= $scheme . '://'; - } - - if (! empty($authority)) - { - $uri .= $authority; - } - - if ($path !== '') - { - $uri .= substr($uri, -1, 1) !== '/' ? '/' . ltrim($path, '/') : ltrim($path, '/'); - } - - if ($query) - { - $uri .= '?' . $query; - } - - if ($fragment) - { - $uri .= '#' . $fragment; - } - - return $uri; - } - - //-------------------------------------------------------------------- - - /** - * Parses the given string an saves the appropriate authority pieces. + * Parses the given string and saves the appropriate authority pieces. * * @param string $str * @@ -950,7 +1021,7 @@ $path = urldecode($path); // Remove dot segments - $path = $this->removeDotSegments($path); + $path = self::removeDotSegments($path); // Fix up some leading slash edge cases... if (strpos($orig, './') === 0) @@ -1013,14 +1084,11 @@ } // Port - if (isset($parts['port'])) + if (isset($parts['port']) && ! is_null($parts['port'])) { - if (! is_null($parts['port'])) - { - // Valid port numbers are enforced by earlier parse_url or setPort() - $port = $parts['port']; - $this->port = $port; - } + // Valid port numbers are enforced by earlier parse_url or setPort() + $port = $parts['port']; + $this->port = $port; } if (isset($parts['pass'])) @@ -1139,7 +1207,7 @@ } array_pop($path); - array_push($path, $reference->getPath()); + $path[] = $reference->getPath(); return implode('/', $path); } @@ -1147,74 +1215,6 @@ //-------------------------------------------------------------------- /** - * Used when resolving and merging paths to correctly interpret and - * remove single and double dot segments from the path per - * RFC 3986 Section 5.2.4 - * - * @see http://tools.ietf.org/html/rfc3986#section-5.2.4 - * - * @param string $path - * - * @return string - * @internal param \CodeIgniter\HTTP\URI $uri - */ - public function removeDotSegments(string $path): string - { - if ($path === '' || $path === '/') - { - return $path; - } - - $output = []; - - $input = explode('/', $path); - - if ($input[0] === '') - { - unset($input[0]); - $input = array_values($input); - } - - // This is not a perfect representation of the - // RFC, but matches most cases and is pretty - // much what Guzzle uses. Should be good enough - // for almost every real use case. - foreach ($input as $segment) - { - if ($segment === '..') - { - array_pop($output); - } - elseif ($segment !== '.' && $segment !== '') - { - array_push($output, $segment); - } - } - - $output = implode('/', $output); - $output = ltrim($output, '/ '); - - if ($output !== '/') - { - // Add leading slash if necessary - if (strpos($path, '/') === 0) - { - $output = '/' . $output; - } - - // Add trailing slash if necessary - if (substr($path, -1, 1) === '/') - { - $output .= '/'; - } - } - - return $output; - } - - //-------------------------------------------------------------------- - - /** * This is equivalent to the native PHP parse_str() function. * This version allows the dot to be used as a key of the query string. * diff --git a/system/Helpers/array_helper.php b/system/Helpers/array_helper.php index c37288c..b818ff6 100644 --- a/system/Helpers/array_helper.php +++ b/system/Helpers/array_helper.php @@ -22,11 +22,15 @@ * @param string $index * @param array $array * - * @return mixed|null + * @return mixed */ function dot_array_search(string $index, array $array) { - $segments = explode('.', rtrim(rtrim($index, '* '), '.')); + $segments = preg_split('/(?setCookie() * @see \CodeIgniter\HTTP\Response::setCookie() */ function set_cookie( @@ -52,47 +48,37 @@ string $sameSite = null ) { - // The following line shows as a syntax error in NetBeans IDE - //(\Config\Services::response())->setcookie $response = Services::response(); - $response->setcookie($name, $value, $expire, $domain, $path, $prefix, $secure, $httpOnly, $sameSite); + $response->setCookie($name, $value, $expire, $domain, $path, $prefix, $secure, $httpOnly, $sameSite); } } -//-------------------------------------------------------------------- - if (! function_exists('get_cookie')) { /** - * Fetch an item from the COOKIE array + * Fetch an item from the $_COOKIE array * * @param string $index * @param boolean $xssClean * * @return mixed * - * @see (\Config\Services::request())->getCookie() * @see \CodeIgniter\HTTP\IncomingRequest::getCookie() */ function get_cookie($index, bool $xssClean = false) { - $app = config(App::class); - $appCookiePrefix = $app->cookiePrefix; - $prefix = isset($_COOKIE[$index]) ? '' : $appCookiePrefix; - + $prefix = isset($_COOKIE[$index]) ? '' : config(App::class)->cookiePrefix; $request = Services::request(); - $filter = true === $xssClean ? FILTER_SANITIZE_STRING : null; + $filter = $xssClean ? FILTER_SANITIZE_STRING : FILTER_DEFAULT; return $request->getCookie($prefix . $index, $filter); } } -//-------------------------------------------------------------------- - if (! function_exists('delete_cookie')) { /** - * Delete a COOKIE + * Delete a cookie * * @param mixed $name * @param string $domain the cookie domain. Usually: .yourdomain.com @@ -101,7 +87,6 @@ * * @return void * - * @see (\Config\Services::response())->deleteCookie() * @see \CodeIgniter\HTTP\Response::deleteCookie() */ function delete_cookie($name, string $domain = '', string $path = '/', string $prefix = '') @@ -109,3 +94,20 @@ Services::response()->deleteCookie($name, $domain, $path, $prefix); } } + +if (! function_exists('has_cookie')) +{ + /** + * Checks if a cookie exists by name. + * + * @param string $name + * @param string|null $value + * @param string $prefix + * + * @return boolean + */ + function has_cookie(string $name, string $value = null, string $prefix = ''): bool + { + return Services::response()->hasCookie($name, $value, $prefix); + } +} diff --git a/system/Helpers/filesystem_helper.php b/system/Helpers/filesystem_helper.php index 9fc6c02..ddbbcf5 100644 --- a/system/Helpers/filesystem_helper.php +++ b/system/Helpers/filesystem_helper.php @@ -48,7 +48,10 @@ continue; } - is_dir($sourceDir . $file) && $file .= DIRECTORY_SEPARATOR; + if (is_dir($sourceDir . $file)) + { + $file .= DIRECTORY_SEPARATOR; + } if (($directoryDepth < 1 || $newDepth > 0) && is_dir($sourceDir . $file)) { diff --git a/system/Helpers/form_helper.php b/system/Helpers/form_helper.php index b1c06c3..8bfb422 100644 --- a/system/Helpers/form_helper.php +++ b/system/Helpers/form_helper.php @@ -71,8 +71,7 @@ $form = '
\n"; // Add CSRF field if enabled, but leave it out for GET requests and requests to external websites - $before = Services::filters() - ->getFilters()['before']; + $before = Services::filters()->getFilters()['before']; if ((in_array('csrf', $before, true) || array_key_exists('csrf', $before)) && strpos($action, base_url()) !== false && ! stripos($form, 'method="get"')) { @@ -110,7 +109,7 @@ { if (is_string($attributes)) { - $attributes .= ' enctype="' . esc('multipart/form-data', 'attr') . '"'; + $attributes .= ' enctype="' . esc('multipart/form-data') . '"'; } else { @@ -217,8 +216,11 @@ */ function form_password($data = '', string $value = '', $extra = ''): string { - is_array($data) || $data = ['name' => $data]; // @phpstan-ignore-line - $data['type'] = 'password'; + if (! is_array($data)) + { + $data = ['name' => $data]; + } + $data['type'] = 'password'; return form_input($data, $value, $extra); } @@ -246,7 +248,10 @@ 'name' => '', ]; - is_array($data) || $data = ['name' => $data]; // @phpstan-ignore-line + if (! is_array($data)) + { + $data = ['name' => $data]; + } $data['type'] = 'file'; @@ -285,12 +290,12 @@ } // Unsets default rows and cols if defined in extra field as array or string. - if ((is_array($extra) && array_key_exists('rows', $extra)) || (is_string($extra) && strpos(strtolower(preg_replace('/\s+/', '', $extra)), 'rows=') !== false)) + if ((is_array($extra) && array_key_exists('rows', $extra)) || (is_string($extra) && stripos(preg_replace('/\s+/', '', $extra), 'rows=') !== false)) { unset($defaults['rows']); } - if ((is_array($extra) && array_key_exists('cols', $extra)) || (is_string($extra) && strpos(strtolower(preg_replace('/\s+/', '', $extra)), 'cols=') !== false)) + if ((is_array($extra) && array_key_exists('cols', $extra)) || (is_string($extra) && stripos(preg_replace('/\s+/', '', $extra), 'cols=') !== false)) { unset($defaults['cols']); } @@ -308,14 +313,14 @@ /** * Multi-select menu * - * @param string $name - * @param array $options - * @param array $selected - * @param mixed $extra + * @param mixed $name + * @param array $options + * @param array $selected + * @param mixed $extra * * @return string */ - function form_multiselect(string $name = '', array $options = [], array $selected = [], $extra = ''): string + function form_multiselect($name = '', array $options = [], array $selected = [], $extra = ''): string { $extra = stringify_attributes($extra); @@ -363,9 +368,14 @@ $defaults = ['name' => $data]; } - is_array($selected) || $selected = [$selected]; // @phpstan-ignore-line - - is_array($options) || $options = [$options]; // @phpstan-ignore-line + if (! is_array($selected)) + { + $selected = [$selected]; + } + if (! is_array($options)) + { + $options = [$options]; + } // If no selected state was submitted we will attempt to set it automatically if (empty($selected)) @@ -383,6 +393,12 @@ } } + // standardize selected as strings, like the option keys will be. + foreach ($selected as $key => $item) + { + $selected[$key] = (string) $item; + } + $extra = stringify_attributes($extra); $multiple = (count($selected) > 1 && stripos($extra, 'multiple') === false) ? ' multiple="multiple"' : ''; $form = '\n"; @@ -483,8 +496,11 @@ */ function form_radio($data = '', string $value = '', bool $checked = false, $extra = ''): string { - is_array($data) || $data = ['name' => $data]; // @phpstan-ignore-line - $data['type'] = 'radio'; + if (! is_array($data)) + { + $data = ['name' => $data]; + } + $data['type'] = 'radio'; return form_checkbox($data, $value, $checked, $extra); } @@ -505,13 +521,7 @@ */ function form_submit($data = '', string $value = '', $extra = ''): string { - $defaults = [ - 'type' => 'submit', - 'name' => is_array($data) ? '' : $data, - 'value' => $value, - ]; - - return '\n"; + return form_input($data, $value, $extra, 'submit'); } } @@ -530,13 +540,7 @@ */ function form_reset($data = '', string $value = '', $extra = ''): string { - $defaults = [ - 'type' => 'reset', - 'name' => is_array($data) ? '' : $data, - 'value' => $value, - ]; - - return '\n"; + return form_input($data, $value, $extra, 'reset'); } } @@ -916,7 +920,7 @@ { if (is_array($attributes)) { - foreach ($default as $key => $val) + foreach (array_keys($default) as $key) { if (isset($attributes[$key])) { @@ -944,7 +948,7 @@ { continue; } - $att .= $key . '="' . $val . '"' . ($val === end($default) ? '' : ' '); + $att .= $key . '="' . $val . '"' . ($key === array_key_last($default) ? '' : ' '); } else { diff --git a/system/Helpers/html_helper.php b/system/Helpers/html_helper.php index cd659a9..be71619 100755 --- a/system/Helpers/html_helper.php +++ b/system/Helpers/html_helper.php @@ -556,14 +556,7 @@ { if (! _has_protocol($src)) { - if ($indexPage === true) - { - $src = site_url($src); - } - else - { - $src = slash_item('baseURL') . $src; - } + $src = $indexPage === true ? site_url($src) : slash_item('baseURL') . $src; } $source = '' . 'var l=new Array();'; - for ($i = 0, $c = count($x); $i < $c; $i ++) + foreach ($x as $i => $value) { - $output .= 'l[' . $i . "] = '" . $x[$i] . "';"; + $output .= 'l[' . $i . "] = '" . $value . "';"; } return $output . ('for (var i = l.length-1; i >= 0; i=i-1) {' diff --git a/system/I18n/Time.php b/system/I18n/Time.php index 660e5cc..87697c6 100644 --- a/system/I18n/Time.php +++ b/system/I18n/Time.php @@ -14,6 +14,8 @@ use CodeIgniter\I18n\Exceptions\I18nException; use DateInterval; use DateTime; +use DateTimeImmutable; +use DateTimeInterface; use DateTimeZone; use Exception; use IntlCalendar; @@ -57,7 +59,7 @@ protected static $relativePattern = '/this|next|last|tomorrow|yesterday|midnight|today|[+-]|first|last|ago/i'; /** - * @var \CodeIgniter\I18n\Time|DateTime|null + * @var static|DateTimeInterface|null */ protected static $testNow; @@ -93,18 +95,14 @@ $timezone = ! empty($timezone) ? $timezone : date_default_timezone_get(); $this->timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone); - if (! empty($time)) + // If the time string was a relative string (i.e. 'next Tuesday') + // then we need to adjust the time going in so that we have a current + // timezone to work with. + if (! empty($time) && (is_string($time) && static::hasRelativeKeywords($time))) { - // If the time string was a relative string (i.e. 'next Tuesday') - // then we need to adjust the time going in so that we have a current - // timezone to work with. - if (is_string($time) && static::hasRelativeKeywords($time)) - { - $instance = new DateTime('now', $this->timezone); - $instance->modify($time); - - $time = $instance->format('Y-m-d H:i:s'); - } + $instance = new DateTime('now', $this->timezone); + $instance->modify($time); + $time = $instance->format('Y-m-d H:i:s'); } parent::__construct($time, $this->timezone); @@ -299,7 +297,26 @@ */ public static function createFromTimestamp(int $timestamp, $timezone = null, string $locale = null) { - return new Time(date('Y-m-d H:i:s', $timestamp), $timezone, $locale); + return new Time(gmdate('Y-m-d H:i:s', $timestamp), $timezone ?? 'UTC', $locale); + } + + //-------------------------------------------------------------------- + + /** + * Takes an instance of DateTimeInterface and returns an instance of Time with it's same values. + * + * @param DateTimeInterface $dateTime + * @param string|null $locale + * + * @return Time + * @throws Exception + */ + public static function createFromInstance(DateTimeInterface $dateTime, string $locale = null) + { + $date = $dateTime->format('Y-m-d H:i:s'); + $timezone = $dateTime->getTimezone(); + + return new Time($date, $timezone, $locale); } //-------------------------------------------------------------------- @@ -312,13 +329,13 @@ * * @return Time * @throws Exception + * + * @deprecated Use createFromInstance() instead + * @codeCoverageIgnore */ public static function instance(DateTime $dateTime, string $locale = null) { - $date = $dateTime->format('Y-m-d H:i:s'); - $timezone = $dateTime->getTimezone(); - - return new Time($date, $timezone, $locale); + return self::createFromInstance($dateTime, $locale); } //-------------------------------------------------------------------- @@ -345,9 +362,9 @@ * Creates an instance of Time that will be returned during testing * when calling 'Time::now' instead of the current time. * - * @param Time|DateTime|string|null $datetime - * @param DateTimeZone|string|null $timezone - * @param string|null $locale + * @param Time|DateTimeInterface|string|null $datetime + * @param DateTimeZone|string|null $timezone + * @param string|null $locale * * @throws Exception */ @@ -365,7 +382,7 @@ { $datetime = new Time($datetime, $timezone, $locale); } - elseif ($datetime instanceof DateTime && ! $datetime instanceof Time) + elseif ($datetime instanceof DateTimeInterface && ! $datetime instanceof Time) { $datetime = new Time($datetime->format('Y-m-d H:i:s'), $timezone); } @@ -767,7 +784,7 @@ public function setTimezone($timezone) { $timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone); - return Time::instance($this->toDateTime()->setTimezone($timezone), $this->locale); + return Time::createFromInstance($this->toDateTime()->setTimezone($timezone), $this->locale); } /** @@ -1042,8 +1059,8 @@ * and are not required to be in the same timezone, as both times are * converted to UTC and compared that way. * - * @param Time|DateTime|string $testTime - * @param string|null $timezone + * @param Time|DateTimeInterface|string $testTime + * @param string|null $timezone * * @return boolean * @throws Exception @@ -1064,15 +1081,15 @@ /** * Ensures that the times are identical, taking timezone into account. * - * @param Time|DateTime|string $testTime - * @param string|null $timezone + * @param Time|DateTimeInterface|string $testTime + * @param string|null $timezone * * @return boolean * @throws Exception */ public function sameAs($testTime, string $timezone = null): bool { - if ($testTime instanceof DateTime) + if ($testTime instanceof DateTimeInterface) { $testTime = $testTime->format('Y-m-d H:i:s'); } @@ -1236,19 +1253,18 @@ { if ($time instanceof Time) { - $time = $time->toDateTime() - ->setTimezone(new DateTimeZone('UTC')); - } - elseif ($time instanceof DateTime) - { - $time = $time->setTimezone(new DateTimeZone('UTC')); + $time = $time->toDateTime(); } elseif (is_string($time)) { $timezone = $timezone ?: $this->timezone; $timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone); $time = new DateTime($time, $timezone); - $time = $time->setTimezone(new DateTimeZone('UTC')); + } + + if ($time instanceof DateTime || $time instanceof DateTimeImmutable) + { + $time = $time->setTimezone(new DateTimeZone('UTC')); } return $time; diff --git a/system/I18n/TimeDifference.php b/system/I18n/TimeDifference.php index 196f087..dca1bb9 100644 --- a/system/I18n/TimeDifference.php +++ b/system/I18n/TimeDifference.php @@ -107,9 +107,8 @@ { $this->difference = $currentTime->getTimestamp() - $testTime->getTimestamp(); - $current = IntlCalendar::fromDateTime($currentTime->format('Y-m-d H:i:s')); - $time = IntlCalendar::fromDateTime($testTime->format('Y-m-d H:i:s')) - ->getTime(); + $current = IntlCalendar::fromDateTime($currentTime); + $time = IntlCalendar::fromDateTime($testTime)->getTime(); $this->currentTime = $current; $this->testTime = $time; @@ -317,7 +316,7 @@ if (method_exists($this, $method)) { - return $this->{$method}($name); + return $this->{$method}(); } return null; diff --git a/system/Images/Handlers/BaseHandler.php b/system/Images/Handlers/BaseHandler.php index f591cd6..9eb007a 100644 --- a/system/Images/Handlers/BaseHandler.php +++ b/system/Images/Handlers/BaseHandler.php @@ -32,7 +32,7 @@ /** * The image/file instance * - * @var \CodeIgniter\Images\Image + * @var Image */ protected $image; diff --git a/system/Language/Language.php b/system/Language/Language.php index f812af8..cb66922 100644 --- a/system/Language/Language.php +++ b/system/Language/Language.php @@ -146,7 +146,7 @@ /** * @return array|string|null */ - private function getTranslationOutput(string $locale, string $file, string $parsedLine) + protected function getTranslationOutput(string $locale, string $file, string $parsedLine) { $output = $this->language[$locale][$file][$parsedLine] ?? null; if ($output !== null) diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index fa4ed3b..ad2bafc 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -16,7 +16,18 @@ 'commandNotFound' => 'Command "{0}" not found.', 'generator' => [ 'cancelOperation' => 'Operation has been cancelled.', - 'className' => 'Class name', + 'className' => [ + 'command' => 'Command class name', + 'config' => 'Config class name', + 'controller' => 'Controller class name', + 'default' => 'Class name', + 'entity' => 'Entity class name', + 'filter' => 'Filter class name', + 'migration' => 'Migration class name', + 'model' => 'Model class name', + 'seeder' => 'Seeder class name', + 'validation' => 'Validation class name', + ], 'commandType' => 'Command type', 'databaseGroup' => 'Database group', 'fileCreate' => 'File created: {0}', diff --git a/system/Language/en/Cache.php b/system/Language/en/Cache.php index e5c2588..5780670 100644 --- a/system/Language/en/Cache.php +++ b/system/Language/en/Cache.php @@ -11,7 +11,7 @@ // Cache language settings return [ - 'unableToWrite' => 'Cache unable to write to {0}', + 'unableToWrite' => 'Cache unable to write to {0}.', 'invalidHandlers' => 'Cache config must have an array of $validHandlers.', 'noBackup' => 'Cache config must have a handler and backupHandler set.', 'handlerNotFound' => 'Cache config has an invalid handler or backup handler specified.', diff --git a/system/Language/en/Cast.php b/system/Language/en/Cast.php index cf088e8..6fb9f4b 100644 --- a/system/Language/en/Cast.php +++ b/system/Language/en/Cast.php @@ -11,10 +11,13 @@ // Cast language settings return [ - 'jsonErrorDepth' => 'Maximum stack depth exceeded', - 'jsonErrorStateMismatch' => 'Underflow or the modes mismatch', - 'jsonErrorCtrlChar' => 'Unexpected control character found', - 'jsonErrorSyntax' => 'Syntax error, malformed JSON', - 'jsonErrorUtf8' => 'Malformed UTF-8 characters, possibly incorrectly encoded', - 'jsonErrorUnknown' => 'Unknown error', + 'baseCastMissing' => 'The "{0}" class must inherit the "CodeIgniter\Entity\Cast\BaseCast" class.', + 'invalidCastMethod' => 'The "{0}" is invalid cast method, valid methods are: ["get", "set"].', + 'invalidTimestamp' => 'Type casting "timestamp" expects a correct timestamp.', + 'jsonErrorCtrlChar' => 'Unexpected control character found.', + 'jsonErrorDepth' => 'Maximum stack depth exceeded.', + 'jsonErrorStateMismatch' => 'Underflow or the modes mismatch.', + 'jsonErrorSyntax' => 'Syntax error, malformed JSON.', + 'jsonErrorUnknown' => 'Unknown error.', + 'jsonErrorUtf8' => 'Malformed UTF-8 characters, possibly incorrectly encoded.', ]; diff --git a/system/Language/en/Cookie.php b/system/Language/en/Cookie.php new file mode 100644 index 0000000..cc64d46 --- /dev/null +++ b/system/Language/en/Cookie.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +// Cookie language settings +return [ + 'invalidExpiresTime' => 'Invalid "{0}" type for "Expires" attribute. Expected: string, integer, DateTimeInterface object.', + 'invalidExpiresValue' => 'The cookie expiration time is not valid.', + 'invalidCookieName' => 'The cookie name "{0}" contains invalid characters.', + 'emptyCookieName' => 'The cookie name cannot be empty.', + 'invalidSecurePrefix' => 'Using the "__Secure-" prefix requires setting the "Secure" attribute.', + 'invalidHostPrefix' => 'Using the "__Host-" prefix must be set with the "Secure" flag, must not have a "Domain" attribute, and the "Path" is set to "/".', + 'invalidSameSite' => 'The SameSite value must be None, Lax, Strict or a blank string, {0} given.', + 'invalidSameSiteNone' => 'Using the "SameSite=None" attribute requires setting the "Secure" attribute.', + 'invalidCookieInstance' => '"{0}" class expected cookies array to be instances of "{1}" but got "{2}" at index {3}.', + 'unknownCookieInstance' => 'Cookie object with name "{0}" and prefix "{1}" was not found in the collection.', +]; diff --git a/system/Language/en/Core.php b/system/Language/en/Core.php index 8ba05c2..82e0397 100644 --- a/system/Language/en/Core.php +++ b/system/Language/en/Core.php @@ -14,6 +14,7 @@ 'copyError' => 'An error was encountered while attempting to replace the file ({0}). Please make sure your file directory is writable.', 'enabledZlibOutputCompression' => 'Your zlib.output_compression ini directive is turned on. This will not work well with output buffers.', 'invalidFile' => 'Invalid file: {0}', + 'invalidPhpVersion' => 'Your PHP version must be {0} or higher to run CodeIgniter. Current version: {1}', 'missingExtension' => 'The framework needs the following extension(s) installed and loaded: {0}.', 'noHandlers' => '{0} must provide at least one Handler.', ]; diff --git a/system/Language/en/Fabricator.php b/system/Language/en/Fabricator.php index 88ca611..4e4ecb3 100644 --- a/system/Language/en/Fabricator.php +++ b/system/Language/en/Fabricator.php @@ -13,5 +13,5 @@ return [ 'invalidModel' => 'Invalid model supplied for fabrication.', 'missingFormatters' => 'No valid formatters defined.', - 'createFailed' => 'Fabricator failed to insert on table {0}: {1}.', + 'createFailed' => 'Fabricator failed to insert on table {0}: {1}', ]; diff --git a/system/Language/en/Files.php b/system/Language/en/Files.php index fe8a80a..772ad8c 100644 --- a/system/Language/en/Files.php +++ b/system/Language/en/Files.php @@ -12,5 +12,5 @@ // Files language settings return [ 'fileNotFound' => 'File not found: {0}', - 'cannotMove' => 'Could not move file {0} to {1} ({2})', + 'cannotMove' => 'Could not move file {0} to {1} ({2}).', ]; diff --git a/system/Language/en/HTTP.php b/system/Language/en/HTTP.php index d641eb5..a70f50d 100644 --- a/system/Language/en/HTTP.php +++ b/system/Language/en/HTTP.php @@ -73,6 +73,6 @@ 'uploadErrUnknown' => 'The file "%s" was not uploaded due to an unknown error.', // SameSite setting - // @deprecated use `Security.invalidSameSiteSetting` + // @deprecated 'invalidSameSiteSetting' => 'The SameSite setting must be None, Lax, Strict, or a blank string. Given: {0}', ]; diff --git a/system/Language/en/Log.php b/system/Language/en/Log.php index 8942ebf..84f5ae1 100644 --- a/system/Language/en/Log.php +++ b/system/Language/en/Log.php @@ -11,5 +11,6 @@ // Log language settings return [ - 'invalidLogLevel' => '{0} is an invalid log level.', + 'invalidLogLevel' => '{0} is an invalid log level.', + 'invalidMessageType' => 'The given message type "{0}" is not supported.', ]; diff --git a/system/Language/en/Security.php b/system/Language/en/Security.php index 3e1b961..a09c358 100644 --- a/system/Language/en/Security.php +++ b/system/Language/en/Security.php @@ -12,5 +12,7 @@ // Security language settings return [ 'disallowedAction' => 'The action you requested is not allowed.', + + // @deprecated 'invalidSameSite' => 'The SameSite value must be None, Lax, Strict, or a blank string. Given: {0}', ]; diff --git a/system/Language/en/Session.php b/system/Language/en/Session.php index 56bd1b7..606860d 100644 --- a/system/Language/en/Session.php +++ b/system/Language/en/Session.php @@ -16,5 +16,7 @@ 'writeProtectedSavePath' => 'Session: Configured save path "{0}" is not writable by the PHP process.', 'emptySavePath' => 'Session: No save path configured.', 'invalidSavePathFormat' => 'Session: Invalid Redis save path format: {0}', + + // @deprecated 'invalidSameSiteSetting' => 'Session: The SameSite setting must be None, Lax, Strict, or a blank string. Given: {0}', ]; diff --git a/system/Log/Exceptions/LogException.php b/system/Log/Exceptions/LogException.php index 12ff819..e2e8855 100644 --- a/system/Log/Exceptions/LogException.php +++ b/system/Log/Exceptions/LogException.php @@ -19,4 +19,9 @@ { return new static(lang('Log.invalidLogLevel', [$level])); } + + public static function forInvalidMessageType(string $messageType) + { + return new static(lang('Log.invalidMessageType', [$messageType])); + } } diff --git a/system/Log/Handlers/BaseHandler.php b/system/Log/Handlers/BaseHandler.php index 61b6e7d..7f06577 100644 --- a/system/Log/Handlers/BaseHandler.php +++ b/system/Log/Handlers/BaseHandler.php @@ -73,7 +73,6 @@ abstract public function handle($level, $message): bool; //-------------------------------------------------------------------- - /** * Stores the date format to use while logging messages. * diff --git a/system/Log/Handlers/ErrorlogHandler.php b/system/Log/Handlers/ErrorlogHandler.php new file mode 100644 index 0000000..fb5d1a4 --- /dev/null +++ b/system/Log/Handlers/ErrorlogHandler.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Log\Handlers; + +use CodeIgniter\Log\Exceptions\LogException; + +/** + * Log handler that writes to PHP's `error_log()` + */ +class ErrorlogHandler extends BaseHandler +{ + /** + * Message is sent to PHP's system logger, using the Operating System's + * system logging mechanism or a file, depending on what the error_log + * configuration directive is set to. + */ + public const TYPE_OS = 0; + + /** + * Message is sent directly to the SAPI logging handler. + */ + public const TYPE_SAPI = 4; + + /** + * Says where the error should go. Currently supported are + * 0 (`TYPE_OS`) and 4 (`TYPE_SAPI`). + * + * @var integer + */ + protected $messageType = 0; + + /** + * Constructor. + * + * @param mixed[] $config + */ + public function __construct(array $config = []) + { + parent::__construct($config); + + $messageType = $config['messageType'] ?? self::TYPE_OS; + + if (! is_int($messageType) || ! in_array($messageType, [self::TYPE_OS, self::TYPE_SAPI], true)) + { + throw LogException::forInvalidMessageType(print_r($messageType, true)); + } + + $this->messageType = $messageType; + } + + /** + * Handles logging the message. + * If the handler returns false, then execution of handlers + * will stop. Any handlers that have not run, yet, will not + * be run. + * + * @param string $level + * @param string $message + * + * @return boolean + */ + public function handle($level, $message): bool + { + $message = strtoupper($level) . ' --> ' . $message . "\n"; + + return $this->errorLog($message, $this->messageType); + } + + /** + * Extracted call to `error_log()` in order to be tested. + * + * @param string $message + * @param integer $messageType + * + * @return boolean + * + * @codeCoverageIgnore + */ + protected function errorLog(string $message, int $messageType): bool + { + return error_log($message, $messageType); + } +} diff --git a/system/Model.php b/system/Model.php index acde965..7558c61 100644 --- a/system/Model.php +++ b/system/Model.php @@ -19,6 +19,7 @@ use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; +use CodeIgniter\Database\Query; use CodeIgniter\Exceptions\ModelException; use CodeIgniter\I18n\Time; use CodeIgniter\Validation\ValidationInterface; @@ -39,14 +40,11 @@ * - allow intermingling calls to the builder * - removes the need to use Result object directly in most cases * - * @property ConnectionInterface $db - * - * @mixin BaseBuilder + * @mixin BaseBuilder + * @property BaseConnection $db */ class Model extends BaseModel { - // region Properties - /** * Name of database table * @@ -92,10 +90,6 @@ */ protected $escape = []; - // endregion - - // region Constructor - /** * Model constructor. * @@ -104,22 +98,16 @@ */ public function __construct(ConnectionInterface &$db = null, ValidationInterface $validation = null) { + /** + * @var BaseConnection $db + */ + $db = $db ?? Database::connect($this->DBGroup); + + $this->db = &$db; + parent::__construct($validation); - - if (is_null($db)) - { - $this->db = Database::connect($this->DBGroup); - } - else - { - $this->db = &$db; - } } - // endregion - - // region Setters - /** * Specify the table associated with a model * @@ -134,10 +122,6 @@ return $this; } - // endregion - - // region Database Methods - /** * Fetches the row of database from $this->table with a primary key * matching $id. This methods works only with dbCalls @@ -187,7 +171,7 @@ */ protected function doFindColumn(string $columnName) { - return $this->select($columnName)->asArray()->find(); + return $this->select($columnName)->asArray()->find(); // @phpstan-ignore-line } /** @@ -229,12 +213,9 @@ { $builder->where($this->table . '.' . $this->deletedField, null); } - else + elseif ($this->useSoftDeletes && empty($builder->QBGroupBy) && $this->primaryKey) { - if ($this->useSoftDeletes && empty($builder->QBGroupBy) && $this->primaryKey) - { - $builder->groupBy($this->table . '.' . $this->primaryKey); - } + $builder->groupBy($this->table . '.' . $this->primaryKey); } // Some databases, like PostgreSQL, need order @@ -253,7 +234,7 @@ * * @param array $data Data * - * @return BaseResult|integer|string|false + * @return Query|boolean */ protected function doInsert(array $data) { @@ -278,17 +259,9 @@ $result = $builder->insert(); // If insertion succeeded then save the insert ID - if ($result->resultID) + if ($result) { - if (! $this->useAutoIncrement) - { - $this->insertID = $data[$this->primaryKey]; - } - else - { - // @phpstan-ignore-next-line - $this->insertID = $this->db->insertID(); - } + $this->insertID = ! $this->useAutoIncrement ? $data[$this->primaryKey] : $this->db->insertID(); } return $result; @@ -379,7 +352,7 @@ * @param integer|string|array|null $id The rows primary key(s) * @param boolean $purge Allows overriding the soft deletes setting. * - * @return BaseResult|boolean + * @return string|boolean * * @throws DatabaseException */ @@ -403,9 +376,7 @@ ); } - // @codeCoverageIgnoreStart - return false; - // @codeCoverageIgnoreEnd + return false; // @codeCoverageIgnore } $set[$this->deletedField] = $this->setDate(); @@ -415,14 +386,10 @@ $set[$this->updatedField] = $this->setDate(); } - $result = $builder->update($set); - } - else - { - $result = $builder->delete(); + return $builder->update($set); } - return $result; + return $builder->delete(); } /** @@ -467,13 +434,37 @@ /** * Grabs the last error(s) that occurred from the Database connection. + * The return array should be in the following format: + * ['source' => 'message'] * This methods works only with dbCalls * - * @return array|null + * @return array */ protected function doErrors() { - return $this->db->error(); + // $error is always ['code' => string|int, 'message' => string] + $error = $this->db->error(); + + if ((int) $error['code'] === 0) + { + return []; + } + + return [get_class($this->db) => $error['message']]; + } + + /** + * Returns the id value for the data array or object + * + * @param array|object $data Data + * + * @return integer|array|string|null + * + * @deprecated Use getIdValue() instead. Will be removed in version 5.0. + */ + protected function idValue($data) + { + return $this->getIdValue($data); } /** @@ -483,7 +474,7 @@ * * @return integer|array|string|null */ - protected function idValue($data) + public function getIdValue($data) { if (is_object($data) && isset($data->{$this->primaryKey})) { @@ -570,10 +561,6 @@ return $this->builder()->testMode($test)->countAllResults($reset); } - // endregion - - // region Builder - /** * Provides a shared instance of the Query Builder. * @@ -638,7 +625,7 @@ { $data = is_array($key) ? $key : [$key => $value]; - foreach ($data as $k => $v) + foreach (array_keys($data) as $k) { $this->tempData['escape'][$k] = $escape; } @@ -648,12 +635,6 @@ return $this; } - // endregion - - // region Overrides - - // region CRUD & Finders - /** * This method is called on save to determine if entry have to be updated * If this method return false insert operation will be executed @@ -669,7 +650,7 @@ return parent::shouldUpdate($data) && ($this->useAutoIncrement ? true - : $this->where($this->primaryKey, $this->idValue($data))->countAllResults() === 1 + : $this->where($this->primaryKey, $this->getIdValue($data))->countAllResults() === 1 ); } @@ -737,10 +718,6 @@ return parent::update($id, $data); } - // endregion - - // region Utility - /** * Takes a class an returns an array of it's public and protected * properties as an array with raw values. @@ -757,23 +734,16 @@ { $properties = parent::objectToRawArray($data, $onlyChanged); - if (method_exists($data, 'toRawArray')) + // Always grab the primary key otherwise updates will fail. + if (method_exists($data, 'toRawArray') && (! empty($properties) && ! empty($this->primaryKey) && ! in_array($this->primaryKey, $properties, true) + && ! empty($data->{$this->primaryKey}))) { - // Always grab the primary key otherwise updates will fail. - if (! empty($properties) && ! empty($this->primaryKey) && ! in_array($this->primaryKey, $properties, true) - && ! empty($data->{$this->primaryKey})) - { - $properties[$this->primaryKey] = $data->{$this->primaryKey}; - } + $properties[$this->primaryKey] = $data->{$this->primaryKey}; } return $properties; } - // endregion - - // region Magic - /** * Provides/instantiates the builder/db connection and model's table/primary key names and return type. * @@ -809,13 +779,7 @@ { return true; } - - if (isset($this->builder()->$name)) - { - return true; - } - - return false; + return isset($this->builder()->$name); } /** @@ -856,12 +820,6 @@ return $result; } - // endregion - - // endregion - - // region Deprecated - /** * Takes a class an returns an array of it's public and protected * properties as an array suitable for use in creates and updates. @@ -937,7 +895,4 @@ return $properties; } - - // endregion - } diff --git a/system/Pager/PagerRenderer.php b/system/Pager/PagerRenderer.php index b0ba80b..37f289e 100644 --- a/system/Pager/PagerRenderer.php +++ b/system/Pager/PagerRenderer.php @@ -435,6 +435,16 @@ } /** + * Returns total number of pages. + * + * @return integer + */ + public function getPageCount(): int + { + return $this->pageCount; + } + + /** * Returns the previous page number. * * @return integer|null diff --git a/system/RESTful/BaseResource.php b/system/RESTful/BaseResource.php index 0bafcb5..376f004 100644 --- a/system/RESTful/BaseResource.php +++ b/system/RESTful/BaseResource.php @@ -61,12 +61,9 @@ } // make a model object if needed - if (empty($this->model) && ! empty($this->modelName)) + if (empty($this->model) && ! empty($this->modelName) && class_exists($this->modelName)) { - if (class_exists($this->modelName)) - { - $this->model = model($this->modelName); - } + $this->model = model($this->modelName); } // determine model name if needed diff --git a/system/Router/RouteCollection.php b/system/Router/RouteCollection.php index 0c4c99a..4cd4f20 100644 --- a/system/Router/RouteCollection.php +++ b/system/Router/RouteCollection.php @@ -134,7 +134,7 @@ * * @var string */ - protected $HTTPVerb; + protected $HTTPVerb = '*'; /** * The default list of HTTP methods (and CLI for command line usage) @@ -197,6 +197,20 @@ */ protected $moduleConfig; + /** + * Flag for sorting routes by priority. + * + * @var boolean + */ + protected $prioritize = false; + + /** + * Route priority detection flag. + * + * @var boolean + */ + protected $prioritizeDetected = false; + //-------------------------------------------------------------------- /** @@ -212,7 +226,6 @@ } //-------------------------------------------------------------------- - /** * Registers a new constraint with the system. Constraints are used * by the routes as placeholders for regular expressions to make defining @@ -239,7 +252,6 @@ } //-------------------------------------------------------------------- - /** * Sets the default namespace to use for Controllers when no other * namespace has been specified. @@ -257,7 +269,6 @@ } //-------------------------------------------------------------------- - /** * Sets the default controller to use when no other controller has been * specified. @@ -274,7 +285,6 @@ } //-------------------------------------------------------------------- - /** * Sets the default method to call on the controller when no other * method has been set in the route. @@ -291,7 +301,6 @@ } //-------------------------------------------------------------------- - /** * Tells the system whether to convert dashes in URI strings into * underscores. In some search engines, including Google, dashes @@ -311,7 +320,6 @@ } //-------------------------------------------------------------------- - /** * If TRUE, the system will attempt to match the URI against * Controllers by matching each segment against folders/files @@ -332,7 +340,6 @@ } //-------------------------------------------------------------------- - /** * Sets the class/method that should be called if routing doesn't * find a match. It can be either a closure or the controller/method @@ -401,7 +408,6 @@ } //-------------------------------------------------------------------- - /** * Sets the default constraint to be used in the system. Typically * for use with the 'resource' method. @@ -520,6 +526,22 @@ } } + // sorting routes by priority + if ($this->prioritizeDetected && $this->prioritize && $routes !== []) + { + $order = []; + + foreach ($routes as $key => $value) + { + $key = $key === '/' ? $key : ltrim($key, '/ '); + $priority = $this->getRoutesOptions($key, $verb)['priority'] ?? 0; + $order[$priority][$key] = $value; + } + + ksort($order); + $routes = array_merge(...$order); + } + return $routes; } @@ -590,7 +612,6 @@ } //-------------------------------------------------------------------- - /** * Adds a single route to the collection. * @@ -744,7 +765,6 @@ // be expanded in the future. See the docblock for 'add' method above for // current list of globally available options. // - /** * Creates a collections of HTTP-verb based routes for a controller. * @@ -975,7 +995,6 @@ } //-------------------------------------------------------------------- - /** * Specifies a single route to match for multiple HTTP Verbs. * @@ -1007,7 +1026,6 @@ } //-------------------------------------------------------------------- - /** * Specifies a route that is only available to GET requests. * @@ -1025,7 +1043,6 @@ } //-------------------------------------------------------------------- - /** * Specifies a route that is only available to POST requests. * @@ -1043,7 +1060,6 @@ } //-------------------------------------------------------------------- - /** * Specifies a route that is only available to PUT requests. * @@ -1061,7 +1077,6 @@ } //-------------------------------------------------------------------- - /** * Specifies a route that is only available to DELETE requests. * @@ -1079,7 +1094,6 @@ } //-------------------------------------------------------------------- - /** * Specifies a route that is only available to HEAD requests. * @@ -1097,7 +1111,6 @@ } //-------------------------------------------------------------------- - /** * Specifies a route that is only available to PATCH requests. * @@ -1115,7 +1128,6 @@ } //-------------------------------------------------------------------- - /** * Specifies a route that is only available to OPTIONS requests. * @@ -1133,7 +1145,6 @@ } //-------------------------------------------------------------------- - /** * Specifies a route that is only available to command-line requests. * @@ -1151,7 +1162,6 @@ } //-------------------------------------------------------------------- - /** * Limits the routes to a specified ENVIRONMENT or they won't run. * @@ -1367,6 +1377,17 @@ $options = array_merge($this->currentOptions ?? [], $options ?? []); + // Route priority detect + if (isset($options['priority'])) + { + $options['priority'] = abs((int) $options['priority']); + + if ($options['priority'] > 0) + { + $this->prioritizeDetected = true; + } + } + // Hostname limiting? if (! empty($options['hostname'])) { @@ -1418,21 +1439,17 @@ } //If is redirect, No processing - if (! isset($options['redirect'])) + if (! isset($options['redirect']) && is_string($to)) { - if (is_string($to)) - { - // If no namespace found, add the default namespace - if (strpos($to, '\\') === false || strpos($to, '\\') > 0) + // If no namespace found, add the default namespace + if (strpos($to, '\\') === false || strpos($to, '\\') > 0) { $namespace = $options['namespace'] ?? $this->defaultNamespace; $to = trim($namespace, '\\') . '\\' . $to; - } - - // Always ensure that we escape our namespace so we're not pointing to - // \CodeIgniter\Routes\Controller::method. - $to = '\\' . ltrim($to, '\\'); } + // Always ensure that we escape our namespace so we're not pointing to + // \CodeIgniter\Routes\Controller::method. + $to = '\\' . ltrim($to, '\\'); } $name = $options['as'] ?? $from; @@ -1551,7 +1568,7 @@ //-------------------------------------------------------------------- /** - * Reset the routes, so that a FeatureTestCase can provide the + * Reset the routes, so that a test case can provide the * explicit ones needed for it. */ public function resetRoutes() @@ -1561,6 +1578,8 @@ { $this->routes[$verb] = []; } + + $this->prioritizeDetected = false; } //-------------------------------------------------------------------- @@ -1596,4 +1615,18 @@ return $options; } + + /** + * Enable or Disable sorting routes by priority + * + * @param boolean $enabled The value status + * + * @return $this + */ + public function setPrioritize(bool $enabled = true) + { + $this->prioritize = $enabled; + + return $this; + } } diff --git a/system/Router/Router.php b/system/Router/Router.php index 1c61d12..1980bda 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -103,7 +103,6 @@ protected $filterInfo; //-------------------------------------------------------------------- - /** * Stores a reference to the RouteCollection object. * @@ -498,7 +497,7 @@ { $segments = explode('/', $uri); - $segments = $this->validateRequest($segments); + $segments = $this->scanControllers($segments); // If we don't have any segments left - try the default controller; // WARNING: Directories get shifted out of the segments array. @@ -512,6 +511,12 @@ $this->controller = ucfirst(array_shift($segments)); } + $controllerName = $this->controllerName(); + if (! $this->isValidSegment($controllerName)) + { + throw new PageNotFoundException($this->controller . ' is not a valid controller name'); + } + // Use the method name if it exists. // If it doesn't, no biggie - the default method name // has already been set. @@ -526,7 +531,6 @@ } $defaultNamespace = $this->collection->getDefaultNamespace(); - $controllerName = $this->controllerName(); if ($this->collection->getHTTPVerb() !== 'cli') { $controller = '\\' . $defaultNamespace; @@ -573,32 +577,63 @@ //-------------------------------------------------------------------- /** - * Attempts to validate the URI request and determine the controller path. + * Scans the controller directory, attempting to locate a controller matching the supplied uri $segments * * @param array $segments URI segments * - * @return array URI segments + * @return array returns an array of remaining uri segments that don't map onto a directory + * + * @deprecated this function name does not properly describe its behavior so it has been deprecated + * + * @codeCoverageIgnore */ protected function validateRequest(array $segments): array { + return $this->scanControllers($segments); + } + + //-------------------------------------------------------------------- + + /** + * Scans the controller directory, attempting to locate a controller matching the supplied uri $segments + * + * @param array $segments URI segments + * + * @return array returns an array of remaining uri segments that don't map onto a directory + */ + protected function scanControllers(array $segments): array + { $segments = array_filter($segments, function ($segment) { - // @phpstan-ignore-next-line - return ! empty($segment) || ($segment !== '0' || $segment !== 0); + return $segment !== ''; }); + // numerically reindex the array, removing gaps $segments = array_values($segments); - $c = count($segments); - $directoryOverride = isset($this->directory); + // if a prior directory value has been set, just return segments and get out of here + if (isset($this->directory)) + { + return $segments; + } // Loop through our segments and return as soon as a controller // is found or when such a directory doesn't exist + $c = count($segments); while ($c-- > 0) { - $test = $this->directory . ucfirst($this->translateURIDashes === true ? str_replace('-', '_', $segments[0]) : $segments[0]); - - if (! is_file(APPPATH . 'Controllers/' . $test . '.php') && $directoryOverride === false && is_dir(APPPATH . 'Controllers/' . $this->directory . ucfirst($segments[0]))) + $segmentConvert = ucfirst($this->translateURIDashes === true ? str_replace('-', '_', $segments[0]) : $segments[0]); + // as soon as we encounter any segment that is not PSR-4 compliant, stop searching + if (! $this->isValidSegment($segmentConvert)) { - $this->setDirectory(array_shift($segments), true); + return $segments; + } + + $test = APPPATH . 'Controllers/' . $this->directory . $segmentConvert; + + // as long as each segment is *not* a controller file but does match a directory, add it to $this->directory + if (! is_file($test . '.php') && is_dir($test)) + { + $this->setDirectory($segmentConvert, true, false); + array_shift($segments); continue; } @@ -614,10 +649,11 @@ /** * Sets the sub-directory that the controller is in. * - * @param string|null $dir - * @param boolean|false $append + * @param string|null $dir + * @param boolean $append + * @param boolean $validate if true, checks to make sure $dir consists of only PSR4 compliant segments */ - public function setDirectory(string $dir = null, bool $append = false) + public function setDirectory(string $dir = null, bool $append = false, bool $validate = true) { if (empty($dir)) { @@ -625,18 +661,41 @@ return; } - $dir = ucfirst($dir); + if ($validate) + { + $segments = explode('/', trim($dir, '/')); + foreach ($segments as $segment) + { + if (! $this->isValidSegment($segment)) + { + return; + } + } + } if ($append !== true || empty($this->directory)) { - $this->directory = str_replace('.', '', trim($dir, '/')) . '/'; + $this->directory = trim($dir, '/') . '/'; } else { - $this->directory .= str_replace('.', '', trim($dir, '/')) . '/'; + $this->directory .= trim($dir, '/') . '/'; } } + /** + * Returns true if the supplied $segment string represents a valid PSR-4 compliant namespace/directory segment + * + * regex comes from https://www.php.net/manual/en/language.variables.basics.php + * + * @param string $segment + * @return boolean + */ + private function isValidSegment(string $segment): bool + { + return (bool) preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $segment); + } + //-------------------------------------------------------------------- /** diff --git a/system/Security/Exceptions/SecurityException.php b/system/Security/Exceptions/SecurityException.php index c129ae5..9e096b8 100644 --- a/system/Security/Exceptions/SecurityException.php +++ b/system/Security/Exceptions/SecurityException.php @@ -19,7 +19,12 @@ { return new static(lang('Security.disallowedAction'), 403); } - + + /** + * @deprecated Use `CookieException::forInvalidSameSite()` instead. + * + * @codeCoverageIgnore + */ public static function forInvalidSameSite(string $samesite) { return new static(lang('Security.invalidSameSite', [$samesite])); diff --git a/system/Security/Security.php b/system/Security/Security.php index a98ed30..22ae21f 100644 --- a/system/Security/Security.php +++ b/system/Security/Security.php @@ -11,9 +11,12 @@ namespace CodeIgniter\Security; +use CodeIgniter\Cookie\Cookie; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\Security\Exceptions\SecurityException; use Config\App; +use Config\Cookie as CookieConfig; +use Config\Security as SecurityConfig; /** * Class Security @@ -51,6 +54,13 @@ protected $headerName = 'X-CSRF-TOKEN'; /** + * The CSRF Cookie instance. + * + * @var Cookie + */ + protected $cookie; + + /** * CSRF Cookie Name * * Cookie name for Cross Site Request Forgery protection cookie. @@ -67,6 +77,8 @@ * Defaults to two hours (in seconds). * * @var integer + * + * @deprecated */ protected $expires = 7200; @@ -99,11 +111,11 @@ * * @see https://portswigger.net/web-security/csrf/samesite-cookies * - * @var string 'Lax'|'None'|'Strict' + * @var string + * + * @deprecated */ - protected $samesite = 'Lax'; - - //-------------------------------------------------------------------- + protected $samesite = Cookie::SAMESITE_LAX; /** * Constructor. @@ -112,35 +124,32 @@ * initial state. * * @param App $config - * - * @throws SecurityException */ - public function __construct($config) + public function __construct(App $config) { + /** @var SecurityConfig */ $security = config('Security'); + // Store CSRF-related configurations $this->tokenName = $security->tokenName ?? $config->CSRFTokenName ?? $this->tokenName; $this->headerName = $security->headerName ?? $config->CSRFHeaderName ?? $this->headerName; - $this->cookieName = $security->cookieName ?? $config->CSRFCookieName ?? $this->cookieName; - $this->expires = $security->expires ?? $config->CSRFExpire ?? $this->expires; $this->regenerate = $security->regenerate ?? $config->CSRFRegenerate ?? $this->regenerate; - $this->samesite = $security->samesite ?? $config->CSRFSameSite ?? $this->samesite; + $rawCookieName = $security->cookieName ?? $config->CSRFCookieName ?? $this->cookieName; - if (! in_array(strtolower($this->samesite), ['none', 'lax', 'strict', ''], true)) - { - throw SecurityException::forInvalidSameSite($this->samesite); - } + /** @var CookieConfig */ + $cookie = config('Cookie'); - if (isset($config->cookiePrefix)) - { - $this->cookieName = $config->cookiePrefix . $this->cookieName; - } + $cookiePrefix = $cookie->prefix ?? $config->cookiePrefix; + $this->cookieName = $cookiePrefix . $rawCookieName; - $this->generateHash(); + $expires = $security->expires ?? $config->CSRFExpire ?? 7200; + + Cookie::setDefaults($cookie); + $this->cookie = new Cookie($rawCookieName, $this->generateHash(), [ + 'expires' => $expires === 0 ? 0 : time() + $expires, + ]); } - //-------------------------------------------------------------------- - /** * CSRF Verify * @@ -151,42 +160,42 @@ * @throws SecurityException * * @deprecated Use `CodeIgniter\Security\Security::verify()` instead of using this method. + * + * @codeCoverageIgnore */ public function CSRFVerify(RequestInterface $request) { return $this->verify($request); } - //-------------------------------------------------------------------- - /** * Returns the CSRF Hash. * * @return string|null * * @deprecated Use `CodeIgniter\Security\Security::getHash()` instead of using this method. + * + * @codeCoverageIgnore */ public function getCSRFHash(): ?string { return $this->getHash(); } - //-------------------------------------------------------------------- - /** * Returns the CSRF Token Name. * * @return string * * @deprecated Use `CodeIgniter\Security\Security::getTokenName()` instead of using this method. + * + * @codeCoverageIgnore */ public function getCSRFTokenName(): string { return $this->getTokenName(); } - //-------------------------------------------------------------------- - /** * CSRF Verify * @@ -226,7 +235,7 @@ $token = $_POST[$this->tokenName] ?? $tokenName; // Does the tokens exist in both the POST/POSTed JSON and COOKIE arrays and match? - if (! isset($token, $_COOKIE[$this->cookieName]) || $token !== $_COOKIE[$this->cookieName]) + if (! isset($token, $_COOKIE[$this->cookieName]) || ! hash_equals($token, $_COOKIE[$this->cookieName])) { throw SecurityException::forDisallowedAction(); } @@ -244,15 +253,13 @@ $request->setBody(json_encode($json)); } - // Regenerate on every submission? if ($this->regenerate) { - // Nothing should last forever. $this->hash = null; unset($_COOKIE[$this->cookieName]); } - $this->generateHash(); + $this->cookie = $this->cookie->withValue($this->generateHash()); $this->sendCookie($request); log_message('info', 'CSRF token verified.'); @@ -260,8 +267,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Returns the CSRF Hash. * @@ -272,8 +277,6 @@ return $this->hash; } - //-------------------------------------------------------------------- - /** * Returns the CSRF Token Name. * @@ -284,8 +287,6 @@ return $this->tokenName; } - //-------------------------------------------------------------------- - /** * Returns the CSRF Header Name. * @@ -296,8 +297,6 @@ return $this->headerName; } - //-------------------------------------------------------------------- - /** * Returns the CSRF Cookie Name. * @@ -308,20 +307,19 @@ return $this->cookieName; } - //-------------------------------------------------------------------- - /** * Check if CSRF cookie is expired. * * @return boolean + * + * @deprecated + * + * @codeCoverageIgnore */ public function isExpired(): bool { - return $this->expires === 0; + return $this->cookie->isExpired(); } - - //-------------------------------------------------------------------- - /** * Check if request should be redirect on failure. * @@ -332,8 +330,6 @@ return $this->redirect; } - //-------------------------------------------------------------------- - /** * Sanitize Filename * @@ -405,8 +401,6 @@ return stripslashes($str); } - //-------------------------------------------------------------------- - /** * Generates the CSRF Hash. * @@ -434,64 +428,36 @@ return $this->hash; } - //-------------------------------------------------------------------- - /** * CSRF Send Cookie * * @param RequestInterface $request * - * @return Security|false - * @codeCoverageIgnore + * @return Security|false */ protected function sendCookie(RequestInterface $request) { - $config = new App(); - - $expires = $this->isExpired() ? $this->expires : time() + $this->expires; - $path = $config->cookiePath ?? '/'; - $domain = $config->cookieDomain ?? ''; - $secure = $config->cookieSecure ?? false; - - if ($secure && ! $request->isSecure()) + if ($this->cookie->isSecure() && ! $request->isSecure()) { return false; } - if (PHP_VERSION_ID < 70300) - { - // In PHP < 7.3.0, there is a "hacky" way to set the samesite parameter - $samesite = ''; - - if (! empty($this->samesite)) - { - $samesite = '; samesite=' . $this->samesite; - } - - setcookie($this->cookieName, $this->hash, $expires, $path . $samesite, $domain, $secure, true); - } - else - { - // PHP 7.3 adds another function signature allowing setting of samesite - $params = [ - 'expires' => $expires, - 'path' => $path, - 'domain' => $domain, - 'secure' => $secure, - 'httponly' => true, // Enforce HTTP only cookie for security - ]; - - if (! empty($this->samesite)) - { - $params['samesite'] = $this->samesite; - } - - // @phpstan-ignore-next-line @todo ignore to be removed in 4.1 with rector 0.9 - setcookie($this->cookieName, $this->hash, $params); - } - + $this->doSendCookie(); log_message('info', 'CSRF cookie sent.'); return $this; } + + /** + * Actual dispatching of cookies. + * Extracted for this to be unit tested. + * + * @codeCoverageIgnore + * + * @return void + */ + protected function doSendCookie(): void + { + cookies([$this->cookie], false)->dispatch(); + } } diff --git a/system/Security/SecurityInterface.php b/system/Security/SecurityInterface.php index 96f87c7..e77ded3 100644 --- a/system/Security/SecurityInterface.php +++ b/system/Security/SecurityInterface.php @@ -25,7 +25,7 @@ * @param RequestInterface $request * * @return $this|false - * + * * @throws SecurityException */ public function verify(RequestInterface $request); @@ -62,6 +62,8 @@ * Check if CSRF cookie is expired. * * @return boolean + * + * @deprecated */ public function isExpired(): bool; diff --git a/system/Session/Exceptions/SessionException.php b/system/Session/Exceptions/SessionException.php index 8a1efa8..4e23b51 100644 --- a/system/Session/Exceptions/SessionException.php +++ b/system/Session/Exceptions/SessionException.php @@ -40,6 +40,11 @@ return new static(lang('Session.invalidSavePathFormat', [$path])); } + /** + * @deprecated + * + * @codeCoverageIgnore + */ public static function forInvalidSameSiteSetting(string $samesite) { return new static(lang('Session.invalidSameSiteSetting', [$samesite])); diff --git a/system/Session/Handlers/DatabaseHandler.php b/system/Session/Handlers/DatabaseHandler.php index 28f62da..0f4bda2 100644 --- a/system/Session/Handlers/DatabaseHandler.php +++ b/system/Session/Handlers/DatabaseHandler.php @@ -144,7 +144,7 @@ } $builder = $this->db->table($this->table) - ->select('data') + ->select($this->platform === 'postgre' ? "encode(data, 'base64') AS data" : 'data') ->where('id', $sessionID); if ($this->matchIP) @@ -165,9 +165,6 @@ return ''; } - // PostgreSQL's variant of a BLOB datatype is Bytea, which is a - // PITA to work with, so we use base64-encoded data in a TEXT - // field instead. if (is_bool($result)) { $result = ''; @@ -214,8 +211,8 @@ $insertData = [ 'id' => $sessionID, 'ip_address' => $this->ipAddress, - 'timestamp' => time(), - 'data' => $this->platform === 'postgre' ? base64_encode($sessionData) : $sessionData, + 'timestamp' => 'now()', + 'data' => $this->platform === 'postgre' ? '\x' . bin2hex($sessionData) : $sessionData, ]; if (! $this->db->table($this->table)->insert($insertData)) @@ -237,12 +234,12 @@ } $updateData = [ - 'timestamp' => time(), + 'timestamp' => 'now()', ]; if ($this->fingerprint !== md5($sessionData)) { - $updateData['data'] = ($this->platform === 'postgre') ? base64_encode($sessionData) : $sessionData; + $updateData['data'] = ($this->platform === 'postgre') ? '\x' . bin2hex($sessionData) : $sessionData; } if (! $builder->update($updateData)) @@ -320,7 +317,8 @@ */ public function gc($maxlifetime): bool { - return ($this->db->table($this->table)->delete('timestamp < ' . (time() - $maxlifetime))) ? true : $this->fail(); + $interval = implode(" '"[(int)($this->platform === 'postgre')], ['', "{$maxlifetime} second", '']); + return ($this->db->table($this->table)->delete("timestamp < now() - INTERVAL {$interval}")) ? true : $this->fail(); } //-------------------------------------------------------------------- diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index 921c556..b3545c0 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -78,8 +78,10 @@ if (preg_match('#(?:tcp://)?([^:?]+)(?:\:(\d+))?(\?.+)?#', $this->savePath, $matches)) { - // @phpstan-ignore-next-line - isset($matches[3]) || $matches[3] = ''; // Just to avoid undefined index notices below + if (! isset($matches[3])) + { + $matches[3] = ''; + } // Just to avoid undefined index notices below $this->savePath = [ 'host' => $matches[1], diff --git a/system/Session/Session.php b/system/Session/Session.php index 0bc3d66..e6cab17 100644 --- a/system/Session/Session.php +++ b/system/Session/Session.php @@ -11,8 +11,9 @@ namespace CodeIgniter\Session; -use CodeIgniter\Session\Exceptions\SessionException; +use CodeIgniter\Cookie\Cookie; use Config\App; +use Config\Cookie as CookieConfig; use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerInterface; use SessionHandlerInterface; @@ -64,7 +65,7 @@ * * For the 'database' driver, it's a table name. * - * TODO: address memcache & redis needs + * @todo address memcache & redis needs * * IMPORTANT: You are REQUIRED to set a valid save path! * @@ -99,10 +100,19 @@ protected $sessionRegenerateDestroy = false; /** + * The session cookie instance. + * + * @var Cookie + */ + protected $cookie; + + /** * The domain name to use for cookies. * Set to .your-domain.com for site-wide cookies. * * @var string + * + * @deprecated */ protected $cookieDomain = ''; @@ -111,6 +121,8 @@ * Typically will be a forward slash. * * @var string + * + * @deprecated */ protected $cookiePath = '/'; @@ -118,6 +130,8 @@ * Cookie will only be set if a secure HTTPS connection exists. * * @var boolean + * + * @deprecated */ protected $cookieSecure = false; @@ -125,9 +139,11 @@ * Cookie SameSite setting as described in RFC6265 * Must be 'None', 'Lax' or 'Strict'. * - * @var string 'Lax'|'None'|'Strict' + * @var string + * + * @deprecated */ - protected $cookieSameSite = 'Lax'; + protected $cookieSameSite = Cookie::SAMESITE_LAX; /** * sid regex expression @@ -143,8 +159,6 @@ */ protected $logger; - //-------------------------------------------------------------------- - /** * Constructor. * @@ -153,7 +167,7 @@ * @param SessionHandlerInterface $driver * @param App $config */ - public function __construct(SessionHandlerInterface $driver, $config) + public function __construct(SessionHandlerInterface $driver, App $config) { $this->driver = $driver; @@ -165,21 +179,30 @@ $this->sessionTimeToUpdate = $config->sessionTimeToUpdate ?? $this->sessionTimeToUpdate; $this->sessionRegenerateDestroy = $config->sessionRegenerateDestroy ?? $this->sessionRegenerateDestroy; - $this->cookieDomain = $config->cookieDomain ?? $this->cookieDomain; + //--------------------------------------------------------------------- + // DEPRECATED COOKIE MANAGEMENT + //--------------------------------------------------------------------- $this->cookiePath = $config->cookiePath ?? $this->cookiePath; + $this->cookieDomain = $config->cookieDomain ?? $this->cookieDomain; $this->cookieSecure = $config->cookieSecure ?? $this->cookieSecure; $this->cookieSameSite = $config->cookieSameSite ?? $this->cookieSameSite; - if (! in_array(strtolower($this->cookieSameSite), ['', 'none', 'lax', 'strict'], true)) - { - throw SessionException::forInvalidSameSiteSetting($this->cookieSameSite); - } + /** @var CookieConfig */ + $cookie = config('Cookie'); + + $this->cookie = new Cookie($this->sessionCookieName, '', [ + 'expires' => $this->sessionExpiration === 0 ? 0 : time() + $this->sessionExpiration, + 'path' => $cookie->path ?? $config->cookiePath, + 'domain' => $cookie->domain ?? $config->cookieDomain, + 'secure' => $cookie->secure ?? $config->cookieSecure, + 'httponly' => true, // for security + 'samesite' => $cookie->samesite ?? $config->cookieSameSite ?? Cookie::SAMESITE_LAX, + 'raw' => $cookie->raw ?? false, + ]); helper('array'); } - //-------------------------------------------------------------------- - /** * Initialize the session container and starts up the session. * @@ -211,13 +234,11 @@ } $this->configure(); - $this->setSaveHandler(); // Sanitize the cookie, because apparently PHP doesn't do that for userspace handlers - if (isset($_COOKIE[$this->sessionCookieName]) && ( - ! is_string($_COOKIE[$this->sessionCookieName]) || ! preg_match('#\A' . $this->sidRegexp . '\z#', $_COOKIE[$this->sessionCookieName]) - ) + if (isset($_COOKIE[$this->sessionCookieName]) + && (! is_string($_COOKIE[$this->sessionCookieName]) || ! preg_match('#\A' . $this->sidRegexp . '\z#', $_COOKIE[$this->sessionCookieName])) ) { unset($_COOKIE[$this->sessionCookieName]); @@ -226,8 +247,7 @@ $this->startSession(); // Is session ID auto-regeneration configured? (ignoring ajax requests) - if ((empty($_SERVER['HTTP_X_REQUESTED_WITH']) - || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') + if ((empty($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') && ($regenerateTime = $this->sessionTimeToUpdate) > 0 ) { @@ -248,14 +268,11 @@ } $this->initVars(); - $this->logger->info("Session: Class initialized using '" . $this->sessionDriverName . "' driver."); return $this; } - //-------------------------------------------------------------------- - /** * Does a full stop of the session: * @@ -266,14 +283,18 @@ public function stop() { setcookie( - $this->sessionCookieName, session_id(), 1, $this->cookiePath, $this->cookieDomain, $this->cookieSecure, true + $this->sessionCookieName, + session_id(), + 1, + $this->cookie->getPath(), + $this->cookie->getDomain(), + $this->cookie->isSecure(), + true ); session_regenerate_id(true); } - //-------------------------------------------------------------------- - /** * Configuration. * @@ -290,43 +311,20 @@ ini_set('session.name', $this->sessionCookieName); } - if (PHP_VERSION_ID < 70300) - { - $sameSite = ''; - if ($this->cookieSameSite !== '') - { - $sameSite = '; samesite=' . $this->cookieSameSite; - } + $sameSite = $this->cookie->getSameSite() ?: ucfirst(Cookie::SAMESITE_LAX); - session_set_cookie_params( - $this->sessionExpiration, - $this->cookiePath . $sameSite, // Hacky way to set SameSite for PHP 7.2 and earlier - $this->cookieDomain, - $this->cookieSecure, - true // HTTP only; Yes, this is intentional and not configurable for security reasons. - ); - } - else - { - // PHP 7.3 adds support for setting samesite in session_set_cookie_params() - $params = [ - 'lifetime' => $this->sessionExpiration, - 'path' => $this->cookiePath, - 'domain' => $this->cookieDomain, - 'secure' => $this->cookieSecure, - 'httponly' => true, // HTTP only; Yes, this is intentional and not configurable for security reasons. - ]; + $params = [ + 'lifetime' => $this->sessionExpiration, + 'path' => $this->cookie->getPath(), + 'domain' => $this->cookie->getDomain(), + 'secure' => $this->cookie->isSecure(), + 'httponly' => true, // HTTP only; Yes, this is intentional and not configurable for security reasons. + 'samesite' => $sameSite, + ]; - if ($this->cookieSameSite !== '') - { - $params['samesite'] = $this->cookieSameSite; - ini_set('session.cookie_samesite', $this->cookieSameSite); - } + ini_set('session.cookie_samesite', $sameSite); + session_set_cookie_params($params); - session_set_cookie_params($params); - } - - //if (empty($this->sessionExpiration)) if (! isset($this->sessionExpiration)) { $this->sessionExpiration = (int) ini_get('session.gc_maxlifetime'); @@ -350,8 +348,6 @@ $this->configureSidLength(); } - // ------------------------------------------------------------------------ - /** * Configure session ID length * @@ -402,8 +398,6 @@ $this->sidRegexp .= '{' . $sidLength . '}'; } - //-------------------------------------------------------------------- - /** * Handle temporary variables * @@ -439,7 +433,6 @@ } //-------------------------------------------------------------------- - //-------------------------------------------------------------------- // Session Utility Methods //-------------------------------------------------------------------- @@ -454,18 +447,20 @@ session_regenerate_id($destroy); } - //-------------------------------------------------------------------- - /** * Destroys the current session. */ public function destroy() { + if (ENVIRONMENT === 'testing') + { + return; + } + session_destroy(); } //-------------------------------------------------------------------- - //-------------------------------------------------------------------- // Basic Setters and Getters //-------------------------------------------------------------------- @@ -503,8 +498,6 @@ $_SESSION[$data] = $value; } - //-------------------------------------------------------------------- - /** * Get user data that has been set in the session. * @@ -535,11 +528,10 @@ } $userdata = []; - $_exclude = array_merge( - ['__ci_vars'], $this->getFlashKeys(), $this->getTempKeys() - ); + $_exclude = array_merge(['__ci_vars'], $this->getFlashKeys(), $this->getTempKeys()); $keys = array_keys($_SESSION); + foreach ($keys as $key) { if (! in_array($key, $_exclude, true)) @@ -551,8 +543,6 @@ return $userdata; } - //-------------------------------------------------------------------- - /** * Returns whether an index exists in the session array. * @@ -565,8 +555,6 @@ return isset($_SESSION[$key]); } - //-------------------------------------------------------------------- - /** * Push new value onto session value that is array. * @@ -583,8 +571,6 @@ } } - //-------------------------------------------------------------------- - /** * Remove one or more session properties. * @@ -609,8 +595,6 @@ unset($_SESSION[$key]); } - //-------------------------------------------------------------------- - /** * Magic method to set variables in the session by simply calling * $session->foo = bar; @@ -623,8 +607,6 @@ $_SESSION[$key] = $value; } - //-------------------------------------------------------------------- - /** * Magic method to get session variables by simply calling * $foo = $session->foo; @@ -650,8 +632,6 @@ return null; } - //-------------------------------------------------------------------- - /** * Magic method to check for session variables. * Different from has() in that it will validate 'session_id' as well. @@ -667,7 +647,6 @@ } //-------------------------------------------------------------------- - //-------------------------------------------------------------------- // Flash Data Methods //-------------------------------------------------------------------- @@ -689,15 +668,14 @@ $this->markAsFlashdata(is_array($data) ? array_keys($data) : $data); } - //-------------------------------------------------------------------- - /** * Retrieve one or more items of flash data from the session. * * If the item key is null, return all flashdata. * - * @param string $key Property identifier - * @return array|null The requested property value, or an associative array of them + * @param string $key Property identifier + * + * @return array|null The requested property value, or an associative array of them */ public function getFlashdata(string $key = null) { @@ -713,15 +691,16 @@ { foreach ($_SESSION['__ci_vars'] as $key => &$value) { - is_int($value) || $flashdata[$key] = $_SESSION[$key]; + if (! is_int($value)) + { + $flashdata[$key] = $_SESSION[$key]; + } } } return $flashdata; } - //-------------------------------------------------------------------- - /** * Keeps a single piece of flash data alive for one more request. * @@ -732,8 +711,6 @@ $this->markAsFlashdata($key); } - //-------------------------------------------------------------------- - /** * Mark a session property or properties as flashdata. * @@ -770,8 +747,6 @@ return true; } - //-------------------------------------------------------------------- - /** * Unmark data in the session as flashdata. * @@ -784,7 +759,10 @@ return; } - is_array($key) || $key = [$key]; // @phpstan-ignore-line + if (! is_array($key)) + { + $key = [$key]; + } foreach ($key as $k) { @@ -800,12 +778,10 @@ } } - //-------------------------------------------------------------------- - /** * Retrieve all of the keys for session data marked as flashdata. * - * @return array The property names of all flashdata + * @return array The property names of all flashdata */ public function getFlashKeys(): array { @@ -817,14 +793,16 @@ $keys = []; foreach (array_keys($_SESSION['__ci_vars']) as $key) { - is_int($_SESSION['__ci_vars'][$key]) || $keys[] = $key; + if (! is_int($_SESSION['__ci_vars'][$key])) + { + $keys[] = $key; + } } return $keys; } //-------------------------------------------------------------------- - //-------------------------------------------------------------------- // Temp Data Methods //-------------------------------------------------------------------- @@ -842,8 +820,6 @@ $this->markAsTempdata($data, $ttl); } - //-------------------------------------------------------------------- - /** * Returns either a single piece of tempdata, or all temp data currently * in the session. @@ -865,15 +841,16 @@ { foreach ($_SESSION['__ci_vars'] as $key => &$value) { - is_int($value) && $tempdata[$key] = $_SESSION[$key]; + if (is_int($value)) + { + $tempdata[$key] = $_SESSION[$key]; + } } } return $tempdata; } - //-------------------------------------------------------------------- - /** * Removes a single piece of temporary data from the session. * @@ -885,8 +862,6 @@ unset($_SESSION[$key]); } - //-------------------------------------------------------------------- - /** * Mark one of more pieces of data as being temporary, meaning that * it has a set lifespan within the session. @@ -894,7 +869,7 @@ * @param string|array $key Property identifier or array of them * @param integer $ttl Time to live, in seconds * - * @return boolean False if any of the properties were not set + * @return boolean False if any of the properties were not set */ public function markAsTempdata($key, int $ttl = 300): bool { @@ -944,8 +919,6 @@ return true; } - //-------------------------------------------------------------------- - /** * Unmarks temporary data in the session, effectively removing its * lifespan and allowing it to live as long as the session does. @@ -959,7 +932,10 @@ return; } - is_array($key) || $key = [$key]; // @phpstan-ignore-line + if (! is_array($key)) + { + $key = [$key]; + } foreach ($key as $k) { @@ -975,8 +951,6 @@ } } - //-------------------------------------------------------------------- - /** * Retrieve the keys of all session data that have been marked as temporary data. * @@ -992,14 +966,15 @@ $keys = []; foreach (array_keys($_SESSION['__ci_vars']) as $key) { - is_int($_SESSION['__ci_vars'][$key]) && $keys[] = $key; + if (is_int($_SESSION['__ci_vars'][$key])) + { + $keys[] = $key; + } } return $keys; } - //-------------------------------------------------------------------- - /** * Sets the driver as the session handler in PHP. * Extracted for easier testing. @@ -1009,8 +984,6 @@ session_set_save_handler($this->driver, true); } - //-------------------------------------------------------------------- - /** * Starts the session. * Extracted for testing reasons. @@ -1023,57 +996,19 @@ return; } - // @codeCoverageIgnoreStart - session_start(); - // @codeCoverageIgnoreEnd + session_start(); // @codeCoverageIgnore } - //-------------------------------------------------------------------- - /** * Takes care of setting the cookie on the client side. - * Extracted for testing reasons. + * + * @codeCoverageIgnore */ protected function setCookie() { - if (PHP_VERSION_ID < 70300) - { - $sameSite = ''; - if ($this->cookieSameSite !== '') - { - $sameSite = '; samesite=' . $this->cookieSameSite; - } + $expiration = $this->sessionExpiration === 0 ? 0 : time() + $this->sessionExpiration; + $this->cookie = $this->cookie->withValue(session_id())->withExpires($expiration); - setcookie( - $this->sessionCookieName, - session_id(), - empty($this->sessionExpiration) ? 0 : time() + $this->sessionExpiration, - $this->cookiePath . $sameSite, // Hacky way to set SameSite for PHP 7.2 and earlier - $this->cookieDomain, - $this->cookieSecure, - true - ); - } - else - { - // PHP 7.3 adds another function signature allowing setting of samesite - $params = [ - 'expires' => empty($this->sessionExpiration) ? 0 : time() + $this->sessionExpiration, - 'path' => $this->cookiePath, - 'domain' => $this->cookieDomain, - 'secure' => $this->cookieSecure, - 'httponly' => true, - ]; - - if ($this->cookieSameSite !== '') - { - $params['samesite'] = $this->cookieSameSite; - } - - // @phpstan-ignore-next-line @todo ignore to be removed in 4.1 with rector 0.9 - setcookie($this->sessionCookieName, session_id(), $params); - } + cookies([$this->cookie], false)->dispatch(); } - - //-------------------------------------------------------------------- } diff --git a/system/Test/CIDatabaseTestCase.php b/system/Test/CIDatabaseTestCase.php index 5d0ffe1..63ded76 100644 --- a/system/Test/CIDatabaseTestCase.php +++ b/system/Test/CIDatabaseTestCase.php @@ -11,449 +11,14 @@ namespace CodeIgniter\Test; -use CodeIgniter\Database\BaseConnection; -use CodeIgniter\Database\Exceptions\DatabaseException; -use CodeIgniter\Database\MigrationRunner; -use CodeIgniter\Database\Seeder; -use CodeIgniter\Exceptions\ConfigException; -use Config\Database; -use Config\Migrations; -use Config\Services; - /** * CIDatabaseTestCase + * + * Use DatabaseTestTrait instead. + * + * @deprecated 4.1.2 */ abstract class CIDatabaseTestCase extends CIUnitTestCase { - /** - * Should run db migration? - * - * @var boolean - */ - protected $migrate = true; - - /** - * Should run db migration only once? - * - * @var boolean - */ - protected $migrateOnce = false; - - /** - * Is db migration done once or more than once? - * - * @var boolean - */ - private static $doneMigration = false; - - /** - * Should run seeding only once? - * - * @var boolean - */ - protected $seedOnce = false; - - /** - * Is seeding done once or more than once? - * - * @var boolean - */ - private static $doneSeed = false; - - /** - * Should the db be refreshed before test? - * - * @var boolean - */ - protected $refresh = true; - - /** - * The seed file(s) used for all tests within this test case. - * Should be fully-namespaced or relative to $basePath - * - * @var string|array - */ - protected $seed = ''; - - /** - * The path to the seeds directory. - * Allows overriding the default application directories. - * - * @var string - */ - protected $basePath = SUPPORTPATH . 'Database'; - - /** - * The namespace(s) to help us find the migration classes. - * Empty is equivalent to running `spark migrate -all`. - * Note that running "all" runs migrations in date order, - * but specifying namespaces runs them in namespace order (then date) - * - * @var string|array|null - */ - protected $namespace = 'Tests\Support'; - - /** - * The name of the database group to connect to. - * If not present, will use the defaultGroup. - * - * @var string - */ - protected $DBGroup = 'tests'; - - /** - * Our database connection. - * - * @var BaseConnection - */ - protected $db; - - /** - * Migration Runner instance. - * - * @var MigrationRunner|mixed - */ - protected $migrations; - - /** - * Seeder instance - * - * @var Seeder - */ - protected $seeder; - - /** - * Stores information needed to remove any - * rows inserted via $this->hasInDatabase(); - * - * @var array - */ - protected $insertCache = []; - - //-------------------------------------------------------------------- - - /** - * Load any database test dependencies. - */ - public function loadDependencies() - { - if ($this->db === null) - { - $this->db = Database::connect($this->DBGroup); - $this->db->initialize(); - } - - if ($this->migrations === null) - { - // Ensure that we can run migrations - $config = new Migrations(); - $config->enabled = true; - - $this->migrations = Services::migrations($config, $this->db); - $this->migrations->setSilent(false); - } - - if ($this->seeder === null) - { - $this->seeder = Database::seeder($this->DBGroup); - $this->seeder->setSilent(true); - } - } - - //-------------------------------------------------------------------- - - /** - * Ensures that the database is cleaned up to a known state - * before each test runs. - * - * @throws ConfigException - */ - protected function setUp(): void - { - parent::setUp(); - - $this->loadDependencies(); - - $this->setUpMigrate(); - $this->setUpSeed(); - } - - //-------------------------------------------------------------------- - - /** - * Migrate on setUp - */ - protected function setUpMigrate() - { - if ($this->migrateOnce === false || self::$doneMigration === false) - { - if ($this->refresh === true) - { - $this->regressDatabase(); - - // Reset counts on faked items - Fabricator::resetCounts(); - } - - $this->migrateDatabase(); - } - } - - //-------------------------------------------------------------------- - - /** - * Seed on setUp - */ - protected function setUpSeed() - { - if ($this->seedOnce === false || self::$doneSeed === false) - { - $this->runSeeds(); - } - } - - //-------------------------------------------------------------------- - - /** - * Takes care of any required cleanup after the test, like - * removing any rows inserted via $this->hasInDatabase() - */ - protected function tearDown(): void - { - parent::tearDown(); - - if (! empty($this->insertCache)) - { - foreach ($this->insertCache as $row) - { - $this->db->table($row[0]) - ->where($row[1]) - ->delete(); - } - } - } - - //-------------------------------------------------------------------- - - /** - * Run seeds as defined by the class - */ - protected function runSeeds() - { - if (! empty($this->seed)) - { - if (! empty($this->basePath)) - { - $this->seeder->setPath(rtrim($this->basePath, '/') . '/Seeds'); - } - - $seeds = is_array($this->seed) ? $this->seed : [$this->seed]; - foreach ($seeds as $seed) - { - $this->seed($seed); - } - } - - self::$doneSeed = true; - } - - //-------------------------------------------------------------------- - - /** - * Regress migrations as defined by the class - */ - protected function regressDatabase() - { - if ($this->migrate === false) - { - return; - } - - // If no namespace was specified then rollback all - if (empty($this->namespace)) - { - $this->migrations->setNamespace(null); - $this->migrations->regress(0, 'tests'); - } - - // Regress each specified namespace - else - { - $namespaces = is_array($this->namespace) ? $this->namespace : [$this->namespace]; - - foreach ($namespaces as $namespace) - { - $this->migrations->setNamespace($namespace); - $this->migrations->regress(0, 'tests'); - } - } - } - - /** - * Run migrations as defined by the class - */ - protected function migrateDatabase() - { - if ($this->migrate === false) - { - return; - } - - // If no namespace was specified then migrate all - if (empty($this->namespace)) - { - $this->migrations->setNamespace(null); - $this->migrations->latest('tests'); - self::$doneMigration = true; - } - // Run migrations for each specified namespace - else - { - $namespaces = is_array($this->namespace) ? $this->namespace : [$this->namespace]; - - foreach ($namespaces as $namespace) - { - $this->migrations->setNamespace($namespace); - $this->migrations->latest('tests'); - self::$doneMigration = true; - } - } - } - - /** - * Seeds that database with a specific seeder. - * - * @param string $name - * - * @return void - */ - public function seed(string $name) - { - $this->seeder->call($name); - } - - //-------------------------------------------------------------------- - // Database Test Helpers - //-------------------------------------------------------------------- - - /** - * Asserts that records that match the conditions in $where do - * not exist in the database. - * - * @param string $table - * @param array $where - * - * @return void - */ - public function dontSeeInDatabase(string $table, array $where) - { - $count = $this->db->table($table) - ->where($where) - ->countAllResults(); - - $this->assertTrue($count === 0, 'Row was found in database'); - } - - //-------------------------------------------------------------------- - - /** - * Asserts that records that match the conditions in $where DO - * exist in the database. - * - * @param string $table - * @param array $where - * - * @return void - * @throws DatabaseException - */ - public function seeInDatabase(string $table, array $where) - { - $count = $this->db->table($table) - ->where($where) - ->countAllResults(); - - $this->assertTrue($count > 0, 'Row not found in database: ' . $this->db->showLastQuery()); - } - - //-------------------------------------------------------------------- - - /** - * Fetches a single column from a database row with criteria - * matching $where. - * - * @param string $table - * @param string $column - * @param array $where - * - * @return boolean - * @throws DatabaseException - */ - public function grabFromDatabase(string $table, string $column, array $where) - { - $query = $this->db->table($table) - ->select($column) - ->where($where) - ->get(); - - $query = $query->getRow(); - - return $query->$column ?? false; - } - - //-------------------------------------------------------------------- - - /** - * Inserts a row into to the database. This row will be removed - * after the test has run. - * - * @param string $table - * @param array $data - * - * @return boolean - */ - public function hasInDatabase(string $table, array $data) - { - $this->insertCache[] = [ - $table, - $data, - ]; - - return $this->db->table($table) - ->insert($data); - } - - //-------------------------------------------------------------------- - - /** - * Asserts that the number of rows in the database that match $where - * is equal to $expected. - * - * @param integer $expected - * @param string $table - * @param array $where - * - * @return void - * @throws DatabaseException - */ - public function seeNumRecords(int $expected, string $table, array $where) - { - $count = $this->db->table($table) - ->where($where) - ->countAllResults(); - - $this->assertEquals($expected, $count, 'Wrong number of matching rows in database.'); - } - - //-------------------------------------------------------------------- - - /** - * Reset $doneMigration and $doneSeed - * - * @afterClass - */ - public static function resetMigrationSeedCount() - { - self::$doneMigration = false; - self::$doneSeed = false; - } + use DatabaseTestTrait; } diff --git a/system/Test/CIUnitTestCase.php b/system/Test/CIUnitTestCase.php index 4fd49ca..95fea08 100644 --- a/system/Test/CIUnitTestCase.php +++ b/system/Test/CIUnitTestCase.php @@ -13,24 +13,31 @@ use CodeIgniter\CodeIgniter; use CodeIgniter\Config\Factories; +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Seeder; use CodeIgniter\Events\Events; +use CodeIgniter\Router\RouteCollection; use CodeIgniter\Session\Handlers\ArrayHandler; use CodeIgniter\Test\Mock\MockCache; +use CodeIgniter\Test\Mock\MockCodeIgniter; use CodeIgniter\Test\Mock\MockEmail; use CodeIgniter\Test\Mock\MockSession; +use Config\App; +use Config\Autoload; +use Config\Modules; use Config\Services; use Exception; use PHPUnit\Framework\TestCase; /** - * PHPunit test case. + * Framework test case for PHPUnit. */ abstract class CIUnitTestCase extends TestCase { use ReflectionHelper; /** - * @var \CodeIgniter\CodeIgniter + * @var CodeIgniter */ protected $app; @@ -53,6 +60,158 @@ */ protected $tearDownMethods = []; + /** + * Store of identified traits. + * + * @var string[]|null + */ + private $traits; + + //-------------------------------------------------------------------- + // Database Properties + //-------------------------------------------------------------------- + + /** + * Should run db migration? + * + * @var boolean + */ + protected $migrate = true; + + /** + * Should run db migration only once? + * + * @var boolean + */ + protected $migrateOnce = false; + + /** + * Should run seeding only once? + * + * @var boolean + */ + protected $seedOnce = false; + + /** + * Should the db be refreshed before test? + * + * @var boolean + */ + protected $refresh = true; + + /** + * The seed file(s) used for all tests within this test case. + * Should be fully-namespaced or relative to $basePath + * + * @var string|array + */ + protected $seed = ''; + + /** + * The path to the seeds directory. + * Allows overriding the default application directories. + * + * @var string + */ + protected $basePath = SUPPORTPATH . 'Database'; + + /** + * The namespace(s) to help us find the migration classes. + * Empty is equivalent to running `spark migrate -all`. + * Note that running "all" runs migrations in date order, + * but specifying namespaces runs them in namespace order (then date) + * + * @var string|array|null + */ + protected $namespace = 'Tests\Support'; + + /** + * The name of the database group to connect to. + * If not present, will use the defaultGroup. + * + * @var string + */ + protected $DBGroup = 'tests'; + + /** + * Our database connection. + * + * @var BaseConnection + */ + protected $db; + + /** + * Migration Runner instance. + * + * @var MigrationRunner|mixed + */ + protected $migrations; + + /** + * Seeder instance + * + * @var Seeder + */ + protected $seeder; + + /** + * Stores information needed to remove any + * rows inserted via $this->hasInDatabase(); + * + * @var array + */ + protected $insertCache = []; + + //-------------------------------------------------------------------- + // Feature Properties + //-------------------------------------------------------------------- + + /** + * If present, will override application + * routes when using call(). + * + * @var RouteCollection|null + */ + protected $routes; + + /** + * Values to be set in the SESSION global + * before running the test. + * + * @var array + */ + protected $session = []; + + /** + * Enabled auto clean op buffer after request call + * + * @var boolean + */ + protected $clean = true; + + /** + * Custom request's headers + * + * @var array + */ + protected $headers = []; + + /** + * Allows for formatting the request body to what + * the controller is going to expect + * + * @var string + */ + protected $bodyFormat = ''; + + /** + * Allows for directly setting the body to what + * it needs to be. + * + * @var mixed + */ + protected $requestBody = ''; + //-------------------------------------------------------------------- // Staging //-------------------------------------------------------------------- @@ -80,6 +239,15 @@ { $this->$method(); } + + // Check for the database trait + if (method_exists($this, 'setUpDatabase')) + { + $this->setUpDatabase(); + } + + // Check for other trait methods + $this->callTraitMethods('setUp'); } protected function tearDown(): void @@ -90,6 +258,41 @@ { $this->$method(); } + + // Check for the database trait + if (method_exists($this, 'tearDownDatabase')) + { + $this->tearDownDatabase(); + } + + // Check for other trait methods + $this->callTraitMethods('tearDown'); + } + + /** + * Checks for traits with corresponding + * methods for setUp or tearDown. + * + * @param string $stage 'setUp' or 'tearDown' + * + * @return void + */ + private function callTraitMethods(string $stage): void + { + if (is_null($this->traits)) + { + $this->traits = class_uses_recursive($this); + } + + foreach ($this->traits as $trait) + { + $method = $stage . class_basename($trait); + + if (method_exists($this, $method)) + { + $this->$method(); + } + } } //-------------------------------------------------------------------- @@ -159,7 +362,12 @@ { $result = TestLogger::didLog($level, $expectedMessage); - $this->assertTrue($result); + $this->assertTrue($result, sprintf( + 'Failed asserting that expected message "%s" with level "%s" was logged.', + $expectedMessage ?? '', + $level + )); + return $result; } @@ -326,9 +534,13 @@ */ protected function createApplication() { - $path = __DIR__ . '/../bootstrap.php'; - $path = realpath($path) ?: $path; - return require $path; + // Initialize the autoloader. + Services::autoloader()->initialize(new Autoload(), new Modules()); + + $app = new MockCodeIgniter(new App()); + $app->initialize(); + + return $app; } /** diff --git a/system/Test/Constraints/SeeInDatabase.php b/system/Test/Constraints/SeeInDatabase.php new file mode 100644 index 0000000..0647fb9 --- /dev/null +++ b/system/Test/Constraints/SeeInDatabase.php @@ -0,0 +1,126 @@ +db = $db; + $this->data = $data; + } + + /** + * Check if data is found in the table + * + * @param mixed $table + * + * @return boolean + */ + public function matches($table): bool + { + return $this->db->table($table)->where($this->data)->countAllResults() > 0; + } + + /** + * Get the description of the failure + * + * @param mixed $table + * + * @return string + */ + public function failureDescription($table): string + { + return sprintf( + "a row in the table [%s] matches the attributes \n%s\n\n%s", + $table, $this->toString(JSON_PRETTY_PRINT), $this->getAdditionalInfo($table) + ); + } + + /** + * Gets additional records similar to $data. + * + * @param string $table + * + * @return string + */ + protected function getAdditionalInfo(string $table): string + { + $builder = $this->db->table($table); + + $similar = $builder->where( + array_key_first($this->data), + $this->data[array_key_first($this->data)] + )->limit($this->show) + ->get()->getResultArray(); + + if ($similar !== []) + { + $description = 'Found similar results: ' . json_encode($similar, JSON_PRETTY_PRINT); + } + else + { + // Does the table have any results at all? + $results = $this->db->table($table) + ->limit($this->show) + ->get() + ->getResultArray(); + + if ($results !== []) + { + return 'The table is empty.'; + } + + $description = 'Found: ' . json_encode($results, JSON_PRETTY_PRINT); + } + + $total = $this->db->table($table)->countAll(); + if ($total > $this->show) + { + $description .= sprintf(' and %s others', $total - $this->show); + } + + return $description; + } + + /** + * Gets a string representation of the constraint + * + * @param integer $options + * + * @return string + */ + public function toString($options = 0): string + { + return json_encode($this->data, $options); + } +} diff --git a/system/Test/ControllerResponse.php b/system/Test/ControllerResponse.php index 2e7bd8d..8e7cea6 100644 --- a/system/Test/ControllerResponse.php +++ b/system/Test/ControllerResponse.php @@ -11,33 +11,24 @@ namespace CodeIgniter\Test; -use CodeIgniter\HTTP\RedirectResponse; -use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; +use Config\Services; /** * Testable response from a controller + * + * @deprecated Use TestResponse directly + * + * @codeCoverageIgnore */ -class ControllerResponse +class ControllerResponse extends TestResponse { /** - * The request. - * - * @var RequestInterface - */ - protected $request; - - /** - * The response. - * - * @var ResponseInterface - */ - protected $response; - - /** * The message payload. * * @var string + * + * @deprecated Use $response->getBody() instead */ protected $body; @@ -45,35 +36,55 @@ * DOM for the body. * * @var DOMParser + * + * @deprecated Use $domParser instead */ protected $dom; /** - * Constructor. + * Maintains the deprecated $dom property. */ public function __construct() { - $this->dom = new DOMParser(); + parent::__construct($response ?? Services::response()); + + $this->dom = &$this->domParser; } - //-------------------------------------------------------------------- - // Getters / Setters - //-------------------------------------------------------------------- + /** + * Sets the response. + * + * @param ResponseInterface $response + * + * @return $this + * + * @deprecated Will revert to parent::setResponse() in a future release (no $body updates) + */ + public function setResponse(ResponseInterface $response) + { + parent::setResponse($response); + + $this->body = $response->getBody() ?? ''; + + return $this; + } /** - * Set the body & DOM. + * Sets the body and updates the DOM. * * @param string $body * * @return $this + * + * @deprecated Use response()->setBody() instead */ public function setBody(string $body) { $this->body = $body; - if (! empty($body)) + if ($body !== '') { - $this->dom = $this->dom->withString($body); + $this->domParser->withString($body); } return $this; @@ -83,118 +94,11 @@ * Retrieve the body. * * @return string + * + * @deprecated Use response()->getBody() instead */ public function getBody() { return $this->body; } - - /** - * Set the request. - * - * @param RequestInterface $request - * - * @return $this - */ - public function setRequest(RequestInterface $request) - { - $this->request = $request; - - return $this; - } - - /** - * Set the response. - * - * @param ResponseInterface $response - * - * @return $this - */ - public function setResponse(ResponseInterface $response) - { - $this->response = $response; - - $this->setBody($response->getBody() ?? ''); - - return $this; - } - - /** - * Request accessor. - * - * @return RequestInterface - */ - public function request() - { - return $this->request; - } - - /** - * Response accessor. - * - * @return ResponseInterface - */ - public function response() - { - return $this->response; - } - - //-------------------------------------------------------------------- - // Simple Response Checks - //-------------------------------------------------------------------- - - /** - * Boils down the possible responses into a boolean valid/not-valid - * response type. - * - * @return boolean - */ - public function isOK(): bool - { - // Only 200 and 300 range status codes - // are considered valid. - if ($this->response->getStatusCode() >= 400 || $this->response->getStatusCode() < 200) - { - return false; - } - - // Empty bodies are not considered valid. - if (empty($this->response->getBody())) - { - return false; - } - - return true; - } - - /** - * Returns whether or not the Response was a redirect or RedirectResponse - * - * @return boolean - */ - public function isRedirect(): bool - { - return $this->response instanceof RedirectResponse - || $this->response->hasHeader('Location') - || $this->response->hasHeader('Refresh'); - } - - //-------------------------------------------------------------------- - // Utility - //-------------------------------------------------------------------- - - /** - * Forward any unrecognized method calls to our DOMParser instance. - * - * @param string $function Method name - * @param mixed $params Any method parameters - * @return mixed - */ - public function __call($function, $params) - { - if (method_exists($this->dom, $function)) - { - return $this->dom->{$function}(...$params); - } - } } diff --git a/system/Test/ControllerTestTrait.php b/system/Test/ControllerTestTrait.php new file mode 100644 index 0000000..83a520e --- /dev/null +++ b/system/Test/ControllerTestTrait.php @@ -0,0 +1,325 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Test; + +use CodeIgniter\Controller; +use CodeIgniter\HTTP\Exceptions\HTTPException; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\Response; +use CodeIgniter\HTTP\URI; +use Config\App; +use Config\Services; +use InvalidArgumentException; +use Throwable; + +/** + * Controller Test Trait + * + * Provides features that make testing controllers simple and fluent. + * + * Example: + * + * $this->withRequest($request) + * ->withResponse($response) + * ->withURI($uri) + * ->withBody($body) + * ->controller('App\Controllers\Home') + * ->execute('methodName'); + */ +trait ControllerTestTrait +{ + /** + * Controller configuration. + * + * @var App + */ + protected $appConfig; + + /** + * Request. + * + * @var IncomingRequest + */ + protected $request; + + /** + * Response. + * + * @var Response + */ + protected $response; + + /** + * Message logger. + * + * @var LoggerInterface + */ + protected $logger; + + /** + * Initialized controller. + * + * @var Controller + */ + protected $controller; + + /** + * URI of this request. + * + * @var string + */ + protected $uri = 'http://example.com'; + + /** + * Request body. + * + * @var string|null + */ + protected $body; + + /** + * Initializes required components. + */ + protected function setUpControllerTestTrait(): void + { + // The URL helper is always loaded by the system so ensure it is available. + helper('url'); + + if (empty($this->appConfig)) + { + $this->appConfig = config('App'); + } + + if (! $this->uri instanceof URI) + { + $this->uri = Services::uri($this->appConfig->baseURL ?? 'http://example.com/', false); + } + + if (empty($this->request)) + { + // Do some acrobatics so we can use the Request service with our own URI + $tempUri = Services::uri(); + Services::injectMock('uri', $this->uri); + + $this->withRequest(Services::request($this->appConfig, false)->setBody($this->body)); + + // Restore the URI service + Services::injectMock('uri', $tempUri); + } + + if (empty($this->response)) + { + $this->response = Services::response($this->appConfig, false); + } + + if (empty($this->logger)) + { + $this->logger = Services::logger(); + } + } + + /** + * Loads the specified controller, and generates any needed dependencies. + * + * @param string $name + * + * @return mixed + */ + public function controller(string $name) + { + if (! class_exists($name)) + { + throw new InvalidArgumentException('Invalid Controller: ' . $name); + } + + $this->controller = new $name(); + $this->controller->initController($this->request, $this->response, $this->logger); + + return $this; + } + + /** + * Runs the specified method on the controller and returns the results. + * + * @param string $method + * @param array $params + * + * @throws InvalidArgumentException + * + * @return TestResponse + */ + public function execute(string $method, ...$params) + { + if (! method_exists($this->controller, $method) || ! is_callable([$this->controller, $method])) + { + throw new InvalidArgumentException('Method does not exist or is not callable in controller: ' . $method); + } + + $response = null; + + try + { + ob_start(); + $response = $this->controller->{$method}(...$params); + } + catch (Throwable $e) + { + $code = $e->getCode(); + + // If code is not a valid HTTP status then assume there is an error + if ($code < 100 || $code >= 600) + { + throw $e; + } + } + finally + { + $output = ob_get_clean(); + } + + // If the controller returned a view then add it to the output + if (is_string($response)) + { + $output = is_string($output) ? $output . $response : $response; + } + + // If the controller did not return a response then start one + if (! $response instanceof Response) + { + $response = $this->response; + } + + // Check for output to set or prepend + // @see \CodeIgniter\CodeIgniter::gatherOutput() + if (is_string($output)) + { + if (is_string($response->getBody())) + { + $response->setBody($output . $response->getBody()); + } + else + { + $response->setBody($output); + } + } + + // Check for an overriding code from exceptions + if (isset($code)) + { + $response->setStatusCode($code); + } + // Otherwise ensure there is a status code + else + { + // getStatusCode() throws for empty codes + try + { + $response->getStatusCode(); + } + catch (HTTPException $e) + { + // If no code has been set then assume success + $response->setStatusCode(200); + } + } + + // Create the result and add the Request for reference + return (new TestResponse($response))->setRequest($this->request); + } + + /** + * Set controller's config, with method chaining. + * + * @param mixed $appConfig + * + * @return mixed + */ + public function withConfig($appConfig) + { + $this->appConfig = $appConfig; + + return $this; + } + + /** + * Set controller's request, with method chaining. + * + * @param mixed $request + * + * @return mixed + */ + public function withRequest($request) + { + $this->request = $request; + + // Make sure it's available for other classes + Services::injectMock('request', $request); + + return $this; + } + + /** + * Set controller's response, with method chaining. + * + * @param mixed $response + * + * @return mixed + */ + public function withResponse($response) + { + $this->response = $response; + + return $this; + } + + /** + * Set controller's logger, with method chaining. + * + * @param mixed $logger + * + * @return mixed + */ + public function withLogger($logger) + { + $this->logger = $logger; + + return $this; + } + + /** + * Set the controller's URI, with method chaining. + * + * @param string $uri + * + * @return mixed + */ + public function withUri(string $uri) + { + $this->uri = new URI($uri); + + return $this; + } + + /** + * Set the method's body, with method chaining. + * + * @param string|null $body + * + * @return mixed + */ + public function withBody($body) + { + $this->body = $body; + + return $this; + } +} diff --git a/system/Test/ControllerTester.php b/system/Test/ControllerTester.php index 8a53a4c..078d260 100644 --- a/system/Test/ControllerTester.php +++ b/system/Test/ControllerTester.php @@ -11,10 +11,10 @@ namespace CodeIgniter\Test; +use CodeIgniter\Controller; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\URI; -use CodeIgniter\HTTP\UserAgent; use Config\App; use Config\Services; use InvalidArgumentException; @@ -32,21 +32,25 @@ * ->withURI($uri) * ->withBody($body) * ->controller('App\Controllers\Home') - * ->run('methodName'); + * ->execute('methodName'); + * + * @deprecated Use ControllerTestTrait instead + * + * @codeCoverageIgnore */ trait ControllerTester { /** * Controller configuration. * - * @var BaseConfig + * @var App */ protected $appConfig; /** * Request. * - * @var Request + * @var IncomingRequest */ protected $request; @@ -81,11 +85,49 @@ /** * Request or response body. * - * @var string + * @var string|null */ protected $body; /** + * Initializes required components. + */ + protected function setUpControllerTester(): void + { + if (empty($this->appConfig)) + { + $this->appConfig = config('App'); + } + + if (! $this->uri instanceof URI) + { + $this->uri = Services::uri($this->appConfig->baseURL ?? 'http://example.com/', false); + } + + if (empty($this->request)) + { + // Do some acrobatics so we can use the Request service with our own URI + $tempUri = Services::uri(); + Services::injectMock('uri', $this->uri); + + $this->withRequest(Services::request($this->appConfig, false)->setBody($this->body)); + + // Restore the URI service + Services::injectMock('uri', $tempUri); + } + + if (empty($this->response)) + { + $this->response = Services::response($this->appConfig, false); + } + + if (empty($this->logger)) + { + $this->logger = Services::logger(); + } + } + + /** * Loads the specified controller, and generates any needed dependencies. * * @param string $name @@ -99,31 +141,6 @@ throw new InvalidArgumentException('Invalid Controller: ' . $name); } - if (empty($this->appConfig)) - { - $this->appConfig = new App(); - } - - if (! $this->uri instanceof URI) - { - $this->uri = new URI($this->appConfig->baseURL ?? 'http://example.com/'); - } - - if (empty($this->request)) - { - $this->request = new IncomingRequest($this->appConfig, $this->uri, $this->body, new UserAgent()); - } - - if (empty($this->response)) - { - $this->response = new Response($this->appConfig); - } - - if (empty($this->logger)) - { - $this->logger = Services::logger(); - } - $this->controller = new $name(); $this->controller->initController($this->request, $this->response, $this->logger); @@ -164,7 +181,15 @@ } catch (Throwable $e) { - $result->response()->setStatusCode($e->getCode()); + $code = $e->getCode(); + + // If code is not a valid HTTP status then assume there is an error + if ($code < 100 || $code >= 600) + { + throw $e; + } + + $result->response()->setStatusCode($code); } finally { @@ -278,7 +303,7 @@ /** * Set the method's body, with method chaining. * - * @param mixed $body + * @param string|null $body * * @return mixed */ diff --git a/system/Test/DatabaseTestTrait.php b/system/Test/DatabaseTestTrait.php new file mode 100644 index 0000000..0d2949a --- /dev/null +++ b/system/Test/DatabaseTestTrait.php @@ -0,0 +1,376 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Test; + +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Database\MigrationRunner; +use CodeIgniter\Database\Seeder; +use CodeIgniter\Test\Constraints\SeeInDatabase; +use Config\Database; +use Config\Migrations; +use Config\Services; + +/** + * DatabaseTestTrait + * + * Provides functionality for refreshing/seeding + * the database during testing. + * + * @mixin CIUnitTestCase + */ +trait DatabaseTestTrait +{ + /** + * Is db migration done once or more than once? + * + * @var boolean + */ + private static $doneMigration = false; + + /** + * Is seeding done once or more than once? + * + * @var boolean + */ + private static $doneSeed = false; + + //-------------------------------------------------------------------- + // Staging + //-------------------------------------------------------------------- + + /** + * Runs the trait set up methods. + */ + protected function setUpDatabase() + { + $this->loadDependencies(); + $this->setUpMigrate(); + $this->setUpSeed(); + } + + /** + * Runs the trait set up methods. + */ + protected function tearDownDatabase() + { + $this->clearInsertCache(); + } + + /** + * Load any database test dependencies. + */ + public function loadDependencies() + { + if ($this->db === null) + { + $this->db = Database::connect($this->DBGroup); + $this->db->initialize(); + } + + if ($this->migrations === null) + { + // Ensure that we can run migrations + $config = new Migrations(); + $config->enabled = true; + + $this->migrations = Services::migrations($config, $this->db); + $this->migrations->setSilent(false); + } + + if ($this->seeder === null) + { + $this->seeder = Database::seeder($this->DBGroup); + $this->seeder->setSilent(true); + } + } + + //-------------------------------------------------------------------- + // Migrations + //-------------------------------------------------------------------- + + /** + * Migrate on setUp + */ + protected function setUpMigrate() + { + if ($this->migrateOnce === false || self::$doneMigration === false) + { + if ($this->refresh === true) + { + $this->regressDatabase(); + + // Reset counts on faked items + Fabricator::resetCounts(); + } + + $this->migrateDatabase(); + } + } + + /** + * Regress migrations as defined by the class + */ + protected function regressDatabase() + { + if ($this->migrate === false) + { + return; + } + + // If no namespace was specified then rollback all + if (empty($this->namespace)) + { + $this->migrations->setNamespace(null); + $this->migrations->regress(0, 'tests'); + } + + // Regress each specified namespace + else + { + $namespaces = is_array($this->namespace) ? $this->namespace : [$this->namespace]; + + foreach ($namespaces as $namespace) + { + $this->migrations->setNamespace($namespace); + $this->migrations->regress(0, 'tests'); + } + } + } + + /** + * Run migrations as defined by the class + */ + protected function migrateDatabase() + { + if ($this->migrate === false) + { + return; + } + + // If no namespace was specified then migrate all + if (empty($this->namespace)) + { + $this->migrations->setNamespace(null); + $this->migrations->latest('tests'); + self::$doneMigration = true; + } + // Run migrations for each specified namespace + else + { + $namespaces = is_array($this->namespace) ? $this->namespace : [$this->namespace]; + + foreach ($namespaces as $namespace) + { + $this->migrations->setNamespace($namespace); + $this->migrations->latest('tests'); + self::$doneMigration = true; + } + } + } + + //-------------------------------------------------------------------- + // Seeds + //-------------------------------------------------------------------- + + /** + * Seed on setUp + */ + protected function setUpSeed() + { + if ($this->seedOnce === false || self::$doneSeed === false) + { + $this->runSeeds(); + } + } + + /** + * Run seeds as defined by the class + */ + protected function runSeeds() + { + if (! empty($this->seed)) + { + if (! empty($this->basePath)) + { + $this->seeder->setPath(rtrim($this->basePath, '/') . '/Seeds'); + } + + $seeds = is_array($this->seed) ? $this->seed : [$this->seed]; + foreach ($seeds as $seed) + { + $this->seed($seed); + } + } + + self::$doneSeed = true; + } + + /** + * Seeds that database with a specific seeder. + * + * @param string $name + * + * @return void + */ + public function seed(string $name) + { + $this->seeder->call($name); + } + + //-------------------------------------------------------------------- + // Utility + //-------------------------------------------------------------------- + + /** + * Reset $doneMigration and $doneSeed + * + * @afterClass + */ + public static function resetMigrationSeedCount() + { + self::$doneMigration = false; + self::$doneSeed = false; + } + + /** + * Removes any rows inserted via $this->hasInDatabase() + */ + protected function clearInsertCache() + { + if (! empty($this->insertCache)) + { + foreach ($this->insertCache as $row) + { + $this->db->table($row[0]) + ->where($row[1]) + ->delete(); + } + } + } + + /** + * Loads the Builder class appropriate for the current database. + * + * @param string $tableName + * + * @return BaseBuilder + */ + public function loadBuilder(string $tableName) + { + $builderClass = str_replace('Connection', 'Builder', get_class($this->db)); + + return new $builderClass($tableName, $this->db); + } + + /** + * Fetches a single column from a database row with criteria + * matching $where. + * + * @param string $table + * @param string $column + * @param array $where + * + * @return boolean + * @throws DatabaseException + */ + public function grabFromDatabase(string $table, string $column, array $where) + { + $query = $this->db->table($table) + ->select($column) + ->where($where) + ->get(); + + $query = $query->getRow(); + + return $query->$column ?? false; + } + + //-------------------------------------------------------------------- + // Assertions + //-------------------------------------------------------------------- + + /** + * Asserts that records that match the conditions in $where DO + * exist in the database. + * + * @param string $table + * @param array $where + * + * @return void + * @throws DatabaseException + */ + public function seeInDatabase(string $table, array $where) + { + $constraint = new SeeInDatabase($this->db, $where); + static::assertThat($table, $constraint); + } + + /** + * Asserts that records that match the conditions in $where do + * not exist in the database. + * + * @param string $table + * @param array $where + * + * @return void + */ + public function dontSeeInDatabase(string $table, array $where) + { + $count = $this->db->table($table) + ->where($where) + ->countAllResults(); + + $this->assertTrue($count === 0, 'Row was found in database'); + } + + /** + * Inserts a row into to the database. This row will be removed + * after the test has run. + * + * @param string $table + * @param array $data + * + * @return boolean + */ + public function hasInDatabase(string $table, array $data) + { + $this->insertCache[] = [ + $table, + $data, + ]; + + return $this->db->table($table) + ->insert($data); + } + + /** + * Asserts that the number of rows in the database that match $where + * is equal to $expected. + * + * @param integer $expected + * @param string $table + * @param array $where + * + * @return void + * @throws DatabaseException + */ + public function seeNumRecords(int $expected, string $table, array $where) + { + $count = $this->db->table($table) + ->where($where) + ->countAllResults(); + + $this->assertEquals($expected, $count, 'Wrong number of matching rows in database.'); + } +} diff --git a/system/Test/Fabricator.php b/system/Test/Fabricator.php index c37edd8..ef2294e 100644 --- a/system/Test/Fabricator.php +++ b/system/Test/Fabricator.php @@ -351,7 +351,6 @@ switch ($this->model->dateFormat) { case 'datetime': - return 'date'; case 'date': return 'date'; case 'int': @@ -430,24 +429,17 @@ $result[$field] = $this->faker->{$formatter}; } } - // If no formatters were defined then look for a model fake() method elseif (method_exists($this->model, 'fake')) { $result = $this->model->fake($this->faker); - // This should cover entities - if (method_exists($result, 'toArray')) - { - $result = $result->toArray(); - } - // Try to cast it - else - { - $result = (array) $result; - } + $result = is_object($result) && method_exists($result, 'toArray') + // This should cover entities + ? $result->toArray() + // Try to cast it + : (array) $result; } - // Nothing left to do but give up else { diff --git a/system/Test/FeatureResponse.php b/system/Test/FeatureResponse.php index f6981d0..488e114 100644 --- a/system/Test/FeatureResponse.php +++ b/system/Test/FeatureResponse.php @@ -11,411 +11,15 @@ namespace CodeIgniter\Test; -use CodeIgniter\HTTP\RedirectResponse; -use CodeIgniter\HTTP\ResponseInterface; -use Config\Services; -use Exception; -use PHPUnit\Framework\TestCase; - /** * Assertions for a response + * + * @deprecated Use TestResponse directly */ -class FeatureResponse extends TestCase +class FeatureResponse extends TestResponse { /** - * The response. - * - * @var \CodeIgniter\HTTP\ResponseInterface + * @deprecated Will be protected in a future release, use response() instead */ public $response; - - /** - * DOM for the body. - * - * @var DOMParser - */ - protected $domParser; - - /** - * Constructor. - * - * @param ResponseInterface $response - */ - public function __construct(ResponseInterface $response = null) - { - $this->response = $response; - - $body = $response->getBody(); - if (! empty($body) && is_string($body)) - { - $this->domParser = (new DOMParser())->withString($body); - } - } - - //-------------------------------------------------------------------- - // Simple Response Checks - //-------------------------------------------------------------------- - - /** - * Boils down the possible responses into a bolean valid/not-valid - * response type. - * - * @return boolean - */ - public function isOK(): bool - { - $status = $this->response->getStatusCode(); - - // Only 200 and 300 range status codes - // are considered valid. - if ($status >= 400 || $status < 200) - { - return false; - } - - // Empty bodies are not considered valid, unless in redirects - if ($status < 300 && empty($this->response->getBody())) - { - return false; - } - - return true; - } - - /** - * Returns whether or not the Response was a redirect or RedirectResponse - * - * @return boolean - */ - public function isRedirect(): bool - { - return $this->response instanceof RedirectResponse - || $this->response->hasHeader('Location') - || $this->response->hasHeader('Refresh'); - } - - /** - * Assert that the given response was a redirect. - * - * @throws Exception - */ - public function assertRedirect() - { - $this->assertTrue($this->isRedirect(), 'Response is not a redirect or RedirectResponse.'); - } - - /** - * Returns the URL set for redirection. - * - * @return string|null - */ - public function getRedirectUrl(): ?string - { - if (! $this->isRedirect()) - { - return null; - } - - if ($this->response->hasHeader('Location')) - { - return $this->response->getHeaderLine('Location'); - } - - if ($this->response->hasHeader('Refresh')) - { - return str_replace('0;url=', '', $this->response->getHeaderLine('Refresh')); - } - - return null; - } - - /** - * Asserts that the status is a specific value. - * - * @param integer $code - * - * @throws Exception - */ - public function assertStatus(int $code) - { - $this->assertEquals($code, $this->response->getStatusCode()); - } - - /** - * Asserts that the Response is considered OK. - * - * @throws Exception - */ - public function assertOK() - { - $this->assertTrue($this->isOK(), "{$this->response->getStatusCode()} is not a successful status code, or the Response has an empty body."); - } - - //-------------------------------------------------------------------- - // Session Assertions - //-------------------------------------------------------------------- - - /** - * Asserts that an SESSION key has been set and, optionally, test it's value. - * - * @param string $key - * @param string|null $value - * - * @throws Exception - */ - public function assertSessionHas(string $key, $value = null) - { - $this->assertTrue(array_key_exists($key, $_SESSION), "'{$key}' is not in the current \$_SESSION"); - - if ($value !== null) - { - $this->assertEquals($value, $_SESSION[$key], "The value of '{$key}' ({$value}) does not match expected value."); - } - } - - /** - * Asserts the session is missing $key. - * - * @param string $key - * - * @throws Exception - */ - public function assertSessionMissing(string $key) - { - $this->assertFalse(array_key_exists($key, $_SESSION), "'{$key}' should not be present in \$_SESSION."); - } - - //-------------------------------------------------------------------- - // Header Assertions - //-------------------------------------------------------------------- - - /** - * Asserts that the Response contains a specific header. - * - * @param string $key - * @param string|null $value - * - * @throws Exception - */ - public function assertHeader(string $key, $value = null) - { - $this->assertTrue($this->response->hasHeader($key), "'{$key}' is not a valid Response header."); - - if ($value !== null) - { - $this->assertEquals($value, $this->response->getHeaderLine($key), "The value of '{$key}' header ({$this->response->getHeaderLine($key)}) does not match expected value."); - } - } - - /** - * Asserts the Response headers does not contain the specified header. - * - * @param string $key - * - * @throws Exception - */ - public function assertHeaderMissing(string $key) - { - $this->assertFalse($this->response->hasHeader($key), "'{$key}' should not be in the Response headers."); - } - - //-------------------------------------------------------------------- - // Cookie Assertions - //-------------------------------------------------------------------- - - /** - * Asserts that the response has the specified cookie. - * - * @param string $key - * @param string|null $value - * @param string $prefix - * - * @throws Exception - */ - public function assertCookie(string $key, $value = null, string $prefix = '') - { - $this->assertTrue($this->response->hasCookie($key, $value, $prefix), "No cookie found named '{$key}'."); - } - - /** - * Assert the Response does not have the specified cookie set. - * - * @param string $key - */ - public function assertCookieMissing(string $key) - { - $this->assertFalse($this->response->hasCookie($key), "Cookie named '{$key}' should not be set."); - } - - /** - * Asserts that a cookie exists and has an expired time. - * - * @param string $key - * @param string $prefix - * - * @throws Exception - */ - public function assertCookieExpired(string $key, string $prefix = '') - { - $this->assertTrue($this->response->hasCookie($key, null, $prefix)); - $this->assertGreaterThan(time(), $this->response->getCookie($key, $prefix)['expires']); - } - - //-------------------------------------------------------------------- - // DomParser Assertions - //-------------------------------------------------------------------- - - /** - * Assert that the desired text can be found in the result body. - * - * @param string|null $search - * @param string|null $element - * - * @throws Exception - */ - public function assertSee(string $search = null, string $element = null) - { - $this->assertTrue($this->domParser->see($search, $element), "Do not see '{$search}' in response."); - } - - /** - * Asserts that we do not see the specified text. - * - * @param string|null $search - * @param string|null $element - * - * @throws Exception - */ - public function assertDontSee(string $search = null, string $element = null) - { - $this->assertTrue($this->domParser->dontSee($search, $element), "I should not see '{$search}' in response."); - } - - /** - * Assert that we see an element selected via a CSS selector. - * - * @param string $search - * - * @throws Exception - */ - public function assertSeeElement(string $search) - { - $this->assertTrue($this->domParser->seeElement($search), "Do not see element with selector '{$search} in response.'"); - } - - /** - * Assert that we do not see an element selected via a CSS selector. - * - * @param string $search - * - * @throws Exception - */ - public function assertDontSeeElement(string $search) - { - $this->assertTrue($this->domParser->dontSeeElement($search), "I should not see an element with selector '{$search}' in response.'"); - } - - /** - * Assert that we see a link with the matching text and/or class. - * - * @param string $text - * @param string|null $details - * - * @throws Exception - */ - public function assertSeeLink(string $text, string $details = null) - { - $this->assertTrue($this->domParser->seeLink($text, $details), "Do no see anchor tag with the text {$text} in response."); - } - - /** - * Assert that we see an input with name/value. - * - * @param string $field - * @param string|null $value - * - * @throws Exception - */ - public function assertSeeInField(string $field, string $value = null) - { - $this->assertTrue($this->domParser->seeInField($field, $value), "Do no see input named {$field} with value {$value} in response."); - } - - //-------------------------------------------------------------------- - // JSON Methods - //-------------------------------------------------------------------- - - /** - * Returns the response's body as JSON - * - * @return mixed|false - */ - public function getJSON() - { - $response = $this->response->getJSON(); - - if (is_null($response)) - { - return false; - } - - return $response; - } - - /** - * Test that the response contains a matching JSON fragment. - * - * @param array $fragment - * @param boolean $strict - * - * @throws Exception - */ - public function assertJSONFragment(array $fragment, bool $strict = false) - { - $json = json_decode($this->getJSON(), true); - $patched = array_replace_recursive($json, $fragment); - - if ($strict) - { - $this->assertSame($json, $patched, 'Response does not contain a matching JSON fragment.'); - } - else - { - $this->assertEquals($json, $patched, 'Response does not contain a matching JSON fragment.'); - } - } - - /** - * Asserts that the JSON exactly matches the passed in data. - * If the value being passed in is a string, it must be a json_encoded string. - * - * @param string|array $test - * - * @throws Exception - */ - public function assertJSONExact($test) - { - $json = $this->getJSON(); - - if (is_array($test)) - { - $test = Services::format()->getFormatter('application/json')->format($test); - } - - $this->assertJsonStringEqualsJsonString($test, $json, 'Response does not contain matching JSON.'); - } - - //-------------------------------------------------------------------- - // XML Methods - //-------------------------------------------------------------------- - - /** - * Returns the response' body as XML - * - * @return mixed|string - */ - public function getXML() - { - return $this->response->getXML(); - } } diff --git a/system/Test/FeatureTestCase.php b/system/Test/FeatureTestCase.php index c5515f6..d1f648c 100644 --- a/system/Test/FeatureTestCase.php +++ b/system/Test/FeatureTestCase.php @@ -11,61 +11,429 @@ namespace CodeIgniter\Test; +use CodeIgniter\Events\Events; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\Request; +use CodeIgniter\HTTP\URI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Router\Exceptions\RedirectException; use CodeIgniter\Router\RouteCollection; +use Config\App; +use Config\Services; +use Exception; +use ReflectionException; /** * Class FeatureTestCase * * Provides a base class with the trait for doing full HTTP testing * against your application. + * + * @deprecated Use FeatureTestTrait instead + * + * @codeCoverageIgnore */ -class FeatureTestCase extends CIDatabaseTestCase +class FeatureTestCase extends CIUnitTestCase { - use FeatureTestTrait; + use DatabaseTestTrait; /** - * If present, will override application - * routes when using call(). + * Sets a RouteCollection that will override + * the application's route collection. * - * @var RouteCollection + * Example routes: + * [ + * ['get', 'home', 'Home::index'] + * ] + * + * @param array $routes + * + * @return $this */ - protected $routes; + protected function withRoutes(array $routes = null) + { + $collection = Services::routes(); + + if ($routes) + { + $collection->resetRoutes(); + foreach ($routes as $route) + { + $collection->{$route[0]}($route[1], $route[2]); + } + } + + $this->routes = $collection; + + return $this; + } /** - * Values to be set in the SESSION global - * before running the test. + * Sets any values that should exist during this session. * - * @var array + * @param array|null $values Array of values, or null to use the current $_SESSION + * + * @return $this */ - protected $session = []; + public function withSession(array $values = null) + { + $this->session = is_null($values) ? $_SESSION : $values; + + return $this; + } /** - * Enabled auto clean op buffer after request call + * Set request's headers * - * @var boolean + * Example of use + * withHeaders([ + * 'Authorization' => 'Token' + * ]) + * + * @param array $headers Array of headers + * + * @return $this */ - protected $clean = true; + public function withHeaders(array $headers = []) + { + $this->headers = $headers; + + return $this; + } /** - * Custom request's headers + * Set the format the request's body should have. * - * @var array + * @param string $format The desired format. Currently supported formats: xml, json + * @return $this */ - protected $headers = []; + public function withBodyFormat(string $format) + { + $this->bodyFormat = $format; + + return $this; + } /** - * Allows for formatting the request body to what - * the controller is going to expect + * Set the raw body for the request * - * @var string + * @param mixed $body + * @return $this */ - protected $bodyFormat = ''; + public function withBody($body) + { + $this->requestBody = $body; + + return $this; + } /** - * Allows for directly setting the body to what - * it needs to be. + * Don't run any events while running this test. * - * @var mixed + * @return $this */ - protected $requestBody = ''; + public function skipEvents() + { + Events::simulate(true); + + return $this; + } + + /** + * Calls a single URI, executes it, and returns a FeatureResponse + * instance that can be used to run many assertions against. + * + * @param string $method + * @param string $path + * @param array|null $params + * + * @return FeatureResponse + * @throws RedirectException + * @throws Exception + */ + public function call(string $method, string $path, array $params = null) + { + $buffer = \ob_get_level(); + + // Clean up any open output buffers + // not relevant to unit testing + // @codeCoverageIgnoreStart + if (\ob_get_level() > 0 && (! isset($this->clean) || $this->clean === true)) + { + \ob_end_clean(); + } + // @codeCoverageIgnoreEnd + + // Simulate having a blank session + $_SESSION = []; + $_SERVER['REQUEST_METHOD'] = $method; + + $request = $this->setupRequest($method, $path); + $request = $this->setupHeaders($request); + $request = $this->populateGlobals($method, $request, $params); + $request = $this->setRequestBody($request); + + // Initialize the RouteCollection + if (! $routes = $this->routes) + { + require APPPATH . 'Config/Routes.php'; + + /** + * @var RouteCollection $routes + */ + $routes->getRoutes('*'); + } + + $routes->setHTTPVerb($method); + + // Make sure any other classes that might call the request + // instance get the right one. + Services::injectMock('request', $request); + + // Make sure filters are reset between tests + Services::injectMock('filters', Services::filters(null, false)); + + $response = $this->app + ->setRequest($request) + ->run($routes, true); + + $output = \ob_get_contents(); + if (empty($response->getBody()) && ! empty($output)) + { + $response->setBody($output); + } + + // Reset directory if it has been set + Services::router()->setDirectory(null); + + // Ensure the output buffer is identical so no tests are risky + // @codeCoverageIgnoreStart + while (\ob_get_level() > $buffer) + { + \ob_end_clean(); + } + while (\ob_get_level() < $buffer) + { + \ob_start(); + } + // @codeCoverageIgnoreEnd + + return new FeatureResponse($response); + } + + /** + * Performs a GET request. + * + * @param string $path + * @param array|null $params + * + * @return FeatureResponse + * @throws RedirectException + * @throws Exception + */ + public function get(string $path, array $params = null) + { + return $this->call('get', $path, $params); + } + + /** + * Performs a POST request. + * + * @param string $path + * @param array|null $params + * + * @return FeatureResponse + * @throws RedirectException + * @throws Exception + */ + public function post(string $path, array $params = null) + { + return $this->call('post', $path, $params); + } + + /** + * Performs a PUT request + * + * @param string $path + * @param array|null $params + * + * @return FeatureResponse + * @throws RedirectException + * @throws Exception + */ + public function put(string $path, array $params = null) + { + return $this->call('put', $path, $params); + } + + /** + * Performss a PATCH request + * + * @param string $path + * @param array|null $params + * + * @return FeatureResponse + * @throws RedirectException + * @throws Exception + */ + public function patch(string $path, array $params = null) + { + return $this->call('patch', $path, $params); + } + + /** + * Performs a DELETE request. + * + * @param string $path + * @param array|null $params + * + * @return FeatureResponse + * @throws RedirectException + * @throws Exception + */ + public function delete(string $path, array $params = null) + { + return $this->call('delete', $path, $params); + } + + /** + * Performs an OPTIONS request. + * + * @param string $path + * @param array|null $params + * + * @return FeatureResponse + * @throws RedirectException + * @throws Exception + */ + public function options(string $path, array $params = null) + { + return $this->call('options', $path, $params); + } + + /** + * Setup a Request object to use so that CodeIgniter + * won't try to auto-populate some of the items. + * + * @param string $method + * @param string|null $path + * + * @return IncomingRequest + */ + protected function setupRequest(string $method, string $path = null): IncomingRequest + { + $config = config(App::class); + $uri = new URI(rtrim($config->baseURL, '/') . '/' . trim($path, '/ ')); + + $request = new IncomingRequest($config, clone($uri), null, new UserAgent()); + $request->uri = $uri; + + $request->setMethod($method); + $request->setProtocolVersion('1.1'); + + if ($config->forceGlobalSecureRequests) + { + $_SERVER['HTTPS'] = 'test'; + } + + return $request; + } + + /** + * Setup the custom request's headers + * + * @param IncomingRequest $request + * + * @return IncomingRequest + */ + protected function setupHeaders(IncomingRequest $request) + { + if (! empty($this->headers)) + { + foreach ($this->headers as $name => $value) + { + $request->setHeader($name, $value); + } + } + + return $request; + } + + /** + * Populates the data of our Request with "global" data + * relevant to the request, like $_POST data. + * + * Always populate the GET vars based on the URI. + * + * @param string $method + * @param Request $request + * @param array|null $params + * + * @return Request + * @throws ReflectionException + */ + protected function populateGlobals(string $method, Request $request, array $params = null) + { + // $params should set the query vars if present, + // otherwise set it from the URL. + $get = ! empty($params) && $method === 'get' + ? $params + : $this->getPrivateProperty($request->uri, 'query'); // @phpstan-ignore-line + + $request->setGlobal('get', $get); + if ($method !== 'get') + { + $request->setGlobal($method, $params); + } + + $request->setGlobal('request', $params); + + $_SESSION = $this->session ?? []; + + return $request; + } + + /** + * Set the request's body formatted according to the value in $this->bodyFormat. + * This allows the body to be formatted in a way that the controller is going to + * expect as in the case of testing a JSON or XML API. + * + * @param Request $request + * @param null|array $params The parameters to be formatted and put in the body. If this is empty, it will get the + * what has been loaded into the request global of the request class. + * @return Request + */ + protected function setRequestBody(Request $request, array $params = null): Request + { + if (isset($this->requestBody) && $this->requestBody !== '') + { + $request->setBody($this->requestBody); + return $request; + } + + if (isset($this->bodyFormat) && $this->bodyFormat !== '') + { + if (empty($params)) + { + $params = $request->fetchGlobal('request'); + } + $formatMime = ''; + if ($this->bodyFormat === 'json') + { + $formatMime = 'application/json'; + } + elseif ($this->bodyFormat === 'xml') + { + $formatMime = 'application/xml'; + } + if (! empty($formatMime) && ! empty($params)) + { + $formatted = Services::format()->getFormatter($formatMime)->format($params); + $request->setBody($formatted); + $request->setHeader('Content-Type', $formatMime); + } + } + + return $request; + } } diff --git a/system/Test/FeatureTestTrait.php b/system/Test/FeatureTestTrait.php index ce60c8b..51f5b41 100644 --- a/system/Test/FeatureTestTrait.php +++ b/system/Test/FeatureTestTrait.php @@ -17,6 +17,7 @@ use CodeIgniter\HTTP\URI; use CodeIgniter\HTTP\UserAgent; use CodeIgniter\Router\Exceptions\RedirectException; +use CodeIgniter\Router\RouteCollection; use Config\App; use Config\Services; use Exception; @@ -133,14 +134,14 @@ } /** - * Calls a single URI, executes it, and returns a FeatureResponse + * Calls a single URI, executes it, and returns a TestResponse * instance that can be used to run many assertions against. * * @param string $method * @param string $path * @param array|null $params * - * @return FeatureResponse + * @return TestResponse * @throws RedirectException * @throws Exception */ @@ -166,8 +167,17 @@ $request = $this->populateGlobals($method, $request, $params); $request = $this->setRequestBody($request); - // Make sure the RouteCollection knows what method we're using... - $routes = $this->routes ?? Services::routes(); + // Initialize the RouteCollection + if (! $routes = $this->routes) + { + require APPPATH . 'Config/Routes.php'; + + /** + * @var RouteCollection $routes + */ + $routes->getRoutes('*'); + } + $routes->setHTTPVerb($method); // Make sure any other classes that might call the request @@ -202,7 +212,7 @@ } // @codeCoverageIgnoreEnd - return new FeatureResponse($response); + return new TestResponse($response); } /** @@ -211,7 +221,7 @@ * @param string $path * @param array|null $params * - * @return FeatureResponse + * @return TestResponse * @throws RedirectException * @throws Exception */ @@ -226,7 +236,7 @@ * @param string $path * @param array|null $params * - * @return FeatureResponse + * @return TestResponse * @throws RedirectException * @throws Exception */ @@ -241,7 +251,7 @@ * @param string $path * @param array|null $params * - * @return FeatureResponse + * @return TestResponse * @throws RedirectException * @throws Exception */ @@ -256,7 +266,7 @@ * @param string $path * @param array|null $params * - * @return FeatureResponse + * @return TestResponse * @throws RedirectException * @throws Exception */ @@ -271,7 +281,7 @@ * @param string $path * @param array|null $params * - * @return FeatureResponse + * @return TestResponse * @throws RedirectException * @throws Exception */ @@ -286,7 +296,7 @@ * @param string $path * @param array|null $params * - * @return FeatureResponse + * @return TestResponse * @throws RedirectException * @throws Exception */ @@ -306,12 +316,15 @@ */ protected function setupRequest(string $method, string $path = null): IncomingRequest { - $config = config(App::class); - $uri = new URI(rtrim($config->baseURL, '/') . '/' . trim($path, '/ ')); + $path = URI::removeDotSegments($path); + $config = config(App::class); + $request = new IncomingRequest($config, new URI(), null, new UserAgent()); - $request = new IncomingRequest($config, clone($uri), null, new UserAgent()); - $request->uri = $uri; + // $path may have a query in it + $parts = explode('?', $path); + $_SERVER['QUERY_STRING'] = $parts[1] ?? ''; + $request->setPath($parts[0]); $request->setMethod($method); $request->setProtocolVersion('1.1'); diff --git a/system/Test/FilterTestTrait.php b/system/Test/FilterTestTrait.php new file mode 100644 index 0000000..4b1dc4d --- /dev/null +++ b/system/Test/FilterTestTrait.php @@ -0,0 +1,291 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace CodeIgniter\Test; + +use Closure; +use CodeIgniter\Filters\Exceptions\FilterException; +use CodeIgniter\Filters\Filters; +use CodeIgniter\Filters\FilterInterface; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Router\RouteCollection; +use Config\Filters as FiltersConfig; +use Config\Services; +use InvalidArgumentException; +use RuntimeException; + +/** + * Filter Test Trait + * + * Provides functionality for testing + * filters and their route associations. + * + * @mixin CIUnitTestCase + */ +trait FilterTestTrait +{ + /** + * Have the one-time classes been instantiated? + * + * @var boolean + */ + private $doneFilterSetUp = false; + + /** + * The active IncomingRequest or CLIRequest + * + * @var RequestInterface + */ + protected $request; + + /** + * The active Response instance + * + * @var ResponseInterface + */ + protected $response; + + /** + * The Filters configuration to use. + * Extracted for access to aliases + * during Filters::discoverFilters(). + * + * @var FiltersConfig|null + */ + protected $filtersConfig; + + /** + * The prepared Filters library. + * + * @var Filters|null + */ + protected $filters; + + /** + * The default App and discovered + * routes to check for filters. + * + * @var RouteCollection|null + */ + protected $collection; + + //-------------------------------------------------------------------- + // Staging + //-------------------------------------------------------------------- + + /** + * Initializes dependencies once. + * + * @return void + */ + protected function setUpFilterTestTrait(): void + { + if ($this->doneFilterSetUp === true) + { + return; + } + + // Create our own Request and Response so we can + // use the same ones for Filters and FilterInterface + // yet isolate them from outside influence + $this->request = $this->request ?? clone Services::request(); + $this->response = $this->response ?? clone Services::response(); + + // Create our config and Filters instance to reuse for performance + $this->filtersConfig = $this->filtersConfig ?? config('Filters'); + $this->filters = $this->filters ?? new Filters($this->filtersConfig, $this->request, $this->response); + + if (is_null($this->collection)) + { + // Load the RouteCollection from Config to gather App route info + // (creates $routes using the Service as a starting point) + require APPPATH . 'Config/Routes.php'; + + $routes->getRoutes('*'); // Triggers discovery + $this->collection = $routes; + } + + $this->doneFilterSetUp = true; + } + + //-------------------------------------------------------------------- + // Utility + //-------------------------------------------------------------------- + + /** + * Returns a callable method for a filter position + * using the local HTTP instances. + * + * @param FilterInterface|string $filter The filter instance, class, or alias + * @param string $position "before" or "after" + * + * @return Closure + */ + protected function getFilterCaller($filter, string $position): Closure + { + if (! in_array($position, ['before', 'after'], true)) + { + throw new InvalidArgumentException('Invalid filter position passed: ' . $position); + } + + if (is_string($filter)) + { + // Check for an alias (no namespace) + if (strpos($filter, '\\') === false) + { + if (! isset($this->filtersConfig->aliases[$filter])) + { + throw new RuntimeException("No filter found with alias '{$filter}'"); + } + + $filter = $this->filtersConfig->aliases[$filter]; + } + + // Get an instance + $filter = new $filter(); + } + + if (! $filter instanceof FilterInterface) + { + throw FilterException::forIncorrectInterface(get_class($filter)); + } + + $request = clone $this->request; + + if ($position === 'before') + { + return function (array $params = null) use ($filter, $request) { + return $filter->before($request, $params); + }; + } + + $response = clone $this->response; + + return function (array $params = null) use ($filter, $request, $response) { + return $filter->after($request, $response, $params); + }; + } + + /** + * Gets an array of filter aliases enabled + * for the given route at position. + * + * @param string $route The route to test + * @param string $position "before" or "after" + * + * @return string[] The filter aliases + */ + protected function getFiltersForRoute(string $route, string $position): array + { + if (! in_array($position, ['before', 'after'], true)) + { + throw new InvalidArgumentException('Invalid filter position passed:' . $position); + } + + $this->filters->reset(); + + if ($routeFilter = $this->collection->getFilterForRoute($route)) + { + $this->filters->enableFilter($routeFilter, $position); + } + + $aliases = $this->filters->initialize($route)->getFilters(); + + $this->filters->reset(); + return $aliases[$position]; + } + + //-------------------------------------------------------------------- + // Assertions + //-------------------------------------------------------------------- + + /** + * Asserts that the given route at position uses + * the filter (by its alias). + * + * @param string $route The route to test + * @param string $position "before" or "after" + * @param string $alias Alias for the anticipated filter + * + * @return void + */ + protected function assertFilter(string $route, string $position, string $alias): void + { + $filters = $this->getFiltersForRoute($route, $position); + + $this->assertContains( + $alias, + $filters, + "Filter '{$alias}' does not apply to '{$route}'.", + ); + } + + /** + * Asserts that the given route at position does not + * use the filter (by its alias). + * + * @param string $route The route to test + * @param string $position "before" or "after" + * @param string $alias Alias for the anticipated filter + * + * @return void + */ + protected function assertNotFilter(string $route, string $position, string $alias) + { + $filters = $this->getFiltersForRoute($route, $position); + + $this->assertNotContains( + $alias, + $filters, + "Filter '{$alias}' applies to '{$route}' when it should not.", + ); + } + + /** + * Asserts that the given route at position has + * at least one filter set. + * + * @param string $route The route to test + * @param string $position "before" or "after" + * + * @return void + */ + protected function assertHasFilters(string $route, string $position) + { + $filters = $this->getFiltersForRoute($route, $position); + + $this->assertNotEmpty( + $filters, + "No filters found for '{$route}' when at least one was expected.", + ); + } + + /** + * Asserts that the given route at position has + * no filters set. + * + * @param string $route The route to test + * @param string $position "before" or "after" + * + * @return void + */ + protected function assertNotHasFilters(string $route, string $position) + { + $filters = $this->getFiltersForRoute($route, $position); + + $this->assertSame( + [], + $filters, + "Found filters for '{$route}' when none were expected: " . implode(', ', $filters) . '.', + ); + } +} diff --git a/system/Test/Mock/MockAppConfig.php b/system/Test/Mock/MockAppConfig.php index c2d0286..d461fbe 100644 --- a/system/Test/Mock/MockAppConfig.php +++ b/system/Test/Mock/MockAppConfig.php @@ -11,7 +11,9 @@ namespace CodeIgniter\Test\Mock; -class MockAppConfig +use Config\App; + +class MockAppConfig extends App { public $baseURL = 'http://example.com/'; diff --git a/system/Test/Mock/MockCache.php b/system/Test/Mock/MockCache.php index 67356cf..f4fb1a9 100644 --- a/system/Test/Mock/MockCache.php +++ b/system/Test/Mock/MockCache.php @@ -12,24 +12,25 @@ namespace CodeIgniter\Test\Mock; use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Cache\Handlers\BaseHandler; use Closure; -class MockCache implements CacheInterface +class MockCache extends BaseHandler implements CacheInterface { /** - * Prefixed to all cache names. - * - * @var string - */ - protected $prefix; - - /** * Mock cache storage. * * @var array */ protected $cache = []; + /** + * Expiration times. + * + * @var ?int[] + */ + protected $expirations = []; + //-------------------------------------------------------------------- /** @@ -37,7 +38,6 @@ */ public function initialize() { - // Not to see here... } //-------------------------------------------------------------------- @@ -51,7 +51,7 @@ */ public function get(string $key) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); return array_key_exists($key, $this->cache) ? $this->cache[$key] @@ -96,13 +96,14 @@ * @param integer $ttl Time To Live, in seconds (default 60) * @param boolean $raw Whether to store the raw value. * - * @return mixed + * @return boolean */ public function save(string $key, $value, int $ttl = 60, bool $raw = false) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); - $this->cache[$key] = $value; + $this->cache[$key] = $value; + $this->expirations[$key] = $ttl > 0 ? time() + $ttl : null; return true; } @@ -114,11 +115,46 @@ * * @param string $key Cache item name * - * @return mixed + * @return boolean */ public function delete(string $key) { + $key = static::validateKey($key, $this->prefix); + + if (! isset($this->cache[$key])) + { + return false; + } + unset($this->cache[$key]); + unset($this->expirations[$key]); + + return true; + } + + //-------------------------------------------------------------------- + + /** + * Deletes items from the cache store matching a given pattern. + * + * @param string $pattern Cache items glob-style pattern + * + * @return integer + */ + public function deleteMatching(string $pattern) + { + $count = 0; + foreach (array_keys($this->cache) as $key) + { + if (fnmatch($pattern, $key)) + { + $count++; + unset($this->cache[$key]); + unset($this->expirations[$key]); + } + } + + return $count; } //-------------------------------------------------------------------- @@ -129,12 +165,11 @@ * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return boolean */ public function increment(string $key, int $offset = 1) { - $key = $this->prefix . $key; - + $key = static::validateKey($key, $this->prefix); $data = $this->cache[$key] ?: null; if (empty($data)) @@ -157,11 +192,11 @@ * @param string $key Cache ID * @param integer $offset Step/value to increase by * - * @return mixed + * @return boolean */ public function decrement(string $key, int $offset = 1) { - $key = $this->prefix . $key; + $key = static::validateKey($key, $this->prefix); $data = $this->cache[$key] ?: null; @@ -182,11 +217,14 @@ /** * Will delete all items in the entire cache. * - * @return mixed + * @return boolean */ public function clean() { - $this->cache = []; + $this->cache = []; + $this->expirations = []; + + return true; } //-------------------------------------------------------------------- @@ -197,11 +235,11 @@ * The information returned and the structure of the data * varies depending on the handler. * - * @return mixed + * @return string[] Keys currently present in the store */ public function getCacheInfo() { - return []; + return array_keys($this->cache); } //-------------------------------------------------------------------- @@ -211,11 +249,27 @@ * * @param string $key Cache item name. * - * @return mixed + * @return array|null + * Returns null if the item does not exist, otherwise array + * with at least the 'expire' key for absolute epoch expiry (or null). */ public function getMetaData(string $key) { - return false; + // Misses return null + if (! array_key_exists($key, $this->expirations)) + { + return null; + } + + // Count expired items as a miss + if (is_int($this->expirations[$key]) && $this->expirations[$key] > time()) + { + return null; + } + + return [ + 'expire' => $this->expirations[$key], + ]; } //-------------------------------------------------------------------- diff --git a/system/Test/Mock/MockConnection.php b/system/Test/Mock/MockConnection.php index 147e1d1..9cb6607 100644 --- a/system/Test/Mock/MockConnection.php +++ b/system/Test/Mock/MockConnection.php @@ -48,7 +48,7 @@ * @param boolean $setEscapeFlags * @param string $queryClass * - * @return BaseResult|Query|false + * @return BaseResult|Query|boolean * * @todo BC set $queryClass default as null in 4.1 */ @@ -81,8 +81,14 @@ $query->setDuration($startTime); - $resultClass = str_replace('Connection', 'Result', get_class($this)); + // resultID is not false, so it must be successful + if ($query->isWriteType()) + { + return true; + } + // query is not write-type, so it must be read-type query; return QueryResult + $resultClass = str_replace('Connection', 'Result', get_class($this)); return new $resultClass($this->connID, $this->resultID); } diff --git a/system/Test/Mock/MockSecurityConfig.php b/system/Test/Mock/MockSecurityConfig.php index a0786a9..c9cd280 100644 --- a/system/Test/Mock/MockSecurityConfig.php +++ b/system/Test/Mock/MockSecurityConfig.php @@ -2,9 +2,14 @@ namespace CodeIgniter\Test\Mock; -use Config\Security as SecurityConfig; +use Config\Security as Security; -class MockSecurityConfig extends SecurityConfig +/** + * @deprecated + * + * @codeCoverageIgnore + */ +class MockSecurityConfig extends Security { public $tokenName = 'csrf_test_name'; public $headerName = 'X-CSRF-TOKEN'; diff --git a/system/Test/Mock/MockSession.php b/system/Test/Mock/MockSession.php index e760fbe..69b1bb0 100644 --- a/system/Test/Mock/MockSession.php +++ b/system/Test/Mock/MockSession.php @@ -11,6 +11,7 @@ namespace CodeIgniter\Test\Mock; +use CodeIgniter\Cookie\Cookie; use CodeIgniter\Session\Session; /** @@ -24,92 +25,46 @@ /** * Holds our "cookie" data. * - * @var array + * @var Cookie[] */ public $cookies = []; public $didRegenerate = false; - //-------------------------------------------------------------------- - /** * Sets the driver as the session handler in PHP. * Extracted for easier testing. */ protected function setSaveHandler() { - // session_set_save_handler($this->driver, true); + // session_set_save_handler($this->driver, true); } - //-------------------------------------------------------------------- - /** * Starts the session. * Extracted for testing reasons. */ protected function startSession() { - // session_start(); + // session_start(); $this->setCookie(); } - //-------------------------------------------------------------------- - /** * Takes care of setting the cookie on the client side. * Extracted for testing reasons. */ protected function setCookie() { - if (PHP_VERSION_ID < 70300) - { - $sameSite = ''; - if ($this->cookieSameSite !== '') - { - $sameSite = '; samesite=' . $this->cookieSameSite; - } + $expiration = $this->sessionExpiration === 0 ? 0 : time() + $this->sessionExpiration; + $this->cookie = $this->cookie->withValue(session_id())->withExpires($expiration); - $this->cookies[] = [ - $this->sessionCookieName, - session_id(), - empty($this->sessionExpiration) ? 0 : time() + $this->sessionExpiration, - $this->cookiePath . $sameSite, // Hacky way to set SameSite for PHP 7.2 and earlier - $this->cookieDomain, - $this->cookieSecure, - true, - ]; - } - else - { - // PHP 7.3 adds another function signature allowing setting of samesite - $params = [ - 'expires' => empty($this->sessionExpiration) ? 0 : time() + $this->sessionExpiration, - 'path' => $this->cookiePath, - 'domain' => $this->cookieDomain, - 'secure' => $this->cookieSecure, - 'httponly' => true, - ]; - - if ($this->cookieSameSite !== '') - { - $params['samesite'] = $this->cookieSameSite; - } - - $this->cookies[] = [ - $this->sessionCookieName, - session_id(), - $params, - ]; - } + $this->cookies[] = $this->cookie; } - //-------------------------------------------------------------------- - public function regenerate(bool $destroy = false) { $this->didRegenerate = true; $_SESSION['__ci_last_regenerate'] = time(); } - - //-------------------------------------------------------------------- } diff --git a/system/Test/ReflectionHelper.php b/system/Test/ReflectionHelper.php index 9934b03..1f26179 100644 --- a/system/Test/ReflectionHelper.php +++ b/system/Test/ReflectionHelper.php @@ -55,14 +55,7 @@ */ private static function getAccessibleRefProperty($obj, $property) { - if (is_object($obj)) - { - $refClass = new ReflectionObject($obj); - } - else - { - $refClass = new ReflectionClass($obj); - } + $refClass = is_object($obj) ? new ReflectionObject($obj) : new ReflectionClass($obj); $refProperty = $refClass->getProperty($property); $refProperty->setAccessible(true); diff --git a/system/Test/TestResponse.php b/system/Test/TestResponse.php new file mode 100644 index 0000000..4c7a655 --- /dev/null +++ b/system/Test/TestResponse.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\Test; + +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Config\Services; +use Exception; +use PHPUnit\Framework\ExpectationFailedException; +use PHPUnit\Framework\TestCase; + +/** + * Test Response Class + * + * Consolidated response processing + * for test results. + */ +class TestResponse extends TestCase +{ + /** + * The request. + * + * @var RequestInterface|null + */ + protected $request; + + /** + * The response. + * + * @var ResponseInterface + */ + protected $response; + + /** + * DOM for the body. + * + * @var DOMParser + */ + protected $domParser; + + /** + * Stores or the Response and parses the body in the DOM. + * + * @param ResponseInterface $response + */ + public function __construct(ResponseInterface $response) + { + $this->setResponse($response); + } + + //-------------------------------------------------------------------- + // Getters / Setters + //-------------------------------------------------------------------- + + /** + * Sets the request. + * + * @param RequestInterface $request + * + * @return $this + */ + public function setRequest(RequestInterface $request) + { + $this->request = $request; + + return $this; + } + + /** + * Sets the Response and updates the DOM. + * + * @param ResponseInterface $response + * + * @return $this + */ + public function setResponse(ResponseInterface $response) + { + $this->response = $response; + $this->domParser = new DOMParser(); + + $body = $response->getBody(); + if (is_string($body) && $body !== '') + { + $this->domParser->withString($body); + } + + return $this; + } + + /** + * Request accessor. + * + * @return RequestInterface|null + */ + public function request() + { + return $this->request; + } + + /** + * Response accessor. + * + * @return ResponseInterface + */ + public function response() + { + return $this->response; + } + + //-------------------------------------------------------------------- + // Status Checks + //-------------------------------------------------------------------- + + /** + * Boils down the possible responses into a boolean valid/not-valid + * response type. + * + * @return boolean + */ + public function isOK(): bool + { + $status = $this->response->getStatusCode(); + + // Only 200 and 300 range status codes + // are considered valid. + if ($status >= 400 || $status < 200) + { + return false; + } + // Empty bodies are not considered valid, unless in redirects + return ! ($status < 300 && empty($this->response->getBody())); + } + + /** + * Asserts that the status is a specific value. + * + * @param integer $code + * + * @throws Exception + */ + public function assertStatus(int $code) + { + $this->assertEquals($code, $this->response->getStatusCode()); + } + + /** + * Asserts that the Response is considered OK. + * + * @throws Exception + */ + public function assertOK() + { + $this->assertTrue($this->isOK(), "{$this->response->getStatusCode()} is not a successful status code, or the Response has an empty body."); + } + + /** + * Asserts that the Response is considered OK. + * + * @throws Exception + */ + public function assertNotOK() + { + $this->assertFalse($this->isOK(), "{$this->response->getStatusCode()} is an unexpected successful status code, or the Response has body content."); + } + + //-------------------------------------------------------------------- + // Redirection + //-------------------------------------------------------------------- + + /** + * Returns whether or not the Response was a redirect or RedirectResponse + * + * @return boolean + */ + public function isRedirect(): bool + { + return $this->response instanceof RedirectResponse + || $this->response->hasHeader('Location') + || $this->response->hasHeader('Refresh'); + } + + /** + * Assert that the given response was a redirect. + * + * @throws Exception + */ + public function assertRedirect() + { + $this->assertTrue($this->isRedirect(), 'Response is not a redirect or RedirectResponse.'); + } + + /** + * Assert that a given response was a redirect + * and it was redirect to a specific URI. + * + * @param string $uri + * + * @throws Exception + */ + public function assertRedirectTo(string $uri) + { + $this->assertRedirect(); + + $uri = trim(strtolower($uri)); + $redirectUri = strtolower($this->getRedirectUrl()); + + $matches = $uri === $redirectUri + || strtolower(site_url($uri)) === $redirectUri + || $uri === site_url($redirectUri); + + $this->assertTrue($matches, "Redirect URL `{$uri}` does not match `{$redirectUri}`"); + } + + /* + * Assert that the given response was not a redirect. + * + * @throws Exception + */ + public function assertNotRedirect() + { + $this->assertFalse($this->isRedirect(), 'Response is an unexpected redirect or RedirectResponse.'); + } + + /** + * Returns the URL set for redirection. + * + * @return string|null + */ + public function getRedirectUrl(): ?string + { + if (! $this->isRedirect()) + { + return null; + } + + if ($this->response->hasHeader('Location')) + { + return $this->response->getHeaderLine('Location'); + } + + if ($this->response->hasHeader('Refresh')) + { + return str_replace('0;url=', '', $this->response->getHeaderLine('Refresh')); + } + + return null; + } + + //-------------------------------------------------------------------- + // Session + //-------------------------------------------------------------------- + + /** + * Asserts that an SESSION key has been set and, optionally, test it's value. + * + * @param string $key + * @param mixed $value + * + * @throws Exception + */ + public function assertSessionHas(string $key, $value = null) + { + $this->assertTrue(array_key_exists($key, $_SESSION), "'{$key}' is not in the current \$_SESSION"); + + if (is_null($value)) + { + return; + } + + if (is_scalar($value)) + { + $this->assertEquals($value, $_SESSION[$key], "The value of '{$key}' ({$value}) does not match expected value."); + } + else + { + $this->assertEquals($value, $_SESSION[$key], "The value of '{$key}' does not match expected value."); + } + } + + /** + * Asserts the session is missing $key. + * + * @param string $key + * + * @throws Exception + */ + public function assertSessionMissing(string $key) + { + $this->assertFalse(array_key_exists($key, $_SESSION), "'{$key}' should not be present in \$_SESSION."); + } + + //-------------------------------------------------------------------- + // Headers + //-------------------------------------------------------------------- + + /** + * Asserts that the Response contains a specific header. + * + * @param string $key + * @param string|null $value + * + * @throws Exception + */ + public function assertHeader(string $key, $value = null) + { + $this->assertTrue($this->response->hasHeader($key), "'{$key}' is not a valid Response header."); + + if ($value !== null) + { + $this->assertEquals($value, $this->response->getHeaderLine($key), "The value of '{$key}' header ({$this->response->getHeaderLine($key)}) does not match expected value."); + } + } + + /** + * Asserts the Response headers does not contain the specified header. + * + * @param string $key + * + * @throws Exception + */ + public function assertHeaderMissing(string $key) + { + $this->assertFalse($this->response->hasHeader($key), "'{$key}' should not be in the Response headers."); + } + + //-------------------------------------------------------------------- + // Cookies + //-------------------------------------------------------------------- + + /** + * Asserts that the response has the specified cookie. + * + * @param string $key + * @param string|null $value + * @param string $prefix + * + * @throws Exception + */ + public function assertCookie(string $key, $value = null, string $prefix = '') + { + $this->assertTrue($this->response->hasCookie($key, $value, $prefix), "No cookie found named '{$key}'."); + } + + /** + * Assert the Response does not have the specified cookie set. + * + * @param string $key + */ + public function assertCookieMissing(string $key) + { + $this->assertFalse($this->response->hasCookie($key), "Cookie named '{$key}' should not be set."); + } + + /** + * Asserts that a cookie exists and has an expired time. + * + * @param string $key + * @param string $prefix + * + * @throws Exception + */ + public function assertCookieExpired(string $key, string $prefix = '') + { + $this->assertTrue($this->response->hasCookie($key, null, $prefix)); + $this->assertGreaterThan(time(), $this->response->getCookie($key, $prefix)->getExpiresTimestamp()); + } + + //-------------------------------------------------------------------- + // JSON + //-------------------------------------------------------------------- + + /** + * Returns the response's body as JSON + * + * @return mixed|false + */ + public function getJSON() + { + $response = $this->response->getJSON(); + + if (is_null($response)) + { + return false; + } + + return $response; + } + + /** + * Test that the response contains a matching JSON fragment. + * + * @param array $fragment + * @param boolean $strict + * + * @throws Exception + */ + public function assertJSONFragment(array $fragment, bool $strict = false) + { + $json = json_decode($this->getJSON(), true); + $this->assertIsArray($json, 'Response does not have valid json'); + $patched = array_replace_recursive($json, $fragment); + + if ($strict) + { + $this->assertSame($json, $patched, 'Response does not contain a matching JSON fragment.'); + } + else + { + $this->assertEquals($json, $patched, 'Response does not contain a matching JSON fragment.'); + } + } + + /** + * Asserts that the JSON exactly matches the passed in data. + * If the value being passed in is a string, it must be a json_encoded string. + * + * @param string|array $test + * + * @throws Exception + */ + public function assertJSONExact($test) + { + $json = $this->getJSON(); + + if (is_object($test)) + { + $test = method_exists($test, 'toArray') ? $test->toArray() : (array) $test; + } + + if (is_array($test)) + { + $test = Services::format()->getFormatter('application/json')->format($test); + } + + $this->assertJsonStringEqualsJsonString($test, $json, 'Response does not contain matching JSON.'); + } + + //-------------------------------------------------------------------- + // XML Methods + //-------------------------------------------------------------------- + + /** + * Returns the response' body as XML + * + * @return mixed|string + */ + public function getXML() + { + return $this->response->getXML(); + } + + //-------------------------------------------------------------------- + // DomParser + //-------------------------------------------------------------------- + + /** + * Assert that the desired text can be found in the result body. + * + * @param string|null $search + * @param string|null $element + * + * @throws Exception + */ + public function assertSee(string $search = null, string $element = null) + { + $this->assertTrue($this->domParser->see($search, $element), "Do not see '{$search}' in response."); + } + + /** + * Asserts that we do not see the specified text. + * + * @param string|null $search + * @param string|null $element + * + * @throws Exception + */ + public function assertDontSee(string $search = null, string $element = null) + { + $this->assertTrue($this->domParser->dontSee($search, $element), "I should not see '{$search}' in response."); + } + + /** + * Assert that we see an element selected via a CSS selector. + * + * @param string $search + * + * @throws Exception + */ + public function assertSeeElement(string $search) + { + $this->assertTrue($this->domParser->seeElement($search), "Do not see element with selector '{$search} in response.'"); + } + + /** + * Assert that we do not see an element selected via a CSS selector. + * + * @param string $search + * + * @throws Exception + */ + public function assertDontSeeElement(string $search) + { + $this->assertTrue($this->domParser->dontSeeElement($search), "I should not see an element with selector '{$search}' in response.'"); + } + + /** + * Assert that we see a link with the matching text and/or class. + * + * @param string $text + * @param string|null $details + * + * @throws Exception + */ + public function assertSeeLink(string $text, string $details = null) + { + $this->assertTrue($this->domParser->seeLink($text, $details), "Do no see anchor tag with the text {$text} in response."); + } + + /** + * Assert that we see an input with name/value. + * + * @param string $field + * @param string|null $value + * + * @throws Exception + */ + public function assertSeeInField(string $field, string $value = null) + { + $this->assertTrue($this->domParser->seeInField($field, $value), "Do no see input named {$field} with value {$value} in response."); + } + + /** + * Forward any unrecognized method calls to our DOMParser instance. + * + * @param string $function Method name + * @param mixed $params Any method parameters + * @return mixed + */ + public function __call($function, $params) + { + if (method_exists($this->domParser, $function)) + { + return $this->domParser->{$function}(...$params); + } + } +} diff --git a/system/Test/bootstrap.php b/system/Test/bootstrap.php index 0fc476d..9625506 100644 --- a/system/Test/bootstrap.php +++ b/system/Test/bootstrap.php @@ -9,6 +9,7 @@ * file that was distributed with this source code. */ +use CodeIgniter\Config\DotEnv; use CodeIgniter\Router\RouteCollection; use CodeIgniter\Services; use Config\Autoload; @@ -83,10 +84,33 @@ class_alias('Config\Services', 'CodeIgniter\Services'); } -// Launch the autoloader to gather namespaces (includes composer.json's "autoload-dev") -$loader = Services::autoloader(); -$loader->initialize(new Autoload(), new Modules()); -$loader->register(); // Register the loader with the SPL autoloader stack. +// Initialize and register the loader with the SPL autoloader stack. +Services::autoloader()->initialize(new Autoload(), new Modules())->register(); + +// Now load Composer's if it's available +if (is_file(COMPOSER_PATH)) +{ + /* + * 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', realpath(ROOTPATH . 'vendor') . DIRECTORY_SEPARATOR); + } + + require_once COMPOSER_PATH; +} + +// Load environment settings from .env files into $_SERVER and $_ENV +require_once SYSTEMPATH . 'Config/DotEnv.php'; + +$env = new DotEnv(ROOTPATH); +$env->load(); + +// Always load the URL helper, it should be used in most of apps. +helper('url'); require_once APPPATH . 'Config/Routes.php'; diff --git a/system/ThirdParty/PSR/Log/AbstractLogger.php b/system/ThirdParty/PSR/Log/AbstractLogger.php index d5106da..e02f9da 100644 --- a/system/ThirdParty/PSR/Log/AbstractLogger.php +++ b/system/ThirdParty/PSR/Log/AbstractLogger.php @@ -14,11 +14,12 @@ /** * System is unusable. * - * @param string $message - * @param array $context - * @return null + * @param string $message + * @param mixed[] $context + * + * @return void */ - public function emergency($message, array $context = []) + public function emergency($message, array $context = array()) { $this->log(LogLevel::EMERGENCY, $message, $context); } @@ -29,11 +30,12 @@ * Example: Entire website down, database unavailable, etc. This should * trigger the SMS alerts and wake you up. * - * @param string $message - * @param array $context - * @return null + * @param string $message + * @param mixed[] $context + * + * @return void */ - public function alert($message, array $context = []) + public function alert($message, array $context = array()) { $this->log(LogLevel::ALERT, $message, $context); } @@ -43,11 +45,12 @@ * * Example: Application component unavailable, unexpected exception. * - * @param string $message - * @param array $context - * @return null + * @param string $message + * @param mixed[] $context + * + * @return void */ - public function critical($message, array $context = []) + public function critical($message, array $context = array()) { $this->log(LogLevel::CRITICAL, $message, $context); } @@ -56,11 +59,12 @@ * Runtime errors that do not require immediate action but should typically * be logged and monitored. * - * @param string $message - * @param array $context - * @return null + * @param string $message + * @param mixed[] $context + * + * @return void */ - public function error($message, array $context = []) + public function error($message, array $context = array()) { $this->log(LogLevel::ERROR, $message, $context); } @@ -71,11 +75,12 @@ * Example: Use of deprecated APIs, poor use of an API, undesirable things * that are not necessarily wrong. * - * @param string $message - * @param array $context - * @return null + * @param string $message + * @param mixed[] $context + * + * @return void */ - public function warning($message, array $context = []) + public function warning($message, array $context = array()) { $this->log(LogLevel::WARNING, $message, $context); } @@ -83,11 +88,12 @@ /** * Normal but significant events. * - * @param string $message - * @param array $context - * @return null + * @param string $message + * @param mixed[] $context + * + * @return void */ - public function notice($message, array $context = []) + public function notice($message, array $context = array()) { $this->log(LogLevel::NOTICE, $message, $context); } @@ -97,11 +103,12 @@ * * Example: User logs in, SQL logs. * - * @param string $message - * @param array $context - * @return null + * @param string $message + * @param mixed[] $context + * + * @return void */ - public function info($message, array $context = []) + public function info($message, array $context = array()) { $this->log(LogLevel::INFO, $message, $context); } @@ -109,11 +116,12 @@ /** * Detailed debug information. * - * @param string $message - * @param array $context - * @return null + * @param string $message + * @param mixed[] $context + * + * @return void */ - public function debug($message, array $context = []) + public function debug($message, array $context = array()) { $this->log(LogLevel::DEBUG, $message, $context); } diff --git a/system/ThirdParty/PSR/Log/LogLevel.php b/system/ThirdParty/PSR/Log/LogLevel.php index e32c151..9cebcac 100644 --- a/system/ThirdParty/PSR/Log/LogLevel.php +++ b/system/ThirdParty/PSR/Log/LogLevel.php @@ -3,16 +3,16 @@ namespace Psr\Log; /** - * Describes log levels + * Describes log levels. */ class LogLevel { const EMERGENCY = 'emergency'; - const ALERT = 'alert'; - const CRITICAL = 'critical'; - const ERROR = 'error'; - const WARNING = 'warning'; - const NOTICE = 'notice'; - const INFO = 'info'; - const DEBUG = 'debug'; + const ALERT = 'alert'; + const CRITICAL = 'critical'; + const ERROR = 'error'; + const WARNING = 'warning'; + const NOTICE = 'notice'; + const INFO = 'info'; + const DEBUG = 'debug'; } diff --git a/system/ThirdParty/PSR/Log/LoggerAwareInterface.php b/system/ThirdParty/PSR/Log/LoggerAwareInterface.php index 2eebc4e..4d64f47 100644 --- a/system/ThirdParty/PSR/Log/LoggerAwareInterface.php +++ b/system/ThirdParty/PSR/Log/LoggerAwareInterface.php @@ -3,15 +3,16 @@ namespace Psr\Log; /** - * Describes a logger-aware instance + * Describes a logger-aware instance. */ interface LoggerAwareInterface { /** - * Sets a logger instance on the object + * Sets a logger instance on the object. * * @param LoggerInterface $logger - * @return null + * + * @return void */ public function setLogger(LoggerInterface $logger); } diff --git a/system/ThirdParty/PSR/Log/LoggerAwareTrait.php b/system/ThirdParty/PSR/Log/LoggerAwareTrait.php index f087a3d..82bf45c 100644 --- a/system/ThirdParty/PSR/Log/LoggerAwareTrait.php +++ b/system/ThirdParty/PSR/Log/LoggerAwareTrait.php @@ -7,12 +7,16 @@ */ trait LoggerAwareTrait { - /** @var LoggerInterface */ + /** + * The logger instance. + * + * @var LoggerInterface|null + */ protected $logger; /** * Sets a logger. - * + * * @param LoggerInterface $logger */ public function setLogger(LoggerInterface $logger) diff --git a/system/ThirdParty/PSR/Log/LoggerInterface.php b/system/ThirdParty/PSR/Log/LoggerInterface.php index 20c7ff0..2206cfd 100644 --- a/system/ThirdParty/PSR/Log/LoggerInterface.php +++ b/system/ThirdParty/PSR/Log/LoggerInterface.php @@ -1,14 +1,16 @@ -log(LogLevel::EMERGENCY, $message, $context); } @@ -31,10 +32,11 @@ * trigger the SMS alerts and wake you up. * * @param string $message - * @param array $context - * @return null + * @param array $context + * + * @return void */ - public function alert($message, array $context = []) + public function alert($message, array $context = array()) { $this->log(LogLevel::ALERT, $message, $context); } @@ -45,10 +47,11 @@ * Example: Application component unavailable, unexpected exception. * * @param string $message - * @param array $context - * @return null + * @param array $context + * + * @return void */ - public function critical($message, array $context = []) + public function critical($message, array $context = array()) { $this->log(LogLevel::CRITICAL, $message, $context); } @@ -58,10 +61,11 @@ * be logged and monitored. * * @param string $message - * @param array $context - * @return null + * @param array $context + * + * @return void */ - public function error($message, array $context = []) + public function error($message, array $context = array()) { $this->log(LogLevel::ERROR, $message, $context); } @@ -73,10 +77,11 @@ * that are not necessarily wrong. * * @param string $message - * @param array $context - * @return null + * @param array $context + * + * @return void */ - public function warning($message, array $context = []) + public function warning($message, array $context = array()) { $this->log(LogLevel::WARNING, $message, $context); } @@ -85,10 +90,11 @@ * Normal but significant events. * * @param string $message - * @param array $context - * @return null + * @param array $context + * + * @return void */ - public function notice($message, array $context = []) + public function notice($message, array $context = array()) { $this->log(LogLevel::NOTICE, $message, $context); } @@ -99,10 +105,11 @@ * Example: User logs in, SQL logs. * * @param string $message - * @param array $context - * @return null + * @param array $context + * + * @return void */ - public function info($message, array $context = []) + public function info($message, array $context = array()) { $this->log(LogLevel::INFO, $message, $context); } @@ -111,10 +118,11 @@ * Detailed debug information. * * @param string $message - * @param array $context - * @return null + * @param array $context + * + * @return void */ - public function debug($message, array $context = []) + public function debug($message, array $context = array()) { $this->log(LogLevel::DEBUG, $message, $context); } @@ -122,10 +130,13 @@ /** * Logs with an arbitrary level. * - * @param mixed $level + * @param mixed $level * @param string $message - * @param array $context - * @return null + * @param array $context + * + * @return void + * + * @throws \Psr\Log\InvalidArgumentException */ - abstract public function log($level, $message, array $context = []); + abstract public function log($level, $message, array $context = array()); } diff --git a/system/ThirdParty/PSR/Log/NullLogger.php b/system/ThirdParty/PSR/Log/NullLogger.php index e47d4b9..c8f7293 100644 --- a/system/ThirdParty/PSR/Log/NullLogger.php +++ b/system/ThirdParty/PSR/Log/NullLogger.php @@ -3,7 +3,7 @@ namespace Psr\Log; /** - * This Logger can be used to avoid conditional log calls + * This Logger can be used to avoid conditional log calls. * * Logging should always be optional, and if no logger is provided to your * library creating a NullLogger instance to have something to throw logs at @@ -15,12 +15,15 @@ /** * Logs with an arbitrary level. * - * @param mixed $level + * @param mixed $level * @param string $message - * @param array $context - * @return null + * @param array $context + * + * @return void + * + * @throws \Psr\Log\InvalidArgumentException */ - public function log($level, $message, array $context = []) + public function log($level, $message, array $context = array()) { // noop } diff --git a/system/Throttle/Throttler.php b/system/Throttle/Throttler.php index f3512be..0b69db4 100644 --- a/system/Throttle/Throttler.php +++ b/system/Throttle/Throttler.php @@ -30,7 +30,7 @@ /** * Container for throttle counters. * - * @var \CodeIgniter\Cache\CacheInterface + * @var CacheInterface */ protected $cache; @@ -135,7 +135,7 @@ // should be refilled, then checked against capacity // to be sure the bucket didn't overflow. $tokens += $rate * $elapsed; - $tokens = $tokens > $capacity ? $capacity : $tokens; + $tokens = $tokens > $capacity ? $capacity : $tokens; // If $tokens >= 1, then we are safe to perform the action, but // we need to decrement the number of available tokens. @@ -150,6 +150,21 @@ return false; } + /** + * @param string $key The name of the bucket + * + * @return $this + */ + public function remove(string $key): self + { + $tokenName = $this->prefix . $key; + + $this->cache->delete($tokenName); + $this->cache->delete($tokenName . 'Time'); + + return $this; + } + //-------------------------------------------------------------------- /** diff --git a/system/Typography/Typography.php b/system/Typography/Typography.php index a922363..038c295 100644 --- a/system/Typography/Typography.php +++ b/system/Typography/Typography.php @@ -187,13 +187,12 @@ // Convert quotes, elipsis, em-dashes, non-breaking spaces, and ampersands $str = $this->formatCharacters($str); - // restore HTML comments - for ($i = 0, $total = count($htmlComments); $i < $total; $i ++) + foreach ($htmlComments as $i => $htmlComment) { // remove surrounding paragraph tags, but only if there's an opening paragraph tag // otherwise HTML comments at the ends of paragraphs will have the closing tag removed // if '

{@HC1}' then replace

{@HC1}

with the comment, else replace only {@HC1} with the comment - $str = preg_replace('#(?(?=

\{@HC' . $i . '\})

\{@HC' . $i . '\}(\s*

)|\{@HC' . $i . '\})#s', $htmlComments[$i], $str); + $str = preg_replace('#(?(?=

\{@HC' . $i . '\})

\{@HC' . $i . '\}(\s*

)|\{@HC' . $i . '\})#s', $htmlComment, $str); } // Final clean up diff --git a/system/Validation/FileRules.php b/system/Validation/FileRules.php index 3c6de57..0ab18b2 100644 --- a/system/Validation/FileRules.php +++ b/system/Validation/FileRules.php @@ -23,7 +23,7 @@ /** * Request instance. So we can get access to the files. * - * @var \CodeIgniter\HTTP\RequestInterface + * @var RequestInterface */ protected $request; diff --git a/system/Validation/Rules.php b/system/Validation/Rules.php index 77cbd30..44e77a5 100644 --- a/system/Validation/Rules.php +++ b/system/Validation/Rules.php @@ -142,12 +142,9 @@ ->where($field, $str) ->limit(1); - if (! empty($whereField) && ! empty($whereValue)) + if (! empty($whereField) && ! empty($whereValue) && ! preg_match('/^\{(\w+)\}$/', $whereValue)) { - if (! preg_match('/^\{(\w+)\}$/', $whereValue)) - { - $row = $row->where($whereField, $whereValue); - } + $row = $row->where($whereField, $whereValue); } return (bool) ($row->get()->getRow() !== null); @@ -201,12 +198,9 @@ ->where($field, $str) ->limit(1); - if (! empty($ignoreField) && ! empty($ignoreValue)) + if (! empty($ignoreField) && ! empty($ignoreValue) && ! preg_match('/^\{(\w+)\}$/', $ignoreValue)) { - if (! preg_match('/^\{(\w+)\}$/', $ignoreValue)) - { - $row = $row->where("{$ignoreField} !=", $ignoreValue); - } + $row = $row->where("{$ignoreField} !=", $ignoreValue); } return (bool) ($row->get()->getRow() === null); @@ -384,10 +378,8 @@ foreach ($fields as $field) { - if ( - (array_key_exists($field, $data) && ! empty($data[$field])) || - (strpos($field, '.') !== false && ! empty(dot_array_search($field, $data))) - ) + if ((array_key_exists($field, $data) && ! empty($data[$field])) || + (strpos($field, '.') !== false && ! empty(dot_array_search($field, $data))) ) { $requiredFields[] = $field; } @@ -435,8 +427,7 @@ // any of the fields are not present in $data foreach ($fields as $field) { - if ( - (strpos($field, '.') === false && (! array_key_exists($field, $data) || empty($data[$field]))) || + if ((strpos($field, '.') === false && (! array_key_exists($field, $data) || empty($data[$field]))) || (strpos($field, '.') !== false && empty(dot_array_search($field, $data))) ) { diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 3b69e41..29a2322 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -83,8 +83,6 @@ */ protected $view; - //-------------------------------------------------------------------- - /** * Validation constructor. * @@ -100,15 +98,13 @@ $this->view = $view; } - //-------------------------------------------------------------------- - /** * Runs the validation process, returning true/false determining whether * validation was successful or not. * - * @param array $data The array of data to validate. - * @param string $group The pre-defined group of rules to apply. - * @param string $dbGroup The database group to use. + * @param array|null $data The array of data to validate. + * @param string|null $group The predefined group of rules to apply. + * @param string|null $dbGroup The database group to use. * * @return boolean */ @@ -120,7 +116,6 @@ $data['DBGroup'] = $dbGroup; $this->loadRuleSets(); - $this->loadRuleGroup($group); // If no rules exist, we return false to ensure @@ -130,8 +125,8 @@ return false; } - // Replace any placeholders (i.e. {id}) in the rules with - // the value found in $data, if exists. + // Replace any placeholders (e.g. {id}) in the rules with + // the value found in $data, if any. $this->rules = $this->fillPlaceholders($this->rules, $data); // Need this for searching arrays in validation. @@ -139,59 +134,52 @@ // Run through each rule. If we have any field set for // this rule, then we need to run them through! - foreach ($this->rules as $rField => $rSetup) + foreach ($this->rules as $field => $setup) { // Blast $rSetup apart, unless it's already an array. - $rules = $rSetup['rules'] ?? $rSetup; + $rules = $setup['rules'] ?? $setup; if (is_string($rules)) { $rules = $this->splitRules($rules); } - $value = dot_array_search($rField, $data); - $fieldNameToken = explode('.', $rField); + $values = dot_array_search($field, $data); + $values = is_array($values) ? $values : [$values]; - if (is_array($value) && end($fieldNameToken) === '*') + if ($values === []) { - foreach ($value as $val) - { - $this->processRules($rField, $rSetup['label'] ?? $rField, $val ?? null, $rules, $data); - } + // We'll process the values right away if an empty array + $this->processRules($field, $setup['label'] ?? $field, $values, $rules, $data); } - else + + foreach ($values as $value) { - $this->processRules($rField, $rSetup['label'] ?? $rField, $value ?? null, $rules, $data); + // Otherwise, we'll let the loop do the job + $this->processRules($field, $setup['label'] ?? $field, $value, $rules, $data); } } - return ! empty($this->getErrors()) ? false : true; + return $this->getErrors() === []; } - //-------------------------------------------------------------------- - /** - * Check; runs the validation process, returning true or false + * Runs the validation process, returning true or false * determining whether validation was successful or not. * - * @param mixed $value Value to validation. - * @param string $rule Rule. - * @param string[] $errors Errors. + * @param mixed $value + * @param string $rule + * @param string[] $errors * - * @return boolean True if valid, else false. + * @return boolean */ public function check($value, string $rule, array $errors = []): bool { $this->reset(); - $this->setRule('check', null, $rule, $errors); - return $this->run([ - 'check' => $value, - ]); + return $this->setRule('check', null, $rule, $errors)->run(['check' => $value]); } - //-------------------------------------------------------------------- - /** * Runs all of $rules against $field, until one fails, or * all of them have been processed. If one fails, it adds @@ -200,9 +188,9 @@ * * @param string $field * @param string|null $label - * @param string|array $value Value to be validated, can be a string or an array + * @param string|array $value * @param array|null $rules - * @param array $data All of the fields to check. + * @param array $data * * @return boolean */ @@ -215,11 +203,30 @@ if (in_array('if_exist', $rules, true)) { - // If the if_exist rule is defined - // and the current field does not exist in the input data - // we can return true, ignoring all other rules to this field. - if (! array_key_exists($field, array_flatten_with_dots($data))) + $flattenedData = array_flatten_with_dots($data); + $ifExistField = $field; + + if (strpos($field, '.*') !== false) { + // We'll change the dot notation into a PCRE pattern + // that can be used later + $ifExistField = str_replace('\.\*', '\.(?:[^\.]+)', preg_quote($field, '/')); + + $dataIsExisting = array_reduce(array_keys($flattenedData), static function ($carry, $item) use ($ifExistField) { + $pattern = sprintf('/%s/u', $ifExistField); + return $carry || preg_match($pattern, $item) === 1; + }, false); + } + else + { + $dataIsExisting = array_key_exists($ifExistField, $flattenedData); + } + + unset($ifExistField, $flattenedData); + + if (! $dataIsExisting) + { + // we return early if `if_exist` is not satisfied. we have nothing to do here. return true; } @@ -270,12 +277,12 @@ foreach ($rules as $rule) { - $callable = is_callable($rule); - $passed = false; + $isCallable = is_callable($rule); - // Rules can contain parameters: max_length[5] - $param = false; - if (! $callable && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) + $passed = false; + $param = false; + + if (! $isCallable && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) { $rule = $match[1]; $param = $match[2]; @@ -285,7 +292,7 @@ $error = null; // If it's a callable, call and and get out of here. - if ($callable) + if ($isCallable) { $passed = $param === false ? $rule($value) : $rule($value, $param, $data); } @@ -301,9 +308,9 @@ continue; } - $found = true; - + $found = true; $passed = $param === false ? $set->$rule($value, $error) : $set->$rule($value, $param, $data, $error); + break; } @@ -324,7 +331,8 @@ $value = '[' . implode(', ', $value) . ']'; } - $this->errors[$field] = is_null($error) ? $this->getErrorMessage($rule, $field, $label, $param, $value) + $this->errors[$field] = is_null($error) + ? $this->getErrorMessage($rule, $field, $label, $param, $value) : $error; // @phpstan-ignore-line return false; @@ -334,8 +342,6 @@ return true; } - //-------------------------------------------------------------------- - /** * Takes a Request object and grabs the input data to use from its * array values. @@ -346,13 +352,16 @@ */ public function withRequest(RequestInterface $request): ValidationInterface { - if ($request->isJSON()) + /** @var IncomingRequest $request */ + if (strpos($request->getHeaderLine('Content-Type'), 'application/json') !== false) { $this->data = $request->getJSON(true); return $this; } - if (in_array($request->getMethod(), ['put', 'patch', 'delete'], true)) + if (in_array($request->getMethod(), ['put', 'patch', 'delete'], true) + && strpos($request->getHeaderLine('Content-Type'), 'multipart/form-data') === false + ) { $this->data = $request->getRawInput(); } @@ -364,11 +373,6 @@ return $this; } - //-------------------------------------------------------------------- - //-------------------------------------------------------------------- - // Rules - //-------------------------------------------------------------------- - /** * Sets an individual rule and custom error messages for a single field. * @@ -401,8 +405,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Stores the rules that should be used to validate the items. * Rules should be an array formatted like: @@ -430,14 +432,18 @@ foreach ($rules as $field => &$rule) { - if (is_array($rule)) + if (! is_array($rule)) { - if (array_key_exists('errors', $rule)) - { - $this->customErrors[$field] = $rule['errors']; - unset($rule['errors']); - } + continue; } + + if (! array_key_exists('errors', $rule)) + { + continue; + } + + $this->customErrors[$field] = $rule['errors']; + unset($rule['errors']); } $this->rules = $rules; @@ -445,8 +451,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Returns all of the rules currently defined. * @@ -457,8 +461,6 @@ return $this->rules; } - //-------------------------------------------------------------------- - /** * Checks to see if the rule for key $field has been set or not. * @@ -471,8 +473,6 @@ return array_key_exists($field, $this->rules); } - //-------------------------------------------------------------------- - /** * Get rule group. * @@ -497,8 +497,6 @@ return $this->config->$group; } - //-------------------------------------------------------------------- - /** * Set rule group. * @@ -532,12 +530,11 @@ throw ValidationException::forInvalidTemplate($template); } - return $this->view->setVar('errors', $this->getErrors()) - ->render($this->config->templates[$template]); + return $this->view + ->setVar('errors', $this->getErrors()) + ->render($this->config->templates[$template]); } - //-------------------------------------------------------------------- - /** * Displays a single error in formatted HTML as defined in the $template view. * @@ -558,12 +555,11 @@ throw ValidationException::forInvalidTemplate($template); } - return $this->view->setVar('error', $this->getError($field)) - ->render($this->config->templates[$template]); + return $this->view + ->setVar('error', $this->getError($field)) + ->render($this->config->templates[$template]); } - //-------------------------------------------------------------------- - /** * Loads all of the rulesets classes that have been defined in the * Config\Validation and stores them locally so we can use them. @@ -581,8 +577,6 @@ } } - //-------------------------------------------------------------------- - /** * Loads custom rule groups (if set) into the current rules. * @@ -626,8 +620,6 @@ return $this->rules; } - //-------------------------------------------------------------------- - /** * Replace any placeholders within the rules with the values that * match the 'key' of any properties being set. For example, if @@ -684,11 +676,6 @@ return $rules; } - //-------------------------------------------------------------------- - //-------------------------------------------------------------------- - // Errors - //-------------------------------------------------------------------- - /** * Checks to see if an error exists for the given field. * @@ -701,8 +688,6 @@ return array_key_exists($field, $this->getErrors()); } - //-------------------------------------------------------------------- - /** * Returns the error(s) for a specified $field (or empty string if not * set). @@ -715,15 +700,12 @@ { if ($field === null && count($this->rules) === 1) { - reset($this->rules); - $field = key($this->rules); + $field = array_key_first($this->rules); } return array_key_exists($field, $this->getErrors()) ? $this->errors[$field] : ''; } - //-------------------------------------------------------------------- - /** * Returns the array of errors that were encountered during * a run() call. The array should be in the following format: @@ -733,7 +715,7 @@ * 'field2' => 'error message', * ] * - * @return array + * @return array * * Excluded from code coverage because that it always run as cli * @@ -744,19 +726,14 @@ // If we already have errors, we'll use those. // If we don't, check the session to see if any were // passed along from a redirect_with_input request. - if (empty($this->errors) && ! is_cli()) + if (empty($this->errors) && ! is_cli() && isset($_SESSION, $_SESSION['_ci_validation_errors'])) { - if (isset($_SESSION, $_SESSION['_ci_validation_errors'])) - { - $this->errors = unserialize($_SESSION['_ci_validation_errors']); - } + $this->errors = unserialize($_SESSION['_ci_validation_errors']); } return $this->errors ?? []; } - //-------------------------------------------------------------------- - /** * Sets the error for a specific field. Used by custom validation methods. * @@ -772,8 +749,6 @@ return $this; } - //-------------------------------------------------------------------- - /** * Attempts to find the appropriate error message * @@ -817,25 +792,18 @@ { $nonEscapeBracket = '((? 'error message', * ] * - * @return array + * @return array */ public function getErrors(): array; diff --git a/system/View/Cell.php b/system/View/Cell.php index bded0ff..acb58de 100644 --- a/system/View/Cell.php +++ b/system/View/Cell.php @@ -141,7 +141,7 @@ } } - foreach ($paramArray as $key => $val) + foreach (array_keys($paramArray) as $key) { if (! isset($methodParams[$key])) { diff --git a/system/View/Parser.php b/system/View/Parser.php index 7481a9c..9bfb937 100644 --- a/system/View/Parser.php +++ b/system/View/Parser.php @@ -191,7 +191,6 @@ } //-------------------------------------------------------------------- - /** * Sets several pieces of view data at once. * In the Parser, we need to store the context here @@ -540,7 +539,6 @@ } //-------------------------------------------------------------------- - /** * Over-ride the substitution field delimiters. * @@ -572,16 +570,17 @@ { // Any dollar signs in the pattern will be misinterpreted, so slash them $pattern = addcslashes($pattern, '$'); + $content = (string) $content; // Flesh out the main pattern from the delimiters and escape the hash - // See https://regex101.com/r/1GIHTa/1 - if (preg_match('/^(#)(.*)(#(m?s)?)$/', $pattern, $parts)) + // See https://regex101.com/r/IKdUlk/1 + if (preg_match('/^(#)(.+)(#(m?s)?)$/s', $pattern, $parts)) { $pattern = $parts[1] . addcslashes($parts[2], '#') . $parts[3]; } // Replace the content in the template - $template = preg_replace_callback($pattern, function ($matches) use ($content, $escape) { + return preg_replace_callback($pattern, function ($matches) use ($content, $escape) { // Check for {! !} syntax to not escape this one. if (strpos($matches[0], '{!') === 0 && substr($matches[0], -2) === '!}') { @@ -589,9 +588,7 @@ } return $this->prepareReplacement($matches, $content, $escape); - }, $template); - - return $template; + }, (string) $template); } //-------------------------------------------------------------------- @@ -613,12 +610,9 @@ // so we need to break them apart so we can apply them all. $filters = ! empty($matches[1]) ? explode('|', $matches[1]) : []; - if ($escape && empty($filters)) + if ($escape && empty($filters) && ($context = $this->shouldAddEscaping($orig))) { - if ($context = $this->shouldAddEscaping($orig)) - { - $filters[] = "esc({$context})"; - } + $filters[] = "esc({$context})"; } return $this->applyFilters($replace, $filters); diff --git a/system/View/Table.php b/system/View/Table.php index f25b5c5..aa1ae36 100644 --- a/system/View/Table.php +++ b/system/View/Table.php @@ -98,8 +98,6 @@ } } - // -------------------------------------------------------------------- - /** * Set the template * @@ -117,8 +115,6 @@ return true; } - // -------------------------------------------------------------------- - /** * Set the table heading * @@ -145,8 +141,6 @@ return $this; } - // -------------------------------------------------------------------- - /** * Set columns. Takes a one-dimensional array as input and creates * a multi-dimensional array with a depth equal to the number of @@ -194,8 +188,6 @@ return $new; } - // -------------------------------------------------------------------- - /** * Set "empty" cells * @@ -210,8 +202,6 @@ return $this; } - // -------------------------------------------------------------------- - /** * Add a table row * @@ -225,8 +215,6 @@ return $this; } - // -------------------------------------------------------------------- - /** * Prep Args * @@ -247,14 +235,15 @@ foreach ($args as $key => $val) { - is_array($val) || $args[$key] = ['data' => $val]; // @phpstan-ignore-line + if (! is_array($val)) + { + $args[$key] = ['data' => $val]; + } } return $args; } - // -------------------------------------------------------------------- - /** * Add a table caption * @@ -267,8 +256,6 @@ return $this; } - // -------------------------------------------------------------------- - /** * Generate the table * @@ -422,8 +409,6 @@ return $out; } - // -------------------------------------------------------------------- - /** * Clears the table arrays. Useful if multiple tables are being generated * @@ -439,8 +424,6 @@ return $this; } - // -------------------------------------------------------------------- - /** * Set table data from a database result object * @@ -450,7 +433,7 @@ protected function _setFromDBResult($object) { // First generate the headings from the table column names - if ($this->autoHeading === true && empty($this->heading)) + if ($this->autoHeading && empty($this->heading)) { $this->heading = $this->_prepArgs($object->getFieldNames()); } @@ -461,17 +444,16 @@ } } - // -------------------------------------------------------------------- - /** * Set table data from an array * - * @param array $data + * @param array $data + * * @return void */ protected function _setFromArray($data) { - if ($this->autoHeading === true && empty($this->heading)) + if ($this->autoHeading && empty($this->heading)) { $this->heading = $this->_prepArgs(array_shift($data)); } @@ -482,8 +464,6 @@ } } - // -------------------------------------------------------------------- - /** * Compile Template * @@ -497,18 +477,15 @@ return; } - $temp = $this->_defaultTemplate(); - foreach (['table_open', 'thead_open', 'thead_close', 'heading_row_start', 'heading_row_end', 'heading_cell_start', 'heading_cell_end', 'tbody_open', 'tbody_close', 'row_start', 'row_end', 'cell_start', 'cell_end', 'row_alt_start', 'row_alt_end', 'cell_alt_start', 'cell_alt_end', 'table_close'] as $val) + foreach ($this->_defaultTemplate() as $field => $template) { - if (! isset($this->template[$val])) + if (! isset($this->template[$field])) { - $this->template[$val] = $temp[$val]; + $this->template[$field] = $template; } } } - // -------------------------------------------------------------------- - /** * Default Template * @@ -543,6 +520,4 @@ 'table_close' => '', ]; } - - // -------------------------------------------------------------------- } diff --git a/system/View/View.php b/system/View/View.php index 9357293..33efdaa 100644 --- a/system/View/View.php +++ b/system/View/View.php @@ -109,6 +109,7 @@ */ protected $layout; + /** * Holds the sections and their data. * @@ -121,10 +122,19 @@ * if any. * * @var string|null + * @deprecated */ protected $currentSection; /** + * The name of the current section being rendered, + * if any. + * + * @var array + */ + protected $sectionStack = []; + + /** * Constructor * * @param ViewConfig $config @@ -227,7 +237,7 @@ // When using layouts, the data has already been stored // in $this->sections, and no other valid output // is allowed in $output so we'll overwrite it. - if (! is_null($this->layout) && empty($this->currentSection)) + if (! is_null($this->layout) && $this->sectionStack === []) { $layoutView = $this->layout; $this->layout = null; @@ -402,35 +412,44 @@ /** * Starts holds content for a section within the layout. * - * @param string $name + * @param string $name Section name + * + * @return void + * */ public function section(string $name) { + //Saved to prevent BC. $this->currentSection = $name; + $this->sectionStack[] = $name; ob_start(); } /** + * Captures the last section + * + * @return void * @throws RuntimeException */ public function endSection() { $contents = ob_get_clean(); - if (empty($this->currentSection)) + if ($this->sectionStack === []) { throw new RuntimeException('View themes, no current section.'); } - // Ensure an array exists so we can store multiple entries for this. - if (! array_key_exists($this->currentSection, $this->sections)) - { - $this->sections[$this->currentSection] = []; - } - $this->sections[$this->currentSection][] = $contents; + $section = array_pop($this->sectionStack); - $this->currentSection = null; + // Ensure an array exists so we can store multiple entries for this. + if (! array_key_exists($section, $this->sections)) + { + $this->sections[$section] = []; + } + + $this->sections[$section][] = $contents; } /** diff --git a/system/bootstrap.php b/system/bootstrap.php index 531182b..d822c0c 100644 --- a/system/bootstrap.php +++ b/system/bootstrap.php @@ -9,9 +9,7 @@ * file that was distributed with this source code. */ -use CodeIgniter\CodeIgniter; use CodeIgniter\Config\DotEnv; -use Config\App; use Config\Autoload; use Config\Modules; use Config\Paths; @@ -155,7 +153,7 @@ * the pieces all working together. */ -$app = new CodeIgniter(new App()); +$app = Services::codeigniter(); $app->initialize(); return $app;