diff --git a/.gitignore b/.gitignore index e82f525..11abea6 100644 --- a/.gitignore +++ b/.gitignore @@ -84,7 +84,6 @@ # Composer #------------------------- vendor/ -composer.lock #------------------------- # IDE / Development Files @@ -125,3 +124,4 @@ /results/ /phpunit*.xml /.phpunit.*.cache + diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index 6a673d3..eaecfdf 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -1,6 +1,8 @@ - SYSTEMPATH, + * 'App' => APPPATH + * ]; + * + * @var array */ - public function __construct() - { - parent::__construct(); + public $psr4 = [ + APP_NAMESPACE => APPPATH, // For custom app namespace + 'Config' => APPPATH . 'Config', + ]; - /** - * ------------------------------------------------------------------- - * Namespaces - * ------------------------------------------------------------------- - * This maps the locations of any namespaces in your application - * to their location on the file system. These are used by the - * Autoloader to locate files the first time they have been instantiated. - * - * The '/app' and '/system' directories are already mapped for - * you. You may change the name of the 'App' namespace if you wish, - * but this should be done prior to creating any namespaced classes, - * else you will need to modify all of those classes for this to work. - * - * DO NOT change the name of the CodeIgniter namespace or your application - * WILL break. * - * Prototype: - * - * $Config['psr4'] = [ - * 'CodeIgniter' => SYSPATH - * `]; - */ - $psr4 = [ - 'App' => APPPATH, // To ensure filters, etc still found, - APP_NAMESPACE => APPPATH, // For custom namespace - 'Config' => APPPATH . 'Config', - ]; - - /** - * ------------------------------------------------------------------- - * Class Map - * ------------------------------------------------------------------- - * The class map provides a map of class names and their exact - * location on the drive. Classes loaded in this manner will have - * slightly faster performance because they will not have to be - * searched for within one or more directories as they would if they - * were being autoloaded through a namespace. - * - * Prototype: - * - * $Config['classmap'] = [ - * 'MyClass' => '/path/to/class/file.php' - * ]; - */ - $classmap = []; - - //-------------------------------------------------------------------- - // Do Not Edit Below This Line - //-------------------------------------------------------------------- - - $this->psr4 = array_merge($this->psr4, $psr4); - $this->classmap = array_merge($this->classmap, $classmap); - - unset($psr4, $classmap); - } - - //-------------------------------------------------------------------- - + /** + * ------------------------------------------------------------------- + * Class Map + * ------------------------------------------------------------------- + * The class map provides a map of class names and their exact + * location on the drive. Classes loaded in this manner will have + * slightly faster performance because they will not have to be + * searched for within one or more directories as they would if they + * were being autoloaded through a namespace. + * + * Prototype: + * + * $classmap = [ + * 'MyClass' => '/path/to/class/file.php' + * ]; + * + * @var array + */ + public $classmap = []; } diff --git a/app/Config/Boot/development.php b/app/Config/Boot/development.php index 7d6ae48..63fdd88 100644 --- a/app/Config/Boot/development.php +++ b/app/Config/Boot/development.php @@ -29,4 +29,4 @@ | items. It can always be used within your own application too. */ -defined('CI_DEBUG') || define('CI_DEBUG', 1); +defined('CI_DEBUG') || define('CI_DEBUG', true); diff --git a/app/Config/Boot/production.php b/app/Config/Boot/production.php index c54bdbd..1241907 100644 --- a/app/Config/Boot/production.php +++ b/app/Config/Boot/production.php @@ -19,4 +19,4 @@ | release of the framework. */ -defined('CI_DEBUG') || define('CI_DEBUG', 0); +defined('CI_DEBUG') || define('CI_DEBUG', false); diff --git a/app/Config/Boot/testing.php b/app/Config/Boot/testing.php index e6c94d7..fab6c07 100644 --- a/app/Config/Boot/testing.php +++ b/app/Config/Boot/testing.php @@ -30,4 +30,4 @@ | release of the framework. */ -defined('CI_DEBUG') || define('CI_DEBUG', 1); +defined('CI_DEBUG') || define('CI_DEBUG', true); diff --git a/app/Config/Events.php b/app/Config/Events.php index 085cc4a..14bfd32 100644 --- a/app/Config/Events.php +++ b/app/Config/Events.php @@ -1,6 +1,7 @@ 0) + if (ini_get('zlib.output_compression')) { - \ob_end_flush(); + throw FrameworkException::forEnabledZlibOutputCompression(); } - \ob_start(function ($buffer) { + while (ob_get_level() > 0) + { + ob_end_flush(); + } + + ob_start(function ($buffer) { return $buffer; }); } diff --git a/app/Config/Exceptions.php b/app/Config/Exceptions.php index c0245b2..5fe33d3 100644 --- a/app/Config/Exceptions.php +++ b/app/Config/Exceptions.php @@ -1,12 +1,13 @@ \CodeIgniter\Format\XMLFormatter::class, 'text/xml' => \CodeIgniter\Format\XMLFormatter::class, ]; - + + /* + |-------------------------------------------------------------------------- + | Formatters Options + |-------------------------------------------------------------------------- + | + | Additional Options to adjust default formatters behaviour. + | For each mime type, list the additional options that should be used. + | + */ + public $formatterOptions = [ + 'application/json' => JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, + 'application/xml' => 0, + 'text/xml' => 0, + ]; //-------------------------------------------------------------------- /** diff --git a/app/Config/Honeypot.php b/app/Config/Honeypot.php index f4444a5..3d9e372 100644 --- a/app/Config/Honeypot.php +++ b/app/Config/Honeypot.php @@ -11,6 +11,7 @@ * @var boolean */ public $hidden = true; + /** * Honeypot Label Content * @@ -31,4 +32,11 @@ * @var string */ public $template = ''; + + /** + * Honeypot container + * + * @var string + */ + public $container = '
{template}
'; } diff --git a/app/Config/Images.php b/app/Config/Images.php index 730ddee..a416b8b 100644 --- a/app/Config/Images.php +++ b/app/Config/Images.php @@ -22,7 +22,7 @@ /** * The available handler classes. * - * @var array + * @var \CodeIgniter\Images\Handlers\BaseHandler[] */ public $handlers = [ 'gd' => \CodeIgniter\Images\Handlers\GDHandler::class, diff --git a/app/Config/Modules.php b/app/Config/Modules.php index 28bbc7d..40cb987 100644 --- a/app/Config/Modules.php +++ b/app/Config/Modules.php @@ -1,7 +1,10 @@ -enabled) - { - return false; - } - - $alias = strtolower($alias); - - return in_array($alias, $this->activeExplorers); - } } diff --git a/app/Config/Routes.php b/app/Config/Routes.php index a2a9654..56839ca 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -38,7 +38,7 @@ * -------------------------------------------------------------------- * * There will often be times that you need additional routing and you - * need to it be able to override any defaults in this file. Environment + * need it to be able to override any defaults in this file. Environment * based routes is one such time. require() additional route files here * to make that happen. * diff --git a/app/Config/Services.php b/app/Config/Services.php index fb85a73..c58da70 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -2,8 +2,6 @@ use CodeIgniter\Config\Services as CoreServices; -require_once SYSTEMPATH . 'Config/Services.php'; - /** * Services Configuration file. * diff --git a/app/Language/en/Validation.php b/app/Language/en/Validation.php new file mode 100644 index 0000000..54d1e7a --- /dev/null +++ b/app/Language/en/Validation.php @@ -0,0 +1,4 @@ +=7.2", - "ext-curl": "*", - "ext-intl": "*", - "ext-json": "*", - "ext-mbstring": "*", - "kint-php/kint": "^3.3", - "psr/log": "^1.1", - "laminas/laminas-escaper": "^2.6" - }, - "require-dev": { - "codeigniter4/codeigniter4-standard": "^1.0", - "mikey179/vfsstream": "1.6.*", - "phpunit/phpunit": "^8.5", - "squizlabs/php_codesniffer": "^3.3" - }, - "autoload": { - "psr-4": { - "CodeIgniter\\": "system/" - } - }, - "scripts": { - "post-update-cmd": [ - "@composer dump-autoload", - "CodeIgniter\\ComposerScripts::postUpdate", - "bash admin/setup.sh" - ] - }, - "support": { - "forum": "http://forum.codeigniter.com/", - "source": "https://github.com/codeigniter4/CodeIgniter4", - "slack": "https://codeigniterchat.slack.com" - } + "name": "codeigniter4/framework", + "type": "project", + "description": "The CodeIgniter framework v4", + "homepage": "https://codeigniter.com", + "license": "MIT", + "require": { + "php": ">=7.2", + "ext-curl": "*", + "ext-intl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "kint-php/kint": "^3.3", + "laminas/laminas-escaper": "^2.6", + "psr/log": "^1.1" + }, + "require-dev": { + "codeigniter4/codeigniter4-standard": "^1.0", + "fzaninotto/faker": "^1.9@dev", + "mikey179/vfsstream": "1.6.*", + "phpunit/phpunit": "^8.5", + "predis/predis": "^1.1", + "squizlabs/php_codesniffer": "^3.3" + }, + "autoload": { + "psr-4": { + "CodeIgniter\\": "system/" + } + }, + "scripts": { + "post-update-cmd": [ + "@composer dump-autoload", + "CodeIgniter\\ComposerScripts::postUpdate" + ], + "test": "phpunit" + }, + "support": { + "forum": "http://forum.codeigniter.com/", + "source": "https://github.com/codeigniter4/CodeIgniter4", + "slack": "https://codeigniterchat.slack.com" + } } diff --git a/env b/env index bc1a156..11f4161 100644 --- a/env +++ b/env @@ -84,10 +84,18 @@ # contentsecuritypolicy.upgradeInsecureRequests = false #-------------------------------------------------------------------- +# ENCRYPTION +#-------------------------------------------------------------------- + +# encryption.key = +# encryption.driver = OpenSSL + +#-------------------------------------------------------------------- # HONEYPOT #-------------------------------------------------------------------- -# honeypot.hidden = 'true' -# honeypot.label = 'Fill This Field' -# honeypot.name = 'honeypot' -# honeypot.template = '' +# honeypot.hidden = 'true' +# honeypot.label = 'Fill This Field' +# honeypot.name = 'honeypot' +# honeypot.template = '' +# honeypot.container = '
{template}
' diff --git a/public/.htaccess b/public/.htaccess index 699e1c1..02026a3 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -18,7 +18,7 @@ # Redirect Trailing Slashes... RewriteCond %{REQUEST_FILENAME} !-d - RewriteRule ^(.*)/$ /$1 [L,R=301] + RewriteRule ^(.*)/$ /$1 [L,R=301] # Rewrite "www.example.com -> example.com" RewriteCond %{HTTPS} !=on @@ -30,7 +30,7 @@ # request to the front controller, index.php RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d - RewriteRule ^(.*)$ index.php/$1 [L] + RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA] # Ensure Authorization header is passed along RewriteCond %{HTTP:Authorization} . diff --git a/spark b/spark index 396ad59..0a0908d 100755 --- a/spark +++ b/spark @@ -24,7 +24,7 @@ */ // Refuse to run when called from php-cgi -if (substr(php_sapi_name(), 0, 3) === 'cgi') +if (strpos(php_sapi_name(), 'cgi') === 0) { die("The cli tool is not supported when running php-cgi. It needs php-cli to function!\n\n"); } diff --git a/system/Autoloader/FileLocator.php b/system/Autoloader/FileLocator.php index 5aa217e..c9d2b78 100644 --- a/system/Autoloader/FileLocator.php +++ b/system/Autoloader/FileLocator.php @@ -171,34 +171,38 @@ { $php = file_get_contents($file); $tokens = token_get_all($php); - $count = count($tokens); $dlm = false; $namespace = ''; $class_name = ''; - for ($i = 2; $i < $count; $i++) + foreach ($tokens as $i => $token) { - if ((isset($tokens[$i - 2][1]) && ($tokens[$i - 2][1] === 'phpnamespace' || $tokens[$i - 2][1] === 'namespace')) || ($dlm && $tokens[$i - 1][0] === T_NS_SEPARATOR && $tokens[$i][0] === T_STRING)) + if ($i < 2) + { + continue; + } + + if ((isset($tokens[$i - 2][1]) && ($tokens[$i - 2][1] === 'phpnamespace' || $tokens[$i - 2][1] === 'namespace')) || ($dlm && $tokens[$i - 1][0] === T_NS_SEPARATOR && $token[0] === T_STRING)) { if (! $dlm) { $namespace = 0; } - if (isset($tokens[$i][1])) + if (isset($token[1])) { - $namespace = $namespace ? $namespace . '\\' . $tokens[$i][1] : $tokens[$i][1]; + $namespace = $namespace ? $namespace . '\\' . $token[1] : $token[1]; $dlm = true; } } - elseif ($dlm && ($tokens[$i][0] !== T_NS_SEPARATOR) && ($tokens[$i][0] !== T_STRING)) + elseif ($dlm && ($token[0] !== T_NS_SEPARATOR) && ($token[0] !== T_STRING)) { $dlm = false; } if (($tokens[$i - 2][0] === T_CLASS || (isset($tokens[$i - 2][1]) && $tokens[$i - 2][1] === 'phpclass')) && $tokens[$i - 1][0] === T_WHITESPACE - && $tokens[$i][0] === T_STRING) + && $token[0] === T_STRING) { - $class_name = $tokens[$i][1]; + $class_name = $token[1]; break; } } @@ -226,25 +230,47 @@ * 'app/Modules/bar/Config/Routes.php', * ] * - * @param string $path - * @param string $ext + * @param string $path + * @param string $ext + * @param boolean $prioritizeApp * * @return array */ - public function search(string $path, string $ext = 'php'): array + public function search(string $path, string $ext = 'php', bool $prioritizeApp = true): array { $path = $this->ensureExt($path, $ext); $foundPaths = []; + $appPaths = []; foreach ($this->getNamespaces() as $namespace) { if (isset($namespace['path']) && is_file($namespace['path'] . $path)) { - $foundPaths[] = $namespace['path'] . $path; + $fullPath = $namespace['path'] . $path; + if ($prioritizeApp) + { + $foundPaths[] = $fullPath; + } + else + { + if (strpos($fullPath, APPPATH) === 0) + { + $appPaths[] = $fullPath; + } + else + { + $foundPaths[] = $fullPath; + } + } } } + if (! $prioritizeApp && ! empty($appPaths)) + { + $foundPaths = array_merge($foundPaths, $appPaths); + } + // Remove any duplicates $foundPaths = array_unique($foundPaths); diff --git a/system/CLI/BaseCommand.php b/system/CLI/BaseCommand.php index 7f81e9e..024daf1 100644 --- a/system/CLI/BaseCommand.php +++ b/system/CLI/BaseCommand.php @@ -1,4 +1,5 @@ logger = $logger; $this->commands = $commands; @@ -151,7 +152,7 @@ // for the command name. array_unshift($params, $command); - return $this->commands->index($params); + return $this->commands->run($command, $params); } //-------------------------------------------------------------------- diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 476c08d..4b6d3d2 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -94,7 +94,7 @@ 'black' => '0;30', 'dark_gray' => '1;30', 'blue' => '0;34', - 'dark_blue' => '1;34', + 'dark_blue' => '0;34', 'light_blue' => '1;34', 'green' => '0;32', 'light_green' => '1;32', @@ -104,8 +104,8 @@ 'light_red' => '1;31', 'purple' => '0;35', 'light_purple' => '1;35', - 'light_yellow' => '0;33', - 'yellow' => '1;33', + 'yellow' => '0;33', + 'light_yellow' => '1;33', 'light_gray' => '0;37', 'white' => '1;37', ]; @@ -147,6 +147,27 @@ */ protected static $lastWrite; + /** + * Height of the CLI window + * + * @var integer + */ + protected static $height; + + /** + * Width of the CLI window + * + * @var integer + */ + protected static $width; + + /** + * Whether the current stream supports colored output. + * + * @var boolean + */ + protected static $isColored = false; + //-------------------------------------------------------------------- /** @@ -154,18 +175,32 @@ */ public static function init() { - // Readline is an extension for PHP that makes interactivity with PHP - // much more bash-like. - // http://www.php.net/manual/en/readline.installation.php - static::$readline_support = extension_loaded('readline'); + if (is_cli()) + { + // Readline is an extension for PHP that makes interactivity with PHP + // much more bash-like. + // http://www.php.net/manual/en/readline.installation.php + static::$readline_support = extension_loaded('readline'); - // clear segments & options to keep testing clean - static::$segments = []; - static::$options = []; + // clear segments & options to keep testing clean + static::$segments = []; + static::$options = []; - static::parseCommandLine(); + // Check our stream resource for color support + static::$isColored = static::hasColorSupport(STDOUT); - static::$initialized = true; + static::parseCommandLine(); + + static::$initialized = true; + } + else + { + // If the command is being called from a controller + // we need to define STDOUT ourselves + // @codeCoverageIgnoreStart + define('STDOUT', 'php://output'); + // @codeCoverageIgnoreEnd + } } //-------------------------------------------------------------------- @@ -216,7 +251,8 @@ * @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 * - * @return string The user input + * @return string The user input + * * @codeCoverageIgnore */ public static function prompt(string $field, $options = null, string $validation = null): string @@ -251,7 +287,7 @@ $default = $options[0]; } - fwrite(STDOUT, $field . $extra_output . ': '); + static::fwrite(STDOUT, $field . $extra_output . ': '); // Read the input from keyboard. $input = trim(static::input()) ?: $default; @@ -276,13 +312,16 @@ * @param string $value Input value * @param string $rules Validation rules * - * @return boolean + * @return boolean + * * @codeCoverageIgnore */ protected static function validate(string $field, string $value, string $rules): bool { + $label = $field; + $field = 'temp'; $validation = \Config\Services::validation(null, false); - $validation->setRule($field, null, $rules); + $validation->setRule($field, $label, $rules); $validation->run([$field => $value]); if ($validation->hasError($field)) @@ -314,7 +353,7 @@ static::$lastWrite = null; - fwrite(STDOUT, $text); + static::fwrite(STDOUT, $text); } /** @@ -337,7 +376,7 @@ static::$lastWrite = 'write'; } - fwrite(STDOUT, $text . PHP_EOL); + static::fwrite(STDOUT, $text . PHP_EOL); } //-------------------------------------------------------------------- @@ -351,12 +390,19 @@ */ public static function error(string $text, string $foreground = 'light_red', string $background = null) { + // Check color support for STDERR + $stdout = static::$isColored; + static::$isColored = static::hasColorSupport(STDERR); + if ($foreground || $background) { $text = static::color($text, $foreground, $background); } - fwrite(STDERR, $text . PHP_EOL); + static::fwrite(STDERR, $text . PHP_EOL); + + // return STDOUT color support + static::$isColored = $stdout; } //-------------------------------------------------------------------- @@ -388,7 +434,7 @@ while ($time > 0) { - fwrite(STDOUT, $time . '... '); + static::fwrite(STDOUT, $time . '... '); sleep(1); $time --; } @@ -446,18 +492,17 @@ /** * Clears the screen of output * - * @return void + * @return void + * * @codeCoverageIgnore */ public static function clearScreen() { - static::isWindows() - - // Windows is a bit crap at this, but their terminal is tiny so shove this in - ? static::newLine(40) - - // Anything with a flair of Unix will handle these magic characters - : fwrite(STDOUT, chr(27) . '[H' . chr(27) . '[2J'); + // Unix systems, and Windows with VT100 Terminal support (i.e. Win10) + // can handle CSI sequences. For lower than Win10 we just shove in 40 new lines. + static::isWindows() && ! static::streamSupports('sapi_windows_vt100_support', STDOUT) + ? static::newLine(40) + : static::fwrite(STDOUT, "\033[H\033[2J"); } //-------------------------------------------------------------------- @@ -475,11 +520,9 @@ */ public static function color(string $text, string $foreground, string $background = null, string $format = null): string { - if (static::isWindows() && ! isset($_SERVER['ANSICON'])) + if (! static::$isColored) { - // @codeCoverageIgnoreStart return $text; - // @codeCoverageIgnoreEnd } if (! array_key_exists($foreground, static::$foreground_colors)) @@ -504,7 +547,37 @@ $string .= "\033[4m"; } - return $string . ($text . "\033[0m"); + // Detect if color method was already in use with this text + if (strpos($text, "\033[0m") !== false) + { + // Split the text into parts so that we can see + // if any part missing the color definition + $chunks = mb_split("\\033\[0m", $text); + // Reset text + $text = ''; + + foreach ($chunks as $chunk) + { + if ($chunk === '') + { + continue; + } + + // If chunk doesn't have colors defined we need to add them + if (strpos($chunk, "\033[") === false) + { + $chunk = static::color($chunk, $foreground, $background, $format); + // Add color reset before chunk and clear end of the string + $text .= rtrim("\033[0m" . $chunk, "\033[0m"); + } + else + { + $text .= $chunk; + } + } + } + + return $string . $text . "\033[0m"; } //-------------------------------------------------------------------- @@ -523,6 +596,7 @@ { return 0; } + foreach (static::$foreground_colors as $color) { $string = strtr($string, ["\033[" . $color . 'm' => '']); @@ -541,9 +615,74 @@ //-------------------------------------------------------------------- /** + * Checks whether the current stream resource supports or + * refers to a valid terminal type device. + * + * @param string $function + * @param resource $resource + * + * @return boolean + */ + public static function streamSupports(string $function, $resource): bool + { + if (ENVIRONMENT === 'testing') + { + // In the current setup of the tests we cannot fully check + // if the stream supports the function since we are using + // filtered streams. + return function_exists($function); + } + + // @codeCoverageIgnoreStart + return function_exists($function) && @$function($resource); + // @codeCoverageIgnoreEnd + } + + //-------------------------------------------------------------------- + + /** + * Returns true if the stream resource supports colors. + * + * This is tricky on Windows, because Cygwin, Msys2 etc. emulate pseudo + * terminals via named pipes, so we can only check the environment. + * + * Reference: https://github.com/composer/xdebug-handler/blob/master/src/Process.php + * + * @param resource $resource + * + * @return boolean + */ + public static function hasColorSupport($resource): bool + { + // Follow https://no-color.org/ + if (isset($_SERVER['NO_COLOR']) || getenv('NO_COLOR') !== false) + { + return false; + } + + if (getenv('TERM_PROGRAM') === 'Hyper') + { + return true; + } + + if (static::isWindows()) + { + // @codeCoverageIgnoreStart + return static::streamSupports('sapi_windows_vt100_support', $resource) + || isset($_SERVER['ANSICON']) + || getenv('ANSICON') !== false + || getenv('ConEmuANSI') === 'ON' + || getenv('TERM') === 'xterm'; + // @codeCoverageIgnoreEnd + } + + return static::streamSupports('stream_isatty', $resource); + } + + //-------------------------------------------------------------------- + + /** * Attempts to determine the width of the viewable CLI window. - * This only works on *nix-based systems, so return a sane default - * for Windows environments. * * @param integer $default * @@ -551,22 +690,18 @@ */ public static function getWidth(int $default = 80): int { - if (static::isWindows() || (int) shell_exec('tput cols') === 0) + if (\is_null(static::$width)) { - // @codeCoverageIgnoreStart - return $default; - // @codeCoverageIgnoreEnd + static::generateDimensions(); } - return (int) shell_exec('tput cols'); + return static::$width ?: $default; } //-------------------------------------------------------------------- /** * Attempts to determine the height of the viewable CLI window. - * This only works on *nix-based systems, so return a sane default - * for Windows environments. * * @param integer $default * @@ -574,14 +709,67 @@ */ public static function getHeight(int $default = 32): int { - if (static::isWindows()) + if (\is_null(static::$height)) { - // @codeCoverageIgnoreStart - return $default; - // @codeCoverageIgnoreEnd + static::generateDimensions(); } - return (int) shell_exec('tput lines'); + return static::$height ?: $default; + } + + //-------------------------------------------------------------------- + + /** + * Populates the CLI's dimensions. + * + * @return void + */ + public static function generateDimensions() + { + if (static::isWindows()) + { + // Shells such as `Cygwin` and `Git bash` returns incorrect values + // when executing `mode CON`, so we use `tput` instead + // @codeCoverageIgnoreStart + if (($shell = getenv('SHELL')) && preg_match('/(?:bash|zsh)(?:\.exe)?$/', $shell) || getenv('TERM')) + { + static::$height = (int) exec('tput lines'); + static::$width = (int) exec('tput cols'); + } + else + { + $return = -1; + $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 (preg_match('/:\s*(\d+)\n[^:]+:\s*(\d+)\n/', implode("\n", $output), $matches)) + { + static::$height = (int) $matches[1]; + static::$width = (int) $matches[2]; + } + } + } + // @codeCoverageIgnoreEnd + } + 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 + } + } } //-------------------------------------------------------------------- @@ -600,7 +788,7 @@ // restore cursor position when progress is continuing. if ($inProgress !== false && $inProgress <= $thisStep) { - fwrite(STDOUT, "\033[1A"); + static::fwrite(STDOUT, "\033[1A"); } $inProgress = $thisStep; @@ -614,13 +802,13 @@ $step = (int) round($percent / 10); // Write the progress bar - fwrite(STDOUT, "[\033[32m" . str_repeat('#', $step) . str_repeat('.', 10 - $step) . "\033[0m]"); + static::fwrite(STDOUT, "[\033[32m" . str_repeat('#', $step) . str_repeat('.', 10 - $step) . "\033[0m]"); // Textual representation... - fwrite(STDOUT, sprintf(' %3d%% Complete', $percent) . PHP_EOL); + static::fwrite(STDOUT, sprintf(' %3d%% Complete', $percent) . PHP_EOL); } else { - fwrite(STDOUT, "\007"); + static::fwrite(STDOUT, "\007"); } } @@ -660,7 +848,7 @@ $max = $max - $pad_left; - $lines = wordwrap($string, $max); + $lines = wordwrap($string, $max, PHP_EOL); if ($pad_left > 0) { @@ -705,7 +893,7 @@ { // If there's no '-' at the beginning of the argument // then add it to our segments. - if (mb_strpos($_SERVER['argv'][$i], '-') === false) + if (mb_strpos($_SERVER['argv'][$i], '-') !== 0) { static::$segments[] = $_SERVER['argv'][$i]; continue; @@ -949,6 +1137,30 @@ } //-------------------------------------------------------------------- + + /** + * While the library is intended for use on CLI commands, + * commands can be called from controllers and elsewhere + * so we need a way to allow them to still work. + * + * For now, just echo the content, but look into a better + * solution down the road. + * + * @param resource $handle + * @param string $string + */ + protected static function fwrite($handle, string $string) + { + if (is_cli()) + { + fwrite($handle, $string); + return; + } + + // @codeCoverageIgnoreStart + echo $string; + // @codeCoverageIgnoreEnd + } } // Ensure the class is initialized. Done outside of code coverage diff --git a/system/CLI/CommandRunner.php b/system/CLI/CommandRunner.php index 2e1a8e1..466ef33 100644 --- a/system/CLI/CommandRunner.php +++ b/system/CLI/CommandRunner.php @@ -1,6 +1,5 @@ commands = service('commands'); + } + + /** * We map all un-routed CLI methods through this function * so we have the chance to look for a Command first. * @@ -100,104 +99,14 @@ { $command = array_shift($params); - $this->createCommandList(); - if (is_null($command)) { $command = 'list'; } - return $this->runCommand($command, $params); + return service('commands')->run($command, $params); } - //-------------------------------------------------------------------- - - /** - * Actually runs the command. - * - * @param string $command - * @param array $params - * - * @return mixed - */ - protected function runCommand(string $command, array $params) - { - if (! isset($this->commands[$command])) - { - CLI::error(lang('CLI.commandNotFound', [$command])); - CLI::newLine(); - return; - } - - // The file would have already been loaded during the - // createCommandList function... - $className = $this->commands[$command]['class']; - $class = new $className($this->logger, $this); - - return $class->run($params); - } - - //-------------------------------------------------------------------- - - /** - * Scans all Commands directories and prepares a list - * of each command with it's group and file. - * - * @throws \ReflectionException - */ - protected function createCommandList() - { - $files = Services::locator()->listFiles('Commands/'); - - // If no matching command files were found, bail - if (empty($files)) - { - // This should never happen in unit testing. - // if it does, we have far bigger problems! - // @codeCoverageIgnoreStart - return; - // @codeCoverageIgnoreEnd - } - - // 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. - foreach ($files as $file) - { - $className = Services::locator()->findQualifiedNameFromPath($file); - if (empty($className) || ! class_exists($className)) - { - continue; - } - - $class = new \ReflectionClass($className); - - if (! $class->isInstantiable() || ! $class->isSubclassOf(BaseCommand::class)) - { - continue; - } - - $class = new $className($this->logger, $this); - - // Store it! - if ($class->group !== null) - { - $this->commands[$class->name] = [ - 'class' => $className, - 'file' => $file, - 'group' => $class->group, - 'description' => $class->description, - ]; - } - - $class = null; - unset($class); - } - - asort($this->commands); - } - - //-------------------------------------------------------------------- - /** * Allows access to the current commands that have been found. * @@ -205,8 +114,6 @@ */ public function getCommands(): array { - return $this->commands; + return $this->commands->getCommands(); } - - //-------------------------------------------------------------------- } diff --git a/system/CLI/Commands.php b/system/CLI/Commands.php new file mode 100644 index 0000000..cb6d8fd --- /dev/null +++ b/system/CLI/Commands.php @@ -0,0 +1,181 @@ +logger = $logger ?? service('logger'); + } + + /** + * Runs a command given + * + * @param string $command + * @param array $params + */ + public function run(string $command, array $params) + { + $this->discoverCommands(); + + if (! isset($this->commands[$command])) + { + CLI::error(lang('CLI.commandNotFound', [$command])); + CLI::newLine(); + return; + } + + // The file would have already been loaded during the + // createCommandList function... + $className = $this->commands[$command]['class']; + $class = new $className($this->logger, $this); + + return $class->run($params); + } + + /** + * Provide access to the list of commands. + * + * @return array + */ + 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. + */ + public function discoverCommands() + { + if (! empty($this->commands)) + { + return; + } + + $files = service('locator')->listFiles('Commands/'); + + // If no matching command files were found, bail + if (empty($files)) + { + // This should never happen in unit testing. + // if it does, we have far bigger problems! + // @codeCoverageIgnoreStart + return; + // @codeCoverageIgnoreEnd + } + + // 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. + foreach ($files as $file) + { + $className = Services::locator()->findQualifiedNameFromPath($file); + if (empty($className) || ! class_exists($className)) + { + continue; + } + + try + { + $class = new \ReflectionClass($className); + + if (! $class->isInstantiable() || ! $class->isSubclassOf(BaseCommand::class)) + { + continue; + } + + $class = new $className($this->logger, $this); + + // Store it! + if ($class->group !== null) + { + $this->commands[$class->name] = [ + 'class' => $className, + 'file' => $file, + 'group' => $class->group, + 'description' => $class->description, + ]; + } + + $class = null; + unset($class); + } + catch (\ReflectionException $e) + { + $this->logger->error($e->getMessage()); + } + } + + asort($this->commands); + } +} diff --git a/system/CLI/Exceptions/CLIException.php b/system/CLI/Exceptions/CLIException.php index 6e2477f..474064e 100644 --- a/system/CLI/Exceptions/CLIException.php +++ b/system/CLI/Exceptions/CLIException.php @@ -1,8 +1,53 @@ -prefix = $config['prefix'] ?? ''; + $this->prefix = $config->prefix ?: ''; if (! empty($config)) { - $this->config = array_merge($this->config, $config['memcached']); + $this->config = array_merge($this->config, $config->memcached); } } @@ -248,7 +246,8 @@ { return $this->memcached->set($key, $value, $ttl); } - elseif ($this->memcached instanceof \Memcache) + + if ($this->memcached instanceof \Memcache) { return $this->memcached->set($key, $value, 0, $ttl); } @@ -263,7 +262,7 @@ * * @param string $key Cache item name * - * @return mixed + * @return boolean */ public function delete(string $key) { @@ -322,7 +321,7 @@ /** * Will delete all items in the entire cache. * - * @return mixed + * @return boolean */ public function clean() { diff --git a/system/Cache/Handlers/PredisHandler.php b/system/Cache/Handlers/PredisHandler.php index 255a997..bdfcea0 100644 --- a/system/Cache/Handlers/PredisHandler.php +++ b/system/Cache/Handlers/PredisHandler.php @@ -57,8 +57,7 @@ /** * Default config * - * @static - * @var array + * @var array */ protected $config = [ 'scheme' => 'tcp', @@ -71,7 +70,7 @@ /** * Predis connection * - * @var Predis + * @var \Predis\Client */ protected $redis; @@ -80,8 +79,7 @@ /** * Constructor. * - * @param type $config - * @throws type + * @param \Config\Cache $config */ public function __construct($config) { @@ -203,7 +201,7 @@ * * @param string $key Cache item name * - * @return mixed + * @return boolean */ public function delete(string $key) { @@ -245,11 +243,11 @@ /** * Will delete all items in the entire cache. * - * @return mixed + * @return boolean */ public function clean() { - return $this->redis->flushdb(); + return $this->redis->flushdb()->getPayload() === 'OK'; } //-------------------------------------------------------------------- @@ -282,13 +280,15 @@ if (isset($data['__ci_value']) && $data['__ci_value'] !== false) { + $time = time(); return [ - 'expire' => time() + $this->redis->ttl($key), + 'expire' => $time + $this->redis->ttl($key), + 'mtime' => $time, 'data' => $data['__ci_value'], ]; } - return false; + return null; } //-------------------------------------------------------------------- diff --git a/system/Cache/Handlers/RedisHandler.php b/system/Cache/Handlers/RedisHandler.php index f24b0eb..639d5f5 100644 --- a/system/Cache/Handlers/RedisHandler.php +++ b/system/Cache/Handlers/RedisHandler.php @@ -58,8 +58,7 @@ /** * Default config * - * @static - * @var array + * @var array */ protected $config = [ 'host' => '127.0.0.1', @@ -72,7 +71,7 @@ /** * Redis connection * - * @var Redis + * @var \Redis */ protected $redis; @@ -81,24 +80,22 @@ /** * Constructor. * - * @param type $config - * @throws type + * @param \Config\Cache $config */ public function __construct($config) { - $config = (array)$config; - $this->prefix = $config['prefix'] ?? ''; + $this->prefix = $config->prefix ?: ''; if (! empty($config)) { - $this->config = array_merge($this->config, $config['redis']); + $this->config = array_merge($this->config, $config->redis); } } /** * Class destructor * - * Closes the connection to Memcache(d) if present. + * Closes the connection to Redis if present. */ public function __destruct() { @@ -241,7 +238,7 @@ * * @param string $key Cache item name * - * @return mixed + * @return boolean */ public function delete(string $key) { @@ -289,7 +286,7 @@ /** * Will delete all items in the entire cache. * - * @return mixed + * @return boolean */ public function clean() { diff --git a/system/Cache/Handlers/WincacheHandler.php b/system/Cache/Handlers/WincacheHandler.php index 305333d..2adf812 100644 --- a/system/Cache/Handlers/WincacheHandler.php +++ b/system/Cache/Handlers/WincacheHandler.php @@ -1,6 +1,5 @@ config->defaultLocale ?? 'en'); + // Set default timezone on the server date_default_timezone_set($this->config->appTimezone ?? 'UTC'); + // Define environment variables + $this->detectEnvironment(); + $this->bootstrapEnvironment(); + // Setup Exception Handling Services::exceptions() ->initialize(); - $this->detectEnvironment(); - $this->bootstrapEnvironment(); - $this->initializeKint(); if (! CI_DEBUG) diff --git a/system/Commands/Cache/ClearCache.php b/system/Commands/Cache/ClearCache.php new file mode 100644 index 0000000..35c6581 --- /dev/null +++ b/system/Commands/Cache/ClearCache.php @@ -0,0 +1,73 @@ + 'The cache driver to use', + ]; + + /** + * Creates a new migration file with the current timestamp. + * + * @param array $params + */ + public function run(array $params = []) + { + $config = config('Cache'); + + $handler = $params[0] ?? $config->handler; + if (! array_key_exists($handler, $config->validHandlers)) + { + CLI::error($handler . ' is not a valid cache handler.'); + return; + } + + $config->handler = $handler; + $cache = CacheFactory::getHandler($config); + + if (! $cache->clean()) + { + CLI::error('Error while clearing the cache.'); + return; + } + + CLI::write(CLI::color('Done', 'green')); + } +} diff --git a/system/Commands/Database/CreateSeeder.php b/system/Commands/Database/CreateSeeder.php new file mode 100644 index 0000000..3c7d620 --- /dev/null +++ b/system/Commands/Database/CreateSeeder.php @@ -0,0 +1,169 @@ + 'The seeder file name', + ]; + + /** + * the Command's Options + * + * @var array + */ + protected $options = [ + '-n' => 'Set seeder namespace', + ]; + + /** + * Creates a new migration file with the current timestamp. + * + * @param array $params + */ + public function run(array $params = []) + { + helper('inflector'); + + $name = array_shift($params); + + if (empty($name)) + { + $name = CLI::prompt(lang('Seed.nameFile'), null, 'required'); + } + + $ns = $params['-n'] ?? CLI::getOption('n'); + $homepath = APPPATH; + + if (! empty($ns)) + { + // Get all namespaces + $namespaces = Services::autoloader()->getNamespace(); + + foreach ($namespaces as $namespace => $path) + { + if ($namespace === $ns) + { + $homepath = realpath(reset($path)) . DIRECTORY_SEPARATOR; + break; + } + } + } + else + { + $ns = defined('APP_NAMESPACE') ? APP_NAMESPACE : 'App'; + } + + // full path + $path = $homepath . 'Database/Seeds/' . $name . '.php'; + + // Class name should be pascal case now (camel case with upper first letter) + $name = pascalize($name); + + $template = <<psr4 as $ns => $path) { - $path = realpath($path) ?? $path; + $path = realpath($path) ?: $path; $tbody[] = [ $ns, - realpath($path) ?? $path, + realpath($path) ?: $path, is_dir($path) ? 'Yes' : 'MISSING', ]; } diff --git a/system/Commands/Utilities/Routes.php b/system/Commands/Utilities/Routes.php index 6dfef2f..4d4a2ef 100644 --- a/system/Commands/Utilities/Routes.php +++ b/system/Commands/Utilities/Routes.php @@ -127,7 +127,7 @@ foreach ($routes as $route => $handler) { // filter for strings, as callbacks aren't displayable - if(is_string($handler)) + if (is_string($handler)) { $tbody[] = [ strtoupper($method), diff --git a/system/Common.php b/system/Common.php index 2e3fcd7..22dd413 100644 --- a/system/Common.php +++ b/system/Common.php @@ -112,6 +112,34 @@ } } +if (! function_exists('command')) +{ + /** + * Runs a single command. + * Input expected in a single string as would + * be used on the command line itself: + * + * > command('migrate:create SomeMigration'); + * + * @param string $command + * + * @return false|string + */ + function command(string $command) + { + $runner = service('commands'); + + $params = explode(' ', $command); + $command = array_shift($params); + + ob_start(); + $runner->run($command, $params); + $output = ob_get_clean(); + + return $output; + } +} + if (! function_exists('config')) { /** @@ -400,7 +428,7 @@ $response = Services::response(null, true); } - if (ENVIRONMENT !== 'testing' && (is_cli() || $request->isSecure())) + if ((ENVIRONMENT !== 'testing' && (is_cli() || $request->isSecure())) || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'test')) { // @codeCoverageIgnoreStart return; @@ -419,7 +447,11 @@ $baseURL = config(App::class)->baseURL; - if (strpos($baseURL, 'http://') === 0) + if (strpos($baseURL, 'https://') === 0) + { + $baseURL = (string) substr($baseURL, strlen('https://')); + } + elseif (strpos($baseURL, 'http://') === 0) { $baseURL = (string) substr($baseURL, strlen('http://')); } diff --git a/system/Config/AutoloadConfig.php b/system/Config/AutoloadConfig.php index 38602b3..349fcb0 100644 --- a/system/Config/AutoloadConfig.php +++ b/system/Config/AutoloadConfig.php @@ -49,90 +49,65 @@ { /** - * Array of namespaces for autoloading. + * ------------------------------------------------------------------- + * Namespaces + * ------------------------------------------------------------------- + * This maps the locations of any namespaces in your application to + * their location on the file system. These are used by the autoloader + * to locate files the first time they have been instantiated. + * + * Do not change the name of the CodeIgniter namespace or your application + * will break. * * @var array */ - public $psr4 = []; + protected $corePsr4 = [ + 'CodeIgniter' => SYSTEMPATH, + 'App' => APPPATH // To ensure filters, etc still found, + ]; /** - * Map of class names and locations + * ------------------------------------------------------------------- + * Class Map + * ------------------------------------------------------------------- + * The class map provides a map of class names and their exact + * location on the drive. Classes loaded in this manner will have + * slightly faster performance because they will not have to be + * searched for within one or more directories as they would if they + * were being autoloaded through a namespace. * * @var array */ - public $classmap = []; + protected $coreClassmap = [ + 'Psr\Log\AbstractLogger' => SYSTEMPATH . 'ThirdParty/PSR/Log/AbstractLogger.php', + 'Psr\Log\InvalidArgumentException' => SYSTEMPATH . 'ThirdParty/PSR/Log/InvalidArgumentException.php', + 'Psr\Log\LoggerAwareInterface' => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerAwareInterface.php', + 'Psr\Log\LoggerAwareTrait' => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerAwareTrait.php', + 'Psr\Log\LoggerInterface' => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerInterface.php', + 'Psr\Log\LoggerTrait' => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerTrait.php', + 'Psr\Log\LogLevel' => SYSTEMPATH . 'ThirdParty/PSR/Log/LogLevel.php', + 'Psr\Log\NullLogger' => SYSTEMPATH . 'ThirdParty/PSR/Log/NullLogger.php', + 'Laminas\Escaper\Escaper' => SYSTEMPATH . 'ThirdParty/Escaper/Escaper.php' + ]; //-------------------------------------------------------------------- /** * Constructor. + * + * Merge the built-in and developer-configured psr4 and classmap, + * with preference to the developer ones. */ public function __construct() { - /** - * ------------------------------------------------------------------- - * Namespaces - * ------------------------------------------------------------------- - * This maps the locations of any namespaces in your application - * to their location on the file system. These are used by the - * Autoloader to locate files the first time they have been instantiated. - * - * The '/application' and '/system' directories are already mapped for - * you. You may change the name of the 'App' namespace if you wish, - * but this should be done prior to creating any namespaced classes, - * else you will need to modify all of those classes for this to work. - * - * DO NOT change the name of the CodeIgniter namespace or your application - * WILL break. * - * Prototype: - * - * $Config['psr4'] = [ - * 'CodeIgniter' => SYSPATH - * `]; - */ - $this->psr4 = [ - 'CodeIgniter' => realpath(SYSTEMPATH), - ]; - if (isset($_SERVER['CI_ENVIRONMENT']) && $_SERVER['CI_ENVIRONMENT'] === 'testing') { $this->psr4['Tests\Support'] = SUPPORTPATH; - } - - /** - * ------------------------------------------------------------------- - * Class Map - * ------------------------------------------------------------------- - * The class map provides a map of class names and their exact - * location on the drive. Classes loaded in this manner will have - * slightly faster performance because they will not have to be - * searched for within one or more directories as they would if they - * were being autoloaded through a namespace. - * - * Prototype: - * - * $Config['classmap'] = [ - * 'MyClass' => '/path/to/class/file.php' - * ]; - */ - $this->classmap = [ - 'Psr\Log\AbstractLogger' => SYSTEMPATH . 'ThirdParty/PSR/Log/AbstractLogger.php', - 'Psr\Log\InvalidArgumentException' => SYSTEMPATH . 'ThirdParty/PSR/Log/InvalidArgumentException.php', - 'Psr\Log\LoggerAwareInterface' => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerAwareInterface.php', - 'Psr\Log\LoggerAwareTrait' => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerAwareTrait.php', - 'Psr\Log\LoggerInterface' => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerInterface.php', - 'Psr\Log\LoggerTrait' => SYSTEMPATH . 'ThirdParty/PSR/Log/LoggerTrait.php', - 'Psr\Log\LogLevel' => SYSTEMPATH . 'ThirdParty/PSR/Log/LogLevel.php', - 'Psr\Log\NullLogger' => SYSTEMPATH . 'ThirdParty/PSR/Log/NullLogger.php', - 'Laminas\Escaper\Escaper' => SYSTEMPATH . 'ThirdParty/Escaper/Escaper.php', - ]; - - if (isset($_SERVER['CI_ENVIRONMENT']) && $_SERVER['CI_ENVIRONMENT'] === 'testing') - { $this->classmap['CodeIgniter\Log\TestLogger'] = SYSTEMPATH . 'Test/TestLogger.php'; $this->classmap['CIDatabaseTestCase'] = SYSTEMPATH . 'Test/CIDatabaseTestCase.php'; } - } - //-------------------------------------------------------------------- + $this->psr4 = array_merge($this->corePsr4, $this->psr4); + $this->classmap = array_merge($this->coreClassmap, $this->classmap); + } } diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index ceec8ba..256f376 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -92,6 +92,12 @@ foreach ($properties as $property) { $this->initEnvValue($this->$property, $property, $prefix, $shortPrefix); + + // Handle hex2bin prefix + if ($shortPrefix === 'encryption' && $property === 'key' && strpos($this->$property, 'hex2bin:') === 0) + { + $this->$property = hex2bin(substr($this->$property, 8)); + } } if (defined('ENVIRONMENT') && ENVIRONMENT !== 'testing') diff --git a/system/Config/DotEnv.php b/system/Config/DotEnv.php index 4220d77..2b7d912 100644 --- a/system/Config/DotEnv.php +++ b/system/Config/DotEnv.php @@ -78,17 +78,7 @@ { $vars = $this->parse(); - if ($vars === null) - { - return false; - } - - foreach ($vars as $name => $value) - { - $this->setVariable($name, $value); - } - - return true; // for success + return ($vars === null ? false : true); } //-------------------------------------------------------------------- @@ -129,6 +119,7 @@ { list($name, $value) = $this->normaliseVariable($line); $vars[$name] = $value; + $this->setVariable($name, $value); } } @@ -191,6 +182,12 @@ $value = $this->resolveNestedVariables($value); + // Handle hex2bin prefix + if ($name === 'encryption.key' && strpos($value, 'hex2bin:') === 0) + { + $value = hex2bin(substr($value, 8)); + } + return [ $name, $value, diff --git a/system/Config/Services.php b/system/Config/Services.php index afb6512..b3c9854 100644 --- a/system/Config/Services.php +++ b/system/Config/Services.php @@ -152,6 +152,23 @@ //-------------------------------------------------------------------- /** + * The commands utility for running and working with CLI commands. + * + * @param boolean $getShared + * + * @return \CodeIgniter\CLI\Commands|mixed + */ + public static function commands(bool $getShared = true) + { + if ($getShared) + { + return static::getSharedInstance('commands'); + } + + return new \CodeIgniter\CLI\Commands(); + } + + /** * The CURL Request class acts as a simple HTTP client for interacting * with other servers, typically through APIs. * @@ -340,9 +357,9 @@ * Acts as a factory for ImageHandler classes and returns an instance * of the handler. Used like Services::image()->withFile($path)->rotate(90)->save(); * - * @param string $handler - * @param mixed $config - * @param boolean $getShared + * @param string|null $handler + * @param \Config\Images|null $config + * @param boolean $getShared * * @return \CodeIgniter\Images\Handlers\BaseHandler */ @@ -760,7 +777,7 @@ $logger = static::logger(); $driverName = $config->sessionDriver; - $driver = new $driverName($config, static::request()->getIpAddress()); + $driver = new $driverName($config, static::request()->getIPAddress()); $driver->setLogger($logger); $session = new Session($driver, $config); diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 33e7004..9e2f3eb 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -252,6 +252,20 @@ */ protected $testMode = false; + /** + * Tables relation types + * + * @var array + */ + protected $joinTypes = [ + 'LEFT', + 'RIGHT', + 'OUTER', + 'INNER', + 'LEFT OUTER', + 'RIGHT OUTER', + ]; + //-------------------------------------------------------------------- /** @@ -370,7 +384,7 @@ * This prevents NULL being escaped * @see https://github.com/codeigniter4/CodeIgniter4/issues/1169 */ - if (strtoupper(mb_substr(trim($val), 0, 4)) === 'NULL') + if (mb_stripos(trim($val), 'NULL') === 0) { $escape = false; } @@ -623,7 +637,7 @@ { $type = strtoupper(trim($type)); - if (! in_array($type, ['LEFT', 'RIGHT', 'OUTER', 'INNER', 'LEFT OUTER', 'RIGHT OUTER'], true)) + if (! in_array($type, $this->joinTypes, true)) { $type = ''; } @@ -663,6 +677,7 @@ $pos = $joints[$i][1] - strlen($joints[$i][0]); $joints[$i] = $joints[$i][0]; } + ksort($conditions); } else { @@ -671,11 +686,11 @@ } $cond = ' ON '; - for ($i = 0, $c = count($conditions); $i < $c; $i ++) + foreach ($conditions as $i => $condition) { - $operator = $this->getOperator($conditions[$i]); + $operator = $this->getOperator($condition); $cond .= $joints[$i]; - $cond .= preg_match("/(\(*)?([\[\]\w\.'-]+)" . preg_quote($operator) . '(.*)/i', $conditions[$i], $match) ? $match[1] . $this->db->protectIdentifiers($match[2]) . $operator . $this->db->protectIdentifiers($match[3]) : $conditions[$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; } } @@ -2438,7 +2453,9 @@ { $this->resetWrite(); - if ($this->db->query($sql, $this->binds, false)) + $result = $this->db->query($sql, $this->binds, false); + + if ($result->resultID !== false) { // Clear our binds so we don't eat up memory $this->binds = []; @@ -3045,28 +3062,28 @@ { if (! empty($this->$qb_key)) { - for ($i = 0, $c = count($this->$qb_key); $i < $c; $i ++) + foreach ($this->$qb_key as &$qbkey) { // Is this condition already compiled? - if (is_string($this->{$qb_key}[$i])) + if (is_string($qbkey)) { continue; } - elseif ($this->{$qb_key}[$i]['escape'] === false) + elseif ($qbkey['escape'] === false) { - $this->{$qb_key}[$i] = $this->{$qb_key}[$i]['condition']; + $qbkey = $qbkey['condition']; continue; } // Split multiple conditions $conditions = preg_split( - '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i', $this->{$qb_key}[$i]['condition'], -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY + '/((?:^|\s+)AND\s+|(?:^|\s+)OR\s+)/i', $qbkey['condition'], -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY ); - for ($ci = 0, $cc = count($conditions); $ci < $cc; $ci ++) + foreach ($conditions as &$condition) { - if (($op = $this->getOperator($conditions[$ci])) === false - || ! preg_match('/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?getOperator($condition)) === false + || ! preg_match('/^(\(?)(.*)(' . preg_quote($op, '/') . ')\s*(.*(?db->protectIdentifiers(trim($matches[2])) + $condition = $matches[1] . $this->db->protectIdentifiers(trim($matches[2])) . ' ' . trim($matches[3]) . $matches[4] . $matches[5]; } - $this->{$qb_key}[$i] = implode('', $conditions); + $qbkey = implode('', $conditions); } return ($qb_key === 'QBHaving' ? "\nHAVING " : "\nWHERE ") @@ -3127,16 +3144,16 @@ { if (! empty($this->QBGroupBy)) { - for ($i = 0, $c = count($this->QBGroupBy); $i < $c; $i ++) + foreach ($this->QBGroupBy as &$groupBy) { // Is it already compiled? - if (is_string($this->QBGroupBy[$i])) + if (is_string($groupBy)) { continue; } - $this->QBGroupBy[$i] = ($this->QBGroupBy[$i]['escape'] === false || - $this->isLiteral($this->QBGroupBy[$i]['field'])) ? $this->QBGroupBy[$i]['field'] : $this->db->protectIdentifiers($this->QBGroupBy[$i]['field']); + $groupBy = ($groupBy['escape'] === false || + $this->isLiteral($groupBy['field'])) ? $groupBy['field'] : $this->db->protectIdentifiers($groupBy['field']); } return "\nGROUP BY " . implode(', ', $this->QBGroupBy); @@ -3162,14 +3179,14 @@ { if (is_array($this->QBOrderBy) && ! empty($this->QBOrderBy)) { - for ($i = 0, $c = count($this->QBOrderBy); $i < $c; $i ++) + foreach ($this->QBOrderBy as &$orderBy) { - if ($this->QBOrderBy[$i]['escape'] !== false && ! $this->isLiteral($this->QBOrderBy[$i]['field'])) + if ($orderBy['escape'] !== false && ! $this->isLiteral($orderBy['field'])) { - $this->QBOrderBy[$i]['field'] = $this->db->protectIdentifiers($this->QBOrderBy[$i]['field']); + $orderBy['field'] = $this->db->protectIdentifiers($orderBy['field']); } - $this->QBOrderBy[$i] = $this->QBOrderBy[$i]['field'] . $this->QBOrderBy[$i]['direction']; + $orderBy = $orderBy['field'] . $orderBy['direction']; } return $this->QBOrderBy = "\nORDER BY " . implode(', ', $this->QBOrderBy); diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 844cf7b..e1c4f7a 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -373,8 +373,15 @@ $this->connectTime = microtime(true); - // Connect to the database and set the connection ID - $this->connID = $this->connect($this->pConnect); + try + { + // Connect to the database and set the connection ID + $this->connID = $this->connect($this->pConnect); + } + catch (\Throwable $e) + { + log_message('error', 'Error connecting to the database: ' . $e->getMessage()); + } // No connection resource? Check if there is a failover else throw an error if (! $this->connID) @@ -394,8 +401,15 @@ } } - // Try to connect - $this->connID = $this->connect($this->pConnect); + try + { + // Try to connect + $this->connID = $this->connect($this->pConnect); + } + catch (\Throwable $e) + { + log_message('error', 'Error connecting to the database: ' . $e->getMessage()); + } // If a connection is made break the foreach loop if ($this->connID) @@ -544,17 +558,6 @@ //-------------------------------------------------------------------- /** - * Returns the last error encountered by this connection. - * - * @return mixed - */ - public function getError() - { - } - - //-------------------------------------------------------------------- - - /** * The name of the platform in use (MySQLi, mssql, etc) * * @return string diff --git a/system/Database/BaseResult.php b/system/Database/BaseResult.php index 5e6abe3..5c6500e 100644 --- a/system/Database/BaseResult.php +++ b/system/Database/BaseResult.php @@ -167,11 +167,11 @@ $_data = null; if (($c = count($this->resultArray)) > 0) { - $_data = 'result_array'; + $_data = 'resultArray'; } elseif (($c = count($this->resultObject)) > 0) { - $_data = 'result_object'; + $_data = 'resultObject'; } if ($_data !== null) diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php index dffa5f3..2df836e 100644 --- a/system/Database/ConnectionInterface.php +++ b/system/Database/ConnectionInterface.php @@ -124,7 +124,7 @@ * * @return mixed */ - public function getError(); + public function error(): array; //-------------------------------------------------------------------- diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index 9bda5b0..206b9f0 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -103,7 +103,7 @@ { $hostname = ($persistent === true) ? 'p:' . $this->hostname : $this->hostname; $port = empty($this->port) ? null : $this->port; - $socket = null; + $socket = ''; } $client_flags = ($this->compress === true) ? MYSQLI_CLIENT_COMPRESS : 0; diff --git a/system/Database/MySQLi/Result.php b/system/Database/MySQLi/Result.php index 32f5f90..7a7c585 100644 --- a/system/Database/MySQLi/Result.php +++ b/system/Database/MySQLi/Result.php @@ -87,6 +87,42 @@ */ public function getFieldData(): array { + static $data_types = [ + MYSQLI_TYPE_DECIMAL => 'decimal', + MYSQLI_TYPE_NEWDECIMAL => 'newdecimal', + MYSQLI_TYPE_FLOAT => 'float', + MYSQLI_TYPE_DOUBLE => 'double', + + MYSQLI_TYPE_BIT => 'bit', + MYSQLI_TYPE_TINY => 'tiny', + MYSQLI_TYPE_SHORT => 'short', + MYSQLI_TYPE_LONG => 'long', + MYSQLI_TYPE_LONGLONG => 'longlong', + MYSQLI_TYPE_INT24 => 'int24', + + MYSQLI_TYPE_YEAR => 'year', + + MYSQLI_TYPE_TIMESTAMP => 'timestamp', + MYSQLI_TYPE_DATE => 'date', + MYSQLI_TYPE_TIME => 'time', + MYSQLI_TYPE_DATETIME => 'datetime', + MYSQLI_TYPE_NEWDATE => 'newdate', + + MYSQLI_TYPE_INTERVAL => 'interval', + MYSQLI_TYPE_SET => 'set', + MYSQLI_TYPE_ENUM => 'enum', + + MYSQLI_TYPE_VAR_STRING => 'var_string', + MYSQLI_TYPE_STRING => 'string', + MYSQLI_TYPE_CHAR => 'char', + + MYSQLI_TYPE_GEOMETRY => 'geometry', + MYSQLI_TYPE_TINY_BLOB => 'tiny_blob', + MYSQLI_TYPE_MEDIUM_BLOB => 'medium_blob', + MYSQLI_TYPE_LONG_BLOB => 'long_blob', + MYSQLI_TYPE_BLOB => 'blob', + ]; + $retVal = []; $fieldData = $this->resultID->fetch_fields(); @@ -95,8 +131,10 @@ $retVal[$i] = new \stdClass(); $retVal[$i]->name = $data->name; $retVal[$i]->type = $data->type; + $retVal[$i]->type_name = isset($data_types[$data->type]) ? $data_types[$data->type] : null; $retVal[$i]->max_length = $data->max_length; $retVal[$i]->primary_key = (int) ($data->flags & 2); + $retVal[$i]->length = $data->length; $retVal[$i]->default = $data->def; } diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index fa45c45..4bace0b 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -405,4 +405,29 @@ } //-------------------------------------------------------------------- + + /** + * 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 BaseBuilder + */ + public function join(string $table, string $cond, string $type = '', bool $escape = null) + { + if (! in_array('FULL OUTER', $this->joinTypes, true)) + { + $this->joinTypes = array_merge($this->joinTypes, ['FULL OUTER']); + } + + return parent::join($table, $cond, $type, $escape); + } + + //-------------------------------------------------------------------- + } diff --git a/system/Database/Postgre/Forge.php b/system/Database/Postgre/Forge.php index c474459..9b0bbdb 100644 --- a/system/Database/Postgre/Forge.php +++ b/system/Database/Postgre/Forge.php @@ -227,7 +227,7 @@ { if (! empty($attributes['AUTO_INCREMENT']) && $attributes['AUTO_INCREMENT'] === true) { - $field['type'] = $field['type'] === 'NUMERIC' ? 'BIGSERIAL' : 'SERIAL'; + $field['type'] = $field['type'] === 'NUMERIC' || $field['type'] === 'BIGINT' ? 'BIGSERIAL' : 'SERIAL'; } } diff --git a/system/Database/Postgre/Result.php b/system/Database/Postgre/Result.php index a58a14e..fcdd509 100644 --- a/system/Database/Postgre/Result.php +++ b/system/Database/Postgre/Result.php @@ -92,8 +92,10 @@ { $retVal[$i] = new \stdClass(); $retVal[$i]->name = pg_field_name($this->resultID, $i); - $retVal[$i]->type = pg_field_type($this->resultID, $i); + $retVal[$i]->type = pg_field_type_oid($this->resultID, $i); + $retVal[$i]->type_name = pg_field_type($this->resultID, $i); $retVal[$i]->max_length = pg_field_size($this->resultID, $i); + $retVal[$i]->length = $retVal[$i]->max_length; // $retVal[$i]->primary_key = (int)($fieldData[$i]->flags & 2); // $retVal[$i]->default = $fieldData[$i]->def; } diff --git a/system/Database/SQLite3/Builder.php b/system/Database/SQLite3/Builder.php index b302f56..3a01578 100644 --- a/system/Database/SQLite3/Builder.php +++ b/system/Database/SQLite3/Builder.php @@ -48,13 +48,6 @@ { /** - * Identifier escape character - * - * @var string - */ - protected $escapeChar = '`'; - - /** * Default installs of SQLite typically do not * support limiting delete clauses. * diff --git a/system/Database/SQLite3/Connection.php b/system/Database/SQLite3/Connection.php index d3ac1cb..b9851e3 100644 --- a/system/Database/SQLite3/Connection.php +++ b/system/Database/SQLite3/Connection.php @@ -56,7 +56,14 @@ */ public $DBDriver = 'SQLite3'; - // -------------------------------------------------------------------- + /** + * Identifier escape character + * + * @var string + */ + public $escapeChar = '`'; + + //-------------------------------------------------------------------- /** * ORDER BY random keyword @@ -85,6 +92,11 @@ } try { + if ($this->database !== ':memory:' && strpos($this->database, DIRECTORY_SEPARATOR) === false) + { + $this->database = WRITEPATH . $this->database; + } + return (! $this->password) ? new \SQLite3($this->database) : new \SQLite3($this->database, SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE, $this->password); diff --git a/system/Database/SQLite3/Result.php b/system/Database/SQLite3/Result.php index fac0725..ae293d6 100644 --- a/system/Database/SQLite3/Result.php +++ b/system/Database/SQLite3/Result.php @@ -101,8 +101,10 @@ $retVal[$i] = new \stdClass(); $retVal[$i]->name = $this->resultID->columnName($i); $type = $this->resultID->columnType($i); - $retVal[$i]->type = isset($data_types[$type]) ? $data_types[$type] : $type; + $retVal[$i]->type = $type; + $retVal[$i]->type_name = isset($data_types[$type]) ? $data_types[$type] : null; $retVal[$i]->max_length = null; + $retVal[$i]->length = null; } return $retVal; diff --git a/system/Debug/Exceptions.php b/system/Debug/Exceptions.php index 7f7ca2c..3b915e3 100644 --- a/system/Debug/Exceptions.php +++ b/system/Debug/Exceptions.php @@ -104,7 +104,7 @@ { $this->ob_level = ob_get_level(); - $this->viewPath = rtrim($config->errorViewPath, '/ ') . '/'; + $this->viewPath = rtrim($config->errorViewPath, '\\/ ') . DIRECTORY_SEPARATOR; $this->config = $config; @@ -139,13 +139,15 @@ * and fire an event that allows custom actions to be taken at this point. * * @param \Throwable $exception + * + * @codeCoverageIgnore */ public function exceptionHandler(Throwable $exception) { - // @codeCoverageIgnoreStart - $codes = $this->determineCodes($exception); - $statusCode = $codes[0]; - $exitCode = $codes[1]; + [ + $statusCode, + $exitCode, + ] = $this->determineCodes($exception); // Log it if ($this->config->log === true && ! in_array($statusCode, $this->config->ignoreCodes)) @@ -172,7 +174,6 @@ $this->render($exception, $statusCode); exit($exitCode); - // @codeCoverageIgnoreEnd } //-------------------------------------------------------------------- @@ -240,7 +241,7 @@ { // Production environments should have a custom exception file. $view = 'production.php'; - $template_path = rtrim($template_path, '/ ') . '/'; + $template_path = rtrim($template_path, '\\/ ') . DIRECTORY_SEPARATOR; if (str_ireplace(['off', 'none', 'no', 'false', 'null'], '', ini_get('display_errors'))) { @@ -254,7 +255,7 @@ } // Allow for custom views based upon the status code - else if (is_file($template_path . 'error_' . $exception->getCode() . '.php')) + if (is_file($template_path . 'error_' . $exception->getCode() . '.php')) { return 'error_' . $exception->getCode() . '.php'; } @@ -272,18 +273,26 @@ */ protected function render(Throwable $exception, int $statusCode) { - // Determine directory with views - $path = $this->viewPath; - if (empty($path)) + // Determine possible directories of error views + $path = $this->viewPath; + $altPath = rtrim((new Paths())->viewDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'errors' . DIRECTORY_SEPARATOR; + + $path .= (is_cli() ? 'cli' : 'html') . DIRECTORY_SEPARATOR; + $altPath .= (is_cli() ? 'cli' : 'html') . DIRECTORY_SEPARATOR; + + // Determine the views + $view = $this->determineView($exception, $path); + $altView = $this->determineView($exception, $altPath); + + // Check if the view exists + if (is_file($path . $view)) { - $paths = new Paths(); - $path = $paths->viewDirectory . '/errors/'; + $viewFile = $path . $view; } - - $path = is_cli() ? $path . 'cli/' : $path . 'html/'; - - // Determine the vew - $view = $this->determineView($exception, $path); + elseif (is_file($altPath . $altView)) + { + $viewFile = $altPath . $altView; + } // Prepare the vars $vars = $this->collectVars($exception, $statusCode); @@ -296,7 +305,7 @@ } ob_start(); - include($path . $view); + include $viewFile; $buffer = ob_get_contents(); ob_end_clean(); echo $buffer; @@ -383,7 +392,7 @@ case strpos($file, FCPATH) === 0: $file = 'FCPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(FCPATH)); break; - case defined('VENDORPATH') && strpos($file, VENDORPATH) === 0; + case defined('VENDORPATH') && strpos($file, VENDORPATH) === 0: $file = 'VENDORPATH' . DIRECTORY_SEPARATOR . substr($file, strlen(VENDORPATH)); break; } diff --git a/system/Debug/Timer.php b/system/Debug/Timer.php index b12b52d..736f558 100644 --- a/system/Debug/Timer.php +++ b/system/Debug/Timer.php @@ -45,10 +45,7 @@ * Provides a simple way to measure the amount of time * that elapses between two points. * - * NOTE: All methods are static since the class is intended - * to measure throughout an entire application's life cycle. - * - * @package CodeIgniter\Benchmark + * @package CodeIgniter\Debug */ class Timer { diff --git a/system/Debug/Toolbar/Collectors/BaseCollector.php b/system/Debug/Toolbar/Collectors/BaseCollector.php index dade0ab..a403b4c 100644 --- a/system/Debug/Toolbar/Collectors/BaseCollector.php +++ b/system/Debug/Toolbar/Collectors/BaseCollector.php @@ -38,6 +38,8 @@ namespace CodeIgniter\Debug\Toolbar\Collectors; +use CodeIgniter\Debug\Exceptions; + /** * Base Toolbar collector */ @@ -253,20 +255,7 @@ */ public function cleanPath(string $file): string { - if (strpos($file, APPPATH) === 0) - { - $file = 'APPPATH/' . substr($file, strlen(APPPATH)); - } - elseif (strpos($file, SYSTEMPATH) === 0) - { - $file = 'SYSTEMPATH/' . substr($file, strlen(SYSTEMPATH)); - } - elseif (strpos($file, FCPATH) === 0) - { - $file = 'FCPATH/' . substr($file, strlen(FCPATH)); - } - - return $file; + return Exceptions::cleanPath($file); } /** diff --git a/system/Debug/Toolbar/Collectors/Routes.php b/system/Debug/Toolbar/Collectors/Routes.php index 22ea7c2..f75374d 100644 --- a/system/Debug/Toolbar/Collectors/Routes.php +++ b/system/Debug/Toolbar/Collectors/Routes.php @@ -134,8 +134,8 @@ /* * Defined Routes */ - $routes = []; - $methods = [ + $routes = []; + $methods = [ 'get', 'head', 'post', @@ -158,8 +158,8 @@ if (is_string($handler)) { $routes[] = [ - 'method' => strtoupper($method), - 'route' => $route, + 'method' => strtoupper($method), + 'route' => $route, 'handler' => $handler, ]; } diff --git a/system/Debug/Toolbar/Views/_routes.tpl b/system/Debug/Toolbar/Views/_routes.tpl index 35acdde..e277046 100644 --- a/system/Debug/Toolbar/Views/_routes.tpl +++ b/system/Debug/Toolbar/Views/_routes.tpl @@ -44,7 +44,7 @@ {routes} {method} - {route} + {route} {handler} {/routes} diff --git a/system/Debug/Toolbar/Views/toolbar.css b/system/Debug/Toolbar/Views/toolbar.css index 7a4c9c4..e2abb4c 100644 --- a/system/Debug/Toolbar/Views/toolbar.css +++ b/system/Debug/Toolbar/Views/toolbar.css @@ -148,7 +148,9 @@ clear: left; display: inline-block; float: left; - margin: 6px 3px 6px 0; } + margin: 6px 3px 6px 0; + width: 16px !important; + } #debug-bar .ci-label .badge { border-radius: 12px; -moz-border-radius: 12px; @@ -593,3 +595,15 @@ .debug-bar-noverflow { overflow: hidden; } + +#debug-bar td[data-debugbar-route] form { + display: none; } +#debug-bar td[data-debugbar-route]:hover form { + display: block; } +#debug-bar td[data-debugbar-route]:hover > div { + display: none; } +#debug-bar td[data-debugbar-route] input[type=text] { + padding: 2px; } +#toolbarContainer.dark td[data-debugbar-route] input[type=text] { + background: #000; + color: #fff; } diff --git a/system/Debug/Toolbar/Views/toolbar.js b/system/Debug/Toolbar/Views/toolbar.js index f146da4..15fa668 100644 --- a/system/Debug/Toolbar/Views/toolbar.js +++ b/system/Debug/Toolbar/Views/toolbar.js @@ -20,6 +20,7 @@ ciDebugBar.setToolbarPosition(); ciDebugBar.setToolbarTheme(); ciDebugBar.toggleViewsHints(); + ciDebugBar.routerLink(); document.getElementById('debug-bar-link').addEventListener('click', ciDebugBar.toggleToolbar, true); document.getElementById('debug-icon-link').addEventListener('click', ciDebugBar.toggleToolbar, true); @@ -545,7 +546,7 @@ } else { - // In any other cases: if there is no cookie, or the cookie is set to + // In any other cases: if there is no cookie, or the cookie is set to // "light", or the "prefers-color-scheme" is "light"... ciDebugBar.createCookie('debug-bar-theme', 'dark', 365); ciDebugBar.removeClass(ciDebugBar.toolbarContainer, 'light'); @@ -600,5 +601,61 @@ } } return null; + }, + + //-------------------------------------------------------------------- + + trimSlash: function(text) { + return text.replace(/^\/|\/$/g, ''); + }, + + routerLink: function() { + var row, _location; + var rowGet = document.querySelectorAll('#debug-bar td[data-debugbar-route="GET"]'); + var patt = /\((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)/; + + for (var i = 0; i < rowGet.length; i++) { + row = rowGet[i]; + if (!/\/\(.+?\)/.test(rowGet[i].innerText)) { + row.style = 'cursor: pointer;'; + row.setAttribute('title', location.origin + '/' + ciDebugBar.trimSlash(row.innerText)); + row.addEventListener('click', function(ev) { + _location = location.origin + '/' + ciDebugBar.trimSlash(ev.target.innerText); + var redirectWindow = window.open(_location, '_blank'); + redirectWindow.location; + }); + } + else { + row.innerHTML = '
' + row.innerText + '
' + + '
' + + row.innerText.replace(patt, '') + + '' + + '
'; + } + } + + rowGet = document.querySelectorAll('#debug-bar td[data-debugbar-route="GET"] form'); + for (var i = 0; i < rowGet.length; i++) { + row = rowGet[i]; + + row.addEventListener('submit', function(event) { + event.preventDefault() + var inputArray = [], t = 0; + var input = event.target.querySelectorAll('input[type=text]'); + var tpl = event.target.getAttribute('data-debugbar-route-tpl'); + + for (var n = 0; n < input.length; n++) { + if (input[n].value.length > 0) inputArray.push(input[n].value); + } + + if (inputArray.length > 0) { + _location = location.origin + '/' + tpl.replace(/\?/g, function() {return inputArray[t++]}); + var redirectWindow = window.open(_location, '_blank'); + redirectWindow.location; + } + }) + } + } + }; diff --git a/system/Email/Email.php b/system/Email/Email.php index 5ace58a..b3a3dcd 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -39,6 +39,7 @@ namespace CodeIgniter\Email; +use CodeIgniter\Events\Events; use Config\Mimes; /** @@ -55,6 +56,18 @@ class Email { /** + * Properties from the last successful send. + * + * @var array|null + */ + public $archive; + /** + * Properties to be added to the next archive. + * + * @var array + */ + protected $tmpArchive = []; + /** * @var string */ public $fromEmail; @@ -451,6 +464,11 @@ $this->validateEmail($this->stringToArray($returnPath)); } } + + // Store the plain text values + $this->tmpArchive['fromEmail'] = $from; + $this->tmpArchive['fromName'] = $name; + // prepare the display name if ($name !== '') { @@ -468,6 +486,8 @@ $this->setHeader('From', $name . ' <' . $from . '>'); isset($returnPath) || $returnPath = $from; $this->setHeader('Return-Path', '<' . $returnPath . '>'); + $this->tmpArchive['returnPath'] = $returnPath; + return $this; } //-------------------------------------------------------------------- @@ -491,6 +511,8 @@ } if ($name !== '') { + $this->tmpArchive['replyName'] = $name; + // only use Q encoding if there are characters that would require it if (! preg_match('/[\200-\377]/', $name)) { @@ -503,7 +525,9 @@ } } $this->setHeader('Reply-To', $name . ' <' . $replyto . '>'); - $this->replyToFlag = true; + $this->replyToFlag = true; + $this->tmpArchive['replyTo'] = $replyto; + return $this; } //-------------------------------------------------------------------- @@ -549,6 +573,7 @@ { $this->CCArray = $cc; } + $this->tmpArchive['CCArray'] = $cc; return $this; } //-------------------------------------------------------------------- @@ -579,6 +604,7 @@ else { $this->setHeader('Bcc', implode(', ', $bcc)); + $this->tmpArchive['BCCArray'] = $bcc; } return $this; } @@ -592,6 +618,8 @@ */ public function setSubject($subject) { + $this->tmpArchive['subject'] = $subject; + $subject = $this->prepQEncoding($subject); $this->setHeader('Subject', $subject); return $this; @@ -1530,8 +1558,7 @@ { $this->setReplyTo($this->headers['From']); } - if (empty($this->recipients) && ! isset($this->headers['To']) && empty($this->BCCArray) && ! isset($this->headers['Bcc']) && ! isset($this->headers['Cc']) - ) + if (empty($this->recipients) && ! isset($this->headers['To']) && empty($this->BCCArray) && ! isset($this->headers['Bcc']) && ! isset($this->headers['Cc'])) { $this->setErrorMessage(lang('Email.noRecipients')); return false; @@ -1548,10 +1575,18 @@ } $this->buildMessage(); $result = $this->spoolEmail(); - if ($result && $autoClear) + if ($result) { - $this->clear(); + $this->setArchiveValues(); + + if ($autoClear) + { + $this->clear(); + } + + Events::trigger('email', $this->archive); } + return $result; } //-------------------------------------------------------------------- @@ -1595,6 +1630,10 @@ $this->buildMessage(); $this->spoolEmail(); } + + // Update the archive + $this->setArchiveValues(); + Events::trigger('email', $this->archive); } //-------------------------------------------------------------------- /** @@ -1688,20 +1727,18 @@ */ protected function sendWithMail() { - if (is_array($this->recipients)) - { - $this->recipients = implode(', ', $this->recipients); - } + $recipients = is_array($this->recipients) ? implode(', ', $this->recipients) : $this->recipients; + // _validate_email_for_shell() below accepts by reference, // so this needs to be assigned to a variable $from = $this->cleanEmail($this->headers['Return-Path']); if (! $this->validateEmailForShell($from)) { - return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr); + return mail($recipients, $this->subject, $this->finalBody, $this->headerStr); } // most documentation of sendmail using the "-f" flag lacks a space after it, however // we've encountered servers that seem to require it to be in place. - return mail($this->recipients, $this->subject, $this->finalBody, $this->headerStr, '-f ' . $from); + return mail($recipients, $this->subject, $this->finalBody, $this->headerStr, '-f ' . $from); } //-------------------------------------------------------------------- /** @@ -2131,4 +2168,21 @@ } return isset($length) ? substr($str, $start, $length) : substr($str, $start); } + //-------------------------------------------------------------------- + /** + * Determines the values that should be stored in $archive. + * + * @return array The updated archive values + */ + protected function setArchiveValues(): array + { + // Get property values and add anything prepped in tmpArchive + $this->archive = array_merge(get_object_vars($this), $this->tmpArchive); + unset($this->archive['archive']); + + // Clear tmpArchive for next run + $this->tmpArchive = []; + + return $this->archive; + } } diff --git a/system/Entity.php b/system/Entity.php index 1ccd6d2..b3da3ce 100644 --- a/system/Entity.php +++ b/system/Entity.php @@ -124,18 +124,7 @@ foreach ($data as $key => $value) { - $key = $this->mapProperty($key); - - $method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $key))); - - if (method_exists($this, $method)) - { - $this->$method($value); - } - else - { - $this->attributes[$key] = $value; - } + $this->$key = $value; } return $this; @@ -371,7 +360,7 @@ // back to the database. if (($castTo === 'json' || $castTo === 'json-array') && function_exists('json_encode')) { - $value = json_encode($value); + $value = json_encode($value, JSON_UNESCAPED_UNICODE); if (json_last_error() !== JSON_ERROR_NONE) { diff --git a/system/Exceptions/FrameworkException.php b/system/Exceptions/FrameworkException.php index c63f828..41a3e95 100644 --- a/system/Exceptions/FrameworkException.php +++ b/system/Exceptions/FrameworkException.php @@ -11,6 +11,10 @@ class FrameworkException extends \RuntimeException implements ExceptionInterface { + public static function forEnabledZlibOutputCompression() + { + return new static(lang('Core.enabledZlibOutputCompression')); + } public static function forInvalidFile(string $path) { @@ -31,5 +35,4 @@ { return new static(lang('Core.noHandlers', [$class])); } - } diff --git a/system/Files/File.php b/system/Files/File.php index 1f59795..727665d 100644 --- a/system/Files/File.php +++ b/system/Files/File.php @@ -144,7 +144,9 @@ { if (! function_exists('finfo_open')) { + // @codeCoverageIgnoreStart return $this->originalMimeType ?? 'application/octet-stream'; + // @codeCoverageIgnoreEnd } $finfo = finfo_open(FILEINFO_MIME_TYPE); @@ -193,7 +195,7 @@ throw FileException::forUnableToMove($this->getBasename(), $targetPath, strip_tags($error['message'])); } - @chmod($targetPath, 0777 & ~umask()); + @chmod($destination, 0777 & ~umask()); return new File($destination); } diff --git a/system/Filters/CSRF.php b/system/Filters/CSRF.php index 1f0bcf8..c79d191 100644 --- a/system/Filters/CSRF.php +++ b/system/Filters/CSRF.php @@ -57,6 +57,7 @@ */ class CSRF implements FilterInterface { + /** * Do whatever processing this filter needs to do. * By default it should not return anything during @@ -68,10 +69,12 @@ * redirects, etc. * * @param RequestInterface|\CodeIgniter\HTTP\IncomingRequest $request + * @param array|null $arguments * * @return mixed + * @throws \CodeIgniter\Security\Exceptions\SecurityException */ - public function before(RequestInterface $request) + public function before(RequestInterface $request, $arguments = null) { if ($request->isCLI()) { @@ -102,10 +105,11 @@ * * @param RequestInterface|\CodeIgniter\HTTP\IncomingRequest $request * @param ResponseInterface|\CodeIgniter\HTTP\Response $response + * @param array|null $arguments * * @return mixed */ - public function after(RequestInterface $request, ResponseInterface $response) + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { } diff --git a/system/Filters/DebugToolbar.php b/system/Filters/DebugToolbar.php index 465ca3e..2c3a55c 100644 --- a/system/Filters/DebugToolbar.php +++ b/system/Filters/DebugToolbar.php @@ -48,14 +48,16 @@ */ class DebugToolbar implements FilterInterface { + /** * We don't need to do anything here. * * @param RequestInterface|\CodeIgniter\HTTP\IncomingRequest $request + * @param array|null $arguments * * @return void */ - public function before(RequestInterface $request) + public function before(RequestInterface $request, $arguments = null) { } @@ -67,10 +69,11 @@ * * @param RequestInterface|\CodeIgniter\HTTP\IncomingRequest $request * @param ResponseInterface|\CodeIgniter\HTTP\Response $response + * @param array|null $arguments * * @return void */ - public function after(RequestInterface $request, ResponseInterface $response) + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { Services::toolbar()->prepare($request, $response); } diff --git a/system/Filters/Exceptions/FilterException.php b/system/Filters/Exceptions/FilterException.php index b2aba94..43e93cf 100644 --- a/system/Filters/Exceptions/FilterException.php +++ b/system/Filters/Exceptions/FilterException.php @@ -1,15 +1,72 @@ -config = $config; - $this->request = & $request; + $this->request = &$request; $this->setResponse($response); } @@ -119,7 +119,7 @@ */ public function setResponse(ResponseInterface $response) { - $this->response = & $response; + $this->response = &$response; } //-------------------------------------------------------------------- @@ -196,7 +196,7 @@ } elseif ($position === 'after') { - $result = $class->after($this->request, $this->response); + $result = $class->after($this->request, $this->response, $this->arguments[$alias] ?? null); if ($result instanceof ResponseInterface) { @@ -337,6 +337,8 @@ /** * Returns the arguments for a specified key, or all. * + * @param string|null $key + * * @return mixed */ public function getArguments(string $key = null) @@ -352,8 +354,9 @@ /** * Add any applicable (not excluded) global filter settings to the mix. * - * @param string $uri - * @return type + * @param string $uri + * + * @return void */ protected function processGlobals(string $uri = null) { @@ -369,6 +372,7 @@ 'before', 'after', ]; + foreach ($sets as $set) { if (isset($this->config->globals[$set])) @@ -394,6 +398,7 @@ { $alias = $rules; // simple name of filter to apply } + if ($keep) { $this->filters[$set][] = $alias; @@ -408,7 +413,7 @@ /** * Add any method-specific flters to the mix. * - * @return type + * @return void */ protected function processMethods() { @@ -432,8 +437,9 @@ /** * Add any applicable configured filters to the mix. * - * @param string $uri - * @return type + * @param string $uri + * + * @return void */ protected function processFilters(string $uri = null) { @@ -456,6 +462,7 @@ $this->filters['before'][] = $alias; } } + if (isset($settings['after'])) { $path = $settings['after']; @@ -470,9 +477,10 @@ /** * Check paths for match for URI * - * @param string $uri URI to test against - * @param mixed $paths The path patterns to test - * @return boolean True if any of the paths apply to the URI + * @param string $uri URI to test against + * @param mixed $paths The path patterns to test + * + * @return boolean True if any of the paths apply to the URI */ private function pathApplies(string $uri, $paths) { @@ -501,6 +509,7 @@ return true; } } + return false; } diff --git a/system/Filters/Honeypot.php b/system/Filters/Honeypot.php index a5807e4..5f2d83c 100644 --- a/system/Filters/Honeypot.php +++ b/system/Filters/Honeypot.php @@ -1,4 +1,5 @@ hasContent($request)) @@ -73,10 +73,11 @@ * * @param \CodeIgniter\HTTP\RequestInterface $request * @param \CodeIgniter\HTTP\ResponseInterface $response + * @param array|null $arguments * * @return void */ - public function after(RequestInterface $request, ResponseInterface $response) + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) { $honeypot = Services::honeypot(new \Config\Honeypot()); $honeypot->attachHoneypot($response); diff --git a/system/Format/JSONFormatter.php b/system/Format/JSONFormatter.php index 823f3ca..0dc0526 100644 --- a/system/Format/JSONFormatter.php +++ b/system/Format/JSONFormatter.php @@ -40,6 +40,7 @@ namespace CodeIgniter\Format; use CodeIgniter\Format\Exceptions\FormatException; +use Config\Format; /** * JSON data formatter @@ -56,13 +57,16 @@ */ public function format($data) { - $options = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR; + $config = new Format(); + + $options = $config->formatterOptions['application/json'] ?? JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + $options = $options | JSON_PARTIAL_OUTPUT_ON_ERROR; $options = ENVIRONMENT === 'production' ? $options : $options | JSON_PRETTY_PRINT; $result = json_encode($data, $options, 512); - if ( ! in_array(json_last_error(), [JSON_ERROR_NONE, JSON_ERROR_RECURSION])) + if (! in_array(json_last_error(), [JSON_ERROR_NONE, JSON_ERROR_RECURSION])) { throw FormatException::forInvalidJSON(json_last_error_msg()); } diff --git a/system/Format/XMLFormatter.php b/system/Format/XMLFormatter.php index 3e262d6..d8dd248 100644 --- a/system/Format/XMLFormatter.php +++ b/system/Format/XMLFormatter.php @@ -40,6 +40,7 @@ namespace CodeIgniter\Format; use CodeIgniter\Format\Exceptions\FormatException; +use Config\Format; /** * XML data formatter @@ -56,6 +57,8 @@ */ public function format($data) { + $config = new Format(); + // SimpleXML is installed but default // but best to check, and then provide a fallback. if (! extension_loaded('simplexml')) @@ -66,7 +69,8 @@ // @codeCoverageIgnoreEnd } - $output = new \SimpleXMLElement(''); + $options = $config->formatterOptions['application/xml'] ?? 0; + $output = new \SimpleXMLElement('', $options); $this->arrayToXML((array)$data, $output); @@ -91,19 +95,21 @@ { if (is_array($value)) { - if (! is_numeric($key)) + if (is_numeric($key)) { - $subnode = $output->addChild("$key"); - $this->arrayToXML($value, $subnode); + $key = "item{$key}"; } - else - { - $subnode = $output->addChild("item{$key}"); - $this->arrayToXML($value, $subnode); - } + + $subnode = $output->addChild("$key"); + $this->arrayToXML($value, $subnode); } else { + if (is_numeric($key)) + { + $key = "item{$key}"; + } + $output->addChild("$key", htmlspecialchars("$value")); } } diff --git a/system/HTTP/CLIRequest.php b/system/HTTP/CLIRequest.php index f9c4838..f5b624d 100644 --- a/system/HTTP/CLIRequest.php +++ b/system/HTTP/CLIRequest.php @@ -223,7 +223,7 @@ { // If there's no '-' at the beginning of the argument // then add it to our segments. - if (! $options_found && strpos($argv[$i], '-') === false) + if (! $options_found && strpos($argv[$i], '-') !== 0) { $this->segments[] = filter_var($argv[$i], FILTER_SANITIZE_STRING); continue; diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index d1a8e94..3ee990a 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -448,10 +448,9 @@ $output = $this->sendRequest($curl_options); - $continueStr = "HTTP/1.1 100 Continue\x0d\x0a\x0d\x0a"; - if (strpos($output, $continueStr) === 0) + if (strpos($output, 'HTTP/1.1 100 Continue') === 0) { - $output = substr($output, strlen($continueStr)); + $output = substr($output, strpos($output, "\r\n\r\n") + 4); } // Split out our headers and body @@ -493,6 +492,7 @@ $this->populateHeaders(); // Otherwise, it will corrupt the request $this->removeHeader('Host'); + $this->removeHeader('Accept-Encoding'); } $headers = $this->getHeaders(); @@ -542,7 +542,7 @@ if ($method === 'PUT' || $method === 'POST') { // See http://tools.ietf.org/html/rfc7230#section-3.3.2 - if (is_null($this->getHeader('content-length'))) + if (is_null($this->getHeader('content-length')) && ! isset($this->config['multipart'])) { $this->setHeader('Content-Length', '0'); } diff --git a/system/HTTP/ContentSecurityPolicy.php b/system/HTTP/ContentSecurityPolicy.php index 910f700..031cf3e 100644 --- a/system/HTTP/ContentSecurityPolicy.php +++ b/system/HTTP/ContentSecurityPolicy.php @@ -285,7 +285,7 @@ * * @return $this */ - public function addBaseURI($uri, ?bool $explicitReporting = null) + public function addBaseURI($uri, bool $explicitReporting = null) { $this->addOption($uri, 'baseURI', $explicitReporting ?? $this->reportOnly); @@ -309,7 +309,7 @@ * * @return $this */ - public function addChildSrc($uri, ?bool $explicitReporting = null) + public function addChildSrc($uri, bool $explicitReporting = null) { $this->addOption($uri, 'childSrc', $explicitReporting ?? $this->reportOnly); @@ -332,7 +332,7 @@ * * @return $this */ - public function addConnectSrc($uri, ?bool $explicitReporting = null) + public function addConnectSrc($uri, bool $explicitReporting = null) { $this->addOption($uri, 'connectSrc', $explicitReporting ?? $this->reportOnly); @@ -355,7 +355,7 @@ * * @return $this */ - public function setDefaultSrc($uri, ?bool $explicitReporting = null) + public function setDefaultSrc($uri, bool $explicitReporting = null) { $this->defaultSrc = [(string) $uri => $explicitReporting ?? $this->reportOnly]; @@ -377,7 +377,7 @@ * * @return $this */ - public function addFontSrc($uri, ?bool $explicitReporting = null) + public function addFontSrc($uri, bool $explicitReporting = null) { $this->addOption($uri, 'fontSrc', $explicitReporting ?? $this->reportOnly); @@ -397,7 +397,7 @@ * * @return $this */ - public function addFormAction($uri, ?bool $explicitReporting = null) + public function addFormAction($uri, bool $explicitReporting = null) { $this->addOption($uri, 'formAction', $explicitReporting ?? $this->reportOnly); @@ -417,7 +417,7 @@ * * @return $this */ - public function addFrameAncestor($uri, ?bool $explicitReporting = null) + public function addFrameAncestor($uri, bool $explicitReporting = null) { $this->addOption($uri, 'frameAncestors', $explicitReporting ?? $this->reportOnly); @@ -437,7 +437,7 @@ * * @return $this */ - public function addImageSrc($uri, ?bool $explicitReporting = null) + public function addImageSrc($uri, bool $explicitReporting = null) { $this->addOption($uri, 'imageSrc', $explicitReporting ?? $this->reportOnly); @@ -457,7 +457,7 @@ * * @return $this */ - public function addMediaSrc($uri, ?bool $explicitReporting = null) + public function addMediaSrc($uri, bool $explicitReporting = null) { $this->addOption($uri, 'mediaSrc', $explicitReporting ?? $this->reportOnly); @@ -477,7 +477,7 @@ * * @return $this */ - public function addManifestSrc($uri, ?bool $explicitReporting = null) + public function addManifestSrc($uri, bool $explicitReporting = null) { $this->addOption($uri, 'manifestSrc', $explicitReporting ?? $this->reportOnly); @@ -497,7 +497,7 @@ * * @return $this */ - public function addObjectSrc($uri, ?bool $explicitReporting = null) + public function addObjectSrc($uri, bool $explicitReporting = null) { $this->addOption($uri, 'objectSrc', $explicitReporting ?? $this->reportOnly); @@ -517,7 +517,7 @@ * * @return $this */ - public function addPluginType($mime, ?bool $explicitReporting = null) + public function addPluginType($mime, bool $explicitReporting = null) { $this->addOption($mime, 'pluginTypes', $explicitReporting ?? $this->reportOnly); @@ -556,7 +556,7 @@ * * @return $this */ - public function addSandbox($flags, ?bool $explicitReporting = null) + public function addSandbox($flags, bool $explicitReporting = null) { $this->addOption($flags, 'sandbox', $explicitReporting ?? $this->reportOnly); return $this; @@ -575,7 +575,7 @@ * * @return $this */ - public function addScriptSrc($uri, ?bool $explicitReporting = null) + public function addScriptSrc($uri, bool $explicitReporting = null) { $this->addOption($uri, 'scriptSrc', $explicitReporting ?? $this->reportOnly); @@ -595,7 +595,7 @@ * * @return $this */ - public function addStyleSrc($uri, ?bool $explicitReporting = null) + public function addStyleSrc($uri, bool $explicitReporting = null) { $this->addOption($uri, 'styleSrc', $explicitReporting ?? $this->reportOnly); @@ -630,7 +630,7 @@ * @param string $target * @param boolean|null $explicitReporting */ - protected function addOption($options, string $target, ?bool $explicitReporting = null) + protected function addOption($options, string $target, bool $explicitReporting = null) { // Ensure we have an array to work with... if (is_string($this->{$target})) diff --git a/system/HTTP/DownloadResponse.php b/system/HTTP/DownloadResponse.php index 080f6b2..15b9a7f 100644 --- a/system/HTTP/DownloadResponse.php +++ b/system/HTTP/DownloadResponse.php @@ -58,7 +58,7 @@ /** * Download for file * - * @var File? + * @var File */ private $file; @@ -263,7 +263,18 @@ //-------------------------------------------------------------------- /** - * {@inheritDoc} + * Return an instance with the specified status code and, optionally, reason phrase. + * + * If no reason phrase is specified, will default recommended reason phrase for + * the response's status code. + * + * @see http://tools.ietf.org/html/rfc7231#section-6 + * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * + * @param integer $code The 3-digit integer result code to set. + * @param string $reason The reason phrase to use with the + * provided status code; if none is provided, will + * default to the IANA name. * * @throws DownloadException */ @@ -275,7 +286,12 @@ //-------------------------------------------------------------------- /** - * {@inheritDoc} + * Gets the response response phrase associated with the status code. + * + * @see http://tools.ietf.org/html/rfc7231#section-6 + * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * + * @return string */ public function getReason(): string { @@ -288,7 +304,11 @@ //-------------------------------------------------------------------- /** - * {@inheritDoc} + * Sets the date header + * + * @param \DateTime $date + * + * @return ResponseInterface */ public function setDate(\DateTime $date) { @@ -302,7 +322,13 @@ //-------------------------------------------------------------------- /** - * {@inheritDoc} + * Sets the Content Type header for this response with the mime type + * and, optionally, the charset. + * + * @param string $mime + * @param string $charset + * + * @return ResponseInterface */ public function setContentType(string $mime, string $charset = 'UTF-8') { @@ -323,7 +349,8 @@ } /** - * {@inheritDoc} + * Sets the appropriate headers to ensure this response + * is not cached by the browsers. */ public function noCache(): self { @@ -337,7 +364,30 @@ //-------------------------------------------------------------------- /** - * {@inheritDoc} + * A shortcut method that allows the developer to set all of the + * cache-control headers in one method call. + * + * The options array is used to provide the cache-control directives + * for the header. It might look something like: + * + * $options = [ + * 'max-age' => 300, + * 's-maxage' => 900 + * 'etag' => 'abcde', + * ]; + * + * Typical options are: + * - etag + * - last-modified + * - max-age + * - s-maxage + * - private + * - public + * - must-revalidate + * - proxy-revalidate + * - no-transform + * + * @param array $options * * @throws DownloadException */ @@ -434,7 +484,7 @@ } // HTTP Status - header(sprintf('HTTP/%s %s %s', $this->protocolVersion, $this->getStatusCode(), $this->getReason()), true, + header(sprintf('HTTP/%s %s %s', $this->getProtocolVersion(), $this->getStatusCode(), $this->getReason()), true, $this->getStatusCode()); // Send all of our headers diff --git a/system/HTTP/Exceptions/HTTPException.php b/system/HTTP/Exceptions/HTTPException.php index 5aa4895..661e149 100644 --- a/system/HTTP/Exceptions/HTTPException.php +++ b/system/HTTP/Exceptions/HTTPException.php @@ -50,9 +50,8 @@ /** * For CurlRequest * - * @return \CodeIgniter\HTTP\Exceptions\HTTPException + * @return \CodeIgniter\HTTP\Exceptions\HTTPException * - * Not testable with travis-ci * @codeCoverageIgnore */ public static function forMissingCurl() @@ -251,6 +250,10 @@ /** * For Uploaded file move * + * @param string $source + * @param string $target + * @param string $error + * * @return \CodeIgniter\HTTP\Exceptions\HTTPException */ public static function forMoveFailed(string $source, string $target, string $error) diff --git a/system/HTTP/IncomingRequest.php b/system/HTTP/IncomingRequest.php index b7c04df..4a89067 100755 --- a/system/HTTP/IncomingRequest.php +++ b/system/HTTP/IncomingRequest.php @@ -166,7 +166,7 @@ $body = file_get_contents('php://input'); } - $this->body = $body; + $this->body = ! empty($body) ? $body : null; $this->config = $config; $this->userAgent = $userAgent; @@ -294,8 +294,7 @@ */ public function isAJAX(): bool { - return ( ! empty($_SERVER['HTTP_X_REQUESTED_WITH']) && - strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'); + return $this->hasHeader('X-Requested-With') && strtolower($this->getHeader('X-Requested-With')->getValue()) === 'xmlhttprequest'; } //-------------------------------------------------------------------- @@ -312,11 +311,11 @@ { return true; } - elseif (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') + elseif ($this->hasHeader('X-Forwarded-Proto') && $this->getHeader('X-Forwarded-Proto')->getValue() === 'https') { return true; } - elseif (! empty($_SERVER['HTTP_FRONT_END_HTTPS']) && strtolower($_SERVER['HTTP_FRONT_END_HTTPS']) !== 'off') + elseif ($this->hasHeader('Front-End-Https') && ! empty($this->getHeader('Front-End-Https')->getValue()) && strtolower($this->getHeader('Front-End-Https')->getValue()) !== 'off') { return true; } @@ -427,7 +426,7 @@ // Use $_POST directly here, since filter_has_var only // checks the initial POST data, not anything that might // have been added since. - return isset($_POST[$index]) ? $this->getPost($index, $filter, $flags) : (isset($_GET[$index]) ? $this->getGet($index, $filter, $flags) : $this->getPost()); + return isset($_POST[$index]) ? $this->getPost($index, $filter, $flags) : (isset($_GET[$index]) ? $this->getGet($index, $filter, $flags) : $this->getPost($index, $filter, $flags)); } //-------------------------------------------------------------------- @@ -446,7 +445,7 @@ // Use $_GET directly here, since filter_has_var only // checks the initial GET data, not anything that might // have been added since. - return isset($_GET[$index]) ? $this->getGet($index, $filter, $flags) : (isset($_POST[$index]) ? $this->getPost($index, $filter, $flags) : $this->getGet()); + return isset($_GET[$index]) ? $this->getGet($index, $filter, $flags) : (isset($_POST[$index]) ? $this->getPost($index, $filter, $flags) : $this->getGet($index, $filter, $flags)); } //-------------------------------------------------------------------- diff --git a/system/HTTP/Negotiate.php b/system/HTTP/Negotiate.php index 74aa470..793e134 100644 --- a/system/HTTP/Negotiate.php +++ b/system/HTTP/Negotiate.php @@ -1,6 +1,5 @@ getCookies(); + + if (empty($cookies)) + { + return $this; + } + + foreach ($cookies as $cookie) + { + $this->setCookie( + $cookie['name'], + $cookie['value'], + $cookie['expires'], + $cookie['domain'], + $cookie['path'], + '', // prefix + $cookie['secure'], + $cookie['httponly'] + ); + } + + return $this; + } + + /** + * Copies any headers from the global Response instance + * into this RedirectResponse. Useful when you've just + * set a header be need to ensure its actually sent + * with the redirect response. + * + * @return $this|RedirectResponse + */ + public function withHeaders() + { + $headers = service('response')->getHeaders(); + + if (empty($headers)) + { + return $this; + } + + foreach ($headers as $name => $header) + { + $this->setHeader($name, $header->getValue()); + } + + return $this; + } + + /** * Ensures the session is loaded and started. * * @return \CodeIgniter\Session\Session diff --git a/system/HTTP/Request.php b/system/HTTP/Request.php index b8b222e..ff30dc9 100644 --- a/system/HTTP/Request.php +++ b/system/HTTP/Request.php @@ -420,6 +420,16 @@ $value = $this->globals[$method][$index] ?? null; } + if (is_array($value) && ($filter !== null || $flags !== null)) + { + // Iterate over array and append filter and flags + array_walk_recursive($value, function (&$val) use ($filter, $flags) { + $val = filter_var($val, $filter, $flags); + }); + + return $value; + } + // Cannot filter these types of data automatically... if (is_array($value) || is_object($value) || is_null($value)) { diff --git a/system/HTTP/Response.php b/system/HTTP/Response.php index 0d8320b..7d0e680 100644 --- a/system/HTTP/Response.php +++ b/system/HTTP/Response.php @@ -1,6 +1,5 @@ cookies; + } + + /** * Actually sets the cookies. */ protected function sendCookies() diff --git a/system/HTTP/URI.php b/system/HTTP/URI.php index 09aa3cd..fc79756 100644 --- a/system/HTTP/URI.php +++ b/system/HTTP/URI.php @@ -153,6 +153,13 @@ */ protected $showPassword = false; + /** + * If true, will continue instead of throwing exceptions. + * + * @var boolean + */ + protected $silent = false; + //-------------------------------------------------------------------- /** @@ -173,6 +180,23 @@ //-------------------------------------------------------------------- /** + * If $silent == true, then will not throw exceptions and will + * attempt to continue gracefully. + * + * @param boolean $silent + * + * @return URI + */ + public function setSilent(bool $silent = true) + { + $this->silent = $silent; + + return $this; + } + + //-------------------------------------------------------------------- + + /** * Sets and overwrites any current URI information. * * @param string|null $uri @@ -187,6 +211,11 @@ if ($parts === false) { + if ($this->silent) + { + return $this; + } + throw HTTPException::forUnableToParseURI($uri); } @@ -469,23 +498,24 @@ /** * Returns the value of a specific segment of the URI path. * - * @param integer $number + * @param integer $number Segment number + * @param string $default Default value * * @return string The value of the segment. If no segment is found, * throws InvalidArgumentError */ - public function getSegment(int $number): string + public function getSegment(int $number, string $default = ''): string { // The segment should treat the array as 1-based for the user // but we still have to deal with a zero-based array. $number -= 1; - if ($number > count($this->segments)) + if ($number > count($this->segments) && ! $this->silent) { throw HTTPException::forURISegmentOutOfRange($number); } - return $this->segments[$number] ?? ''; + return $this->segments[$number] ?? $default; } /** @@ -505,6 +535,11 @@ if ($number > count($this->segments) + 1) { + if ($this->silent) + { + return $this; + } + throw HTTPException::forURISegmentOutOfRange($number); } @@ -568,7 +603,7 @@ if ($path !== '') { - $uri .= substr($uri, -1, 1) !== '/' ? '/' . ltrim($path, '/') : $path; + $uri .= substr($uri, -1, 1) !== '/' ? '/' . ltrim($path, '/') : ltrim($path, '/'); } if ($query) @@ -689,6 +724,11 @@ if ($port <= 0 || $port > 65535) { + if ($this->silent) + { + return $this; + } + throw HTTPException::forInvalidPort($port); } @@ -749,6 +789,11 @@ { if (strpos($query, '#') !== false) { + if ($this->silent) + { + return $this; + } + throw HTTPException::forMalformedQueryString(); } diff --git a/system/HTTP/UserAgent.php b/system/HTTP/UserAgent.php index c6873fd..4f7777a 100644 --- a/system/HTTP/UserAgent.php +++ b/system/HTTP/UserAgent.php @@ -51,7 +51,7 @@ * * @var string */ - protected $agent; + protected $agent = ''; /** * Flag for if the user-agent belongs to a browser @@ -130,14 +130,11 @@ * * Sets the User Agent and runs the compilation routine * - * @param null $config + * @param null|\Config\UserAgents $config */ - public function __construct($config = null) + public function __construct(UserAgents $config = null) { - if (is_null($config)) - { - $this->config = new UserAgents(); - } + $this->config = $config ?? new UserAgents(); if (isset($_SERVER['HTTP_USER_AGENT'])) { diff --git a/system/Helpers/filesystem_helper.php b/system/Helpers/filesystem_helper.php index 2556e86..bf0353c 100644 --- a/system/Helpers/filesystem_helper.php +++ b/system/Helpers/filesystem_helper.php @@ -94,7 +94,7 @@ closedir($fp); return $fileData; } - catch (\Exception $fe) + catch (\Throwable $e) { return []; } @@ -138,7 +138,7 @@ return is_int($result); } - catch (\Exception $fe) + catch (\Throwable $e) { return false; } @@ -160,39 +160,48 @@ * @param string $path File path * @param boolean $del_dir Whether to delete any directories found in the path * @param boolean $htdocs Whether to skip deleting .htaccess and index page files - * @param integer $_level Current directory depth level (default: 0; internal use only) + * @param boolean $hidden Whether to include hidden files (files beginning with a period) * * @return boolean */ - function delete_files(string $path, bool $del_dir = false, bool $htdocs = false, int $_level = 0): bool + function delete_files(string $path, bool $del_dir = false, bool $htdocs = false, bool $hidden = false): bool { - // Trim the trailing slash - $path = rtrim($path, '/\\'); + $path = realpath($path) ?: $path; + $path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; try { - $current_dir = opendir($path); - - while (false !== ($filename = @readdir($current_dir))) + foreach (new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ) as $object) { - if ($filename !== '.' && $filename !== '..') + $filename = $object->getFilename(); + + if (! $hidden && $filename[0] === '.') { - if (is_dir($path . DIRECTORY_SEPARATOR . $filename) && $filename[0] !== '.') + continue; + } + elseif (! $htdocs || ! preg_match('/^(\.htaccess|index\.(html|htm|php)|web\.config)$/i', $filename)) + { + $isDir = $object->isDir(); + + if ($isDir && $del_dir) { - delete_files($path . DIRECTORY_SEPARATOR . $filename, $del_dir, $htdocs, $_level + 1); + @rmdir($object->getPathname()); + continue; } - elseif ($htdocs !== true || ! preg_match('/^(\.htaccess|index\.(html|htm|php)|web\.config)$/i', $filename)) + + if (! $isDir) { - @unlink($path . DIRECTORY_SEPARATOR . $filename); + @unlink($object->getPathname()); } } } - closedir($current_dir); - - return ($del_dir === true && $_level > 0) ? @rmdir($path) : true; + return true; } - catch (\Exception $fe) + catch (\Throwable $e) { return false; } @@ -311,7 +320,7 @@ return $fileData; } } - catch (\Exception $fe) + catch (\Throwable $fe) { return []; } diff --git a/system/Helpers/form_helper.php b/system/Helpers/form_helper.php index e505e65..5a1ad9d 100644 --- a/system/Helpers/form_helper.php +++ b/system/Helpers/form_helper.php @@ -843,7 +843,7 @@ } // Unchecked checkbox and radio inputs are not even submitted by browsers ... - if (intval($input) === 0 || ! empty($request->getPost()) || ! empty(old($field))) + if ((string) $input === '0' || ! empty($request->getPost()) || ! empty(old($field))) { return ($input === $value) ? ' checked="checked"' : ''; } @@ -895,7 +895,7 @@ // Unchecked checkbox and radio inputs are not even submitted by browsers ... $result = ''; - if (intval($input) === 0 || ! empty($input = $request->getPost($field)) || ! empty($input = old($field))) + if ((string) $input === '0' || ! empty($input = $request->getPost($field)) || ! empty($input = old($field))) { $result = ($input === $value) ? ' checked="checked"' : ''; } diff --git a/system/Helpers/html_helper.php b/system/Helpers/html_helper.php index 373cec3..535812b 100755 --- a/system/Helpers/html_helper.php +++ b/system/Helpers/html_helper.php @@ -273,9 +273,10 @@ * @param string $title * @param string $media * @param boolean $indexPage should indexPage be added to the CSS path. + * @param string $hreflang * @return string */ - function link_tag($href = '', string $rel = 'stylesheet', string $type = 'text/css', string $title = '', string $media = '', bool $indexPage = false): string + function link_tag($href = '', string $rel = 'stylesheet', string $type = 'text/css', string $title = '', string $media = '', bool $indexPage = false, string $hreflang = ''): string { $link = ''; } } - // ------------------------------------------------------------------------ + +// ------------------------------------------------------------------------ if (! function_exists('video')) { diff --git a/system/Helpers/number_helper.php b/system/Helpers/number_helper.php index f535198..cc9e925 100644 --- a/system/Helpers/number_helper.php +++ b/system/Helpers/number_helper.php @@ -214,7 +214,7 @@ function format_number(float $num, int $precision = 1, string $locale = null, array $options = []): string { // Locale is either passed in here, negotiated with client, or grabbed from our config file. - $locale = $locale ?? \CodeIgniter\Config\Services::request()->getLocale(); + $locale = $locale ?? \Config\Services::request()->getLocale(); // Type can be any of the NumberFormatter options, but provide a default. $type = (int) ($options['type'] ?? NumberFormatter::DECIMAL); diff --git a/system/Helpers/test_helper.php b/system/Helpers/test_helper.php new file mode 100644 index 0000000..0f3e454 --- /dev/null +++ b/system/Helpers/test_helper.php @@ -0,0 +1,73 @@ +setOverrides($overrides); + } + + return $fabricator->create(); + } +} diff --git a/system/Helpers/url_helper.php b/system/Helpers/url_helper.php index ece4e38..e39ed27 100644 --- a/system/Helpers/url_helper.php +++ b/system/Helpers/url_helper.php @@ -111,7 +111,7 @@ // We should be using the configured baseURL that the user set; // otherwise get rid of the path, because we have // no way of knowing the intent... - $config = \CodeIgniter\Config\Services::request()->config; + $config = \Config\Services::request()->config; // If baseUrl does not have a trailing slash it won't resolve // correctly for users hosting in a subfolder. @@ -130,7 +130,7 @@ // If the scheme wasn't provided, check to // see if it was a secure request - if (empty($protocol) && \CodeIgniter\Config\Services::request()->isSecure()) + if (empty($protocol) && \Config\Services::request()->isSecure()) { $protocol = 'https'; } @@ -160,25 +160,21 @@ */ function current_url(bool $returnObject = false) { - $uri = clone service('request')->uri; + $uri = clone \Config\Services::request()->uri; // 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. - $baseUri = new \CodeIgniter\HTTP\URI(config('App')->baseURL); + $baseUri = new \CodeIgniter\HTTP\URI(config(\Config\App::class)->baseURL); if (! empty($baseUri->getPath())) { - $path = rtrim($baseUri->getPath(), '/ ') . '/' . $uri->getPath(); - - $uri->setPath($path); + $uri->setPath(rtrim($baseUri->getPath(), '/ ') . '/' . $uri->getPath()); } // Since we're basing off of the IncomingRequest URI, // we are guaranteed to have a host based on our own configs. - return $returnObject - ? $uri - : (string)$uri->setQuery(''); + return $returnObject ? $uri : (string) $uri->setQuery(''); } } @@ -201,7 +197,7 @@ // Grab from the session first, if we have it, // since it's more reliable and safer. // Otherwise, grab a sanitized version from $_SERVER. - $referer = $_SESSION['_ci_previous_url'] ?? \CodeIgniter\Config\Services::request()->getServer('HTTP_REFERER', FILTER_SANITIZE_URL); + $referer = $_SESSION['_ci_previous_url'] ?? \Config\Services::request()->getServer('HTTP_REFERER', FILTER_SANITIZE_URL); $referer = $referer ?? site_url('/'); @@ -222,7 +218,7 @@ */ function uri_string(): string { - return \CodeIgniter\Config\Services::request()->uri->getPath(); + return \Config\Services::request()->uri->getPath(); } } @@ -613,4 +609,28 @@ } } +// ------------------------------------------------------------------------ + +if (! function_exists('mb_url_title')) +{ + /** + * Create URL Title that takes into account accented characters + * + * Takes a "title" string as input and creates a + * human-friendly URL string with a "separator" string + * as the word separator. + * + * @param string $str Input string + * @param string $separator Word separator (usually '-' or '_') + * @param boolean $lowercase Whether to transform the output string to lowercase + * @return string + */ + function mb_url_title(string $str, string $separator = '-', bool $lowercase = false): string + { + helper('text'); + + return url_title(convert_accented_characters($str), $separator, $lowercase); + } +} + //-------------------------------------------------------------------- diff --git a/system/Honeypot/Honeypot.php b/system/Honeypot/Honeypot.php index a8d908a..b944395 100644 --- a/system/Honeypot/Honeypot.php +++ b/system/Honeypot/Honeypot.php @@ -73,6 +73,11 @@ throw HoneypotException::forNoHiddenValue(); } + if (empty($this->config->container) || strpos($this->config->container, '{template}') === false) + { + $this->config->container = '
{template}
'; + } + if ($this->config->template === '') { throw HoneypotException::forNoTemplate(); @@ -124,8 +129,9 @@ if ($this->config->hidden) { - $template = '
' . $template . '
'; + $template = str_ireplace('{template}', $template, $this->config->container); } + return $template; } diff --git a/system/I18n/Exceptions/I18nException.php b/system/I18n/Exceptions/I18nException.php index a2d21c1..dfbec95 100644 --- a/system/I18n/Exceptions/I18nException.php +++ b/system/I18n/Exceptions/I18nException.php @@ -1,35 +1,126 @@ -format('Y-m-d H:i:s'), $timezone); } @@ -548,6 +548,7 @@ /** * Returns the age in years from the "current" date and 'now' * + * @return integer * @throws \Exception */ public function getAge() @@ -592,6 +593,7 @@ if ($transition['time'] > $this->format('U')) { $daylightSaving = (bool) $transition['isdst'] ?? $daylightSaving; + break; } } return $daylightSaving; @@ -641,7 +643,7 @@ /** * Sets the current year for this instance. * - * @param $value + * @param integer|string $value * * @return \CodeIgniter\I18n\Time * @throws \Exception @@ -654,7 +656,7 @@ /** * Sets the month of the year. * - * @param $value + * @param integer|string $value * * @return \CodeIgniter\I18n\Time * @throws \Exception @@ -677,7 +679,7 @@ /** * Sets the day of the month. * - * @param $value + * @param integer|string $value * * @return \CodeIgniter\I18n\Time * @throws \Exception @@ -702,7 +704,7 @@ /** * Sets the hour of the day (24 hour cycle) * - * @param $value + * @param integer|string $value * * @return \CodeIgniter\I18n\Time * @throws \Exception @@ -720,7 +722,7 @@ /** * Sets the minute of the hour * - * @param $value + * @param integer|string $value * * @return \CodeIgniter\I18n\Time * @throws \Exception @@ -738,7 +740,7 @@ /** * Sets the second of the minute. * - * @param $value + * @param integer|string $value * * @return \CodeIgniter\I18n\Time * @throws \Exception @@ -756,8 +758,8 @@ /** * Helper method to do the heavy lifting of the 'setX' methods. * - * @param string $name - * @param $value + * @param string $name + * @param integer $value * * @return \CodeIgniter\I18n\Time * @throws \Exception @@ -773,14 +775,15 @@ /** * Returns a new instance with the revised timezone. * - * @param \DateTimeZone $timezone + * @param string|\DateTimeZone $timezone * * @return \CodeIgniter\I18n\Time * @throws \Exception */ public function setTimezone($timezone) { - return Time::parse($this->toDateTimeString(), $timezone, $this->locale); + $timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone); + return Time::instance($this->toDateTime()->setTimezone($timezone), $this->locale); } /** @@ -1037,7 +1040,7 @@ * @return string|boolean * @throws \Exception */ - public function toLocalizedString(?string $format = null) + public function toLocalizedString(string $format = null) { $format = $format ?? $this->toStringFormat; @@ -1252,11 +1255,11 @@ $time = $time->toDateTime() ->setTimezone(new DateTimeZone('UTC')); } - else if ($time instanceof DateTime) + elseif ($time instanceof DateTime) { $time = $time->setTimezone(new DateTimeZone('UTC')); } - else if (is_string($time)) + elseif (is_string($time)) { $timezone = $timezone ?: $this->timezone; $timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone); @@ -1327,7 +1330,7 @@ * return values. * See http://php.net/manual/en/language.oop5.overloading.php * - * @param $name + * @param string $name * * @return mixed */ @@ -1348,7 +1351,7 @@ /** * Allow for property-type checking to any getX method... * - * @param $name + * @param string $name * * @return boolean */ diff --git a/system/I18n/TimeDifference.php b/system/I18n/TimeDifference.php index abfbf76..e17b7ba 100644 --- a/system/I18n/TimeDifference.php +++ b/system/I18n/TimeDifference.php @@ -52,7 +52,7 @@ /** * The timestamp of the "current" time. * - * @var integer + * @var \IntlCalendar */ protected $currentTime; @@ -291,28 +291,28 @@ $phrase = lang('Time.years', [abs($years)], $locale); $before = $years < 0; } - else if ($months !== 0) + elseif ($months !== 0) { $phrase = lang('Time.months', [abs($months)], $locale); $before = $months < 0; } - else if ($days !== 0 && (abs($days) >= 7)) + elseif ($days !== 0 && (abs($days) >= 7)) { $weeks = ceil($days / 7); $phrase = lang('Time.weeks', [abs($weeks)], $locale); $before = $days < 0; } - else if ($days !== 0) + elseif ($days !== 0) { $phrase = lang('Time.days', [abs($days)], $locale); $before = $days < 0; } - else if ($hours !== 0) + elseif ($hours !== 0) { $phrase = lang('Time.hours', [abs($hours)], $locale); $before = $hours < 0; } - else if ($minutes !== 0) + elseif ($minutes !== 0) { $phrase = lang('Time.minutes', [abs($minutes)], $locale); $before = $minutes < 0; @@ -330,7 +330,7 @@ /** * Allow property-like access to our calculated values. * - * @param $name + * @param string $name * * @return mixed */ @@ -350,7 +350,7 @@ /** * Allow property-like checking for our calculated values. * - * @param $name + * @param string $name * * @return boolean */ diff --git a/system/Images/Handlers/BaseHandler.php b/system/Images/Handlers/BaseHandler.php index 5ec903b..f7e59b6 100644 --- a/system/Images/Handlers/BaseHandler.php +++ b/system/Images/Handlers/BaseHandler.php @@ -41,6 +41,7 @@ use CodeIgniter\Images\Exceptions\ImageException; use CodeIgniter\Images\Image; use CodeIgniter\Images\ImageHandlerInterface; +use Config\Images; /** * Base image handling implementation @@ -86,7 +87,7 @@ /** * File permission mask. * - * @var type + * @var integer */ protected $filePermissions = 0644; @@ -143,11 +144,11 @@ /** * Constructor. * - * @param type $config + * @param \Config\Images|null $config */ public function __construct($config = null) { - $this->config = $config; + $this->config = $config ?? new Images(); } //-------------------------------------------------------------------- @@ -181,26 +182,7 @@ /** * Make the image resource object if needed */ - protected function ensureResource() - { - if ($this->resource === null) - { - $path = $this->image()->getPathname(); - // if valid image type, make corresponding image resource - switch ($this->image()->imageType) - { - case IMAGETYPE_GIF: - $this->resource = imagecreatefromgif($path); - break; - case IMAGETYPE_JPEG: - $this->resource = imagecreatefromjpeg($path); - break; - case IMAGETYPE_PNG: - $this->resource = imagecreatefrompng($path); - break; - } - } - } + protected abstract function ensureResource(); //-------------------------------------------------------------------- @@ -218,9 +200,9 @@ * Verifies that a file has been supplied and it is an image. * * @return Image The image instance - * @throws type ImageException + * @throws ImageException */ - protected function image(): ?Image + protected function image(): Image { if ($this->verified) { @@ -269,6 +251,21 @@ //-------------------------------------------------------------------- /** + * Load the temporary image used during the image processing. + * Some functions e.g. save() will only copy and not compress + * your image otherwise. + * + * @return $this + */ + public function withResource() + { + $this->ensureResource(); + return $this; + } + + //-------------------------------------------------------------------- + + /** * Resize the image * * @param integer $width @@ -312,7 +309,7 @@ * @param boolean $maintainRatio * @param string $masterDim * - * @return mixed + * @return $this */ public function crop(int $width = null, int $height = null, int $x = null, int $y = null, bool $maintainRatio = false, string $masterDim = 'auto') { @@ -358,7 +355,7 @@ * * @param float $angle * - * @return mixed + * @return $this */ public function rotate(float $angle) { @@ -400,7 +397,7 @@ * @param integer $green * @param integer $blue * - * @return mixed + * @return $this */ public function flatten(int $red = 255, int $green = 255, int $blue = 255) { @@ -419,8 +416,8 @@ * @param integer $green * @param integer $blue * - * @return mixed - * @internal param int $angle + * @return $this + * @internal */ protected abstract function _flatten(int $red = 255, int $green = 255, int $blue = 255); @@ -464,7 +461,7 @@ * * @param string $direction * - * @return mixed + * @return $this */ protected abstract function _flip(string $direction); @@ -560,7 +557,6 @@ * EXIF data is only supported fr JPEG & TIFF formats. * * @param string|null $key If specified, will only return this piece of EXIF data. - * * @param boolean $silent If true, will not throw our own exceptions. * * @return mixed @@ -610,7 +606,7 @@ * @param integer $height * @param string $position * - * @return boolean + * @return $this */ public function fit(int $width, int $height = null, string $position = 'center') { @@ -635,10 +631,10 @@ /** * Calculate image aspect ratio. * - * @param $width - * @param null $height - * @param $origWidth - * @param $origHeight + * @param integer|float $width + * @param integer|float|null $height + * @param integer|float $origWidth + * @param integer|float $origHeight * * @return array */ @@ -679,11 +675,11 @@ * Based on the position, will determine the correct x/y coords to * crop the desired portion from the image. * - * @param $width - * @param $height - * @param $origWidth - * @param $origHeight - * @param $position + * @param integer|float $width + * @param integer|float $height + * @param integer|float $origWidth + * @param integer|float $origHeight + * @param string $position * * @return array */ @@ -756,10 +752,10 @@ * $image->resize(100, 200, true) * ->save($target); * - * @param string $target - * @param integer $quality + * @param string|null $target + * @param integer $quality * - * @return mixed + * @return boolean */ public abstract function save(string $target = null, int $quality = 90); @@ -870,7 +866,7 @@ * * accessor for testing; not part of interface * - * @return type + * @return integer */ public function getHeight() { diff --git a/system/Images/Handlers/GDHandler.php b/system/Images/Handlers/GDHandler.php index d57034c..f49df25 100644 --- a/system/Images/Handlers/GDHandler.php +++ b/system/Images/Handlers/GDHandler.php @@ -49,8 +49,8 @@ /** * Constructor. * - * @param type $config - * @throws type + * @param \Config\Images|null $config + * @throws ImageException */ public function __construct($config = null) { @@ -151,49 +151,9 @@ { $srcImg = $this->createImage(); - $width = $this->image()->origWidth; - $height = $this->image()->origHeight; + $angle = $direction === 'horizontal' ? IMG_FLIP_HORIZONTAL : IMG_FLIP_VERTICAL; - if ($direction === 'horizontal') - { - for ($i = 0; $i < $height; $i ++) - { - $left = 0; - $right = $width - 1; - - while ($left < $right) - { - $cl = imagecolorat($srcImg, $left, $i); - $cr = imagecolorat($srcImg, $right, $i); - - imagesetpixel($srcImg, $left, $i, $cr); - imagesetpixel($srcImg, $right, $i, $cl); - - $left ++; - $right --; - } - } - } - else - { - for ($i = 0; $i < $width; $i ++) - { - $top = 0; - $bottom = $height - 1; - - while ($top < $bottom) - { - $ct = imagecolorat($srcImg, $i, $top); - $cb = imagecolorat($srcImg, $i, $bottom); - - imagesetpixel($srcImg, $i, $top, $cb); - imagesetpixel($srcImg, $i, $bottom, $ct); - - $top ++; - $bottom --; - } - } - } + imageflip($srcImg, $angle); $this->resource = $srcImg; @@ -224,7 +184,7 @@ /** * Resizes the image. * - * @return boolean|\CodeIgniter\Images\Handlers\GDHandler + * @return \CodeIgniter\Images\Handlers\GDHandler */ public function _resize() { @@ -236,7 +196,7 @@ /** * Crops the image. * - * @return boolean|\CodeIgniter\Images\Handlers\GDHandler + * @return \CodeIgniter\Images\Handlers\GDHandler */ public function _crop() { @@ -250,7 +210,7 @@ * * @param string $action * - * @return $this|bool + * @return $this */ protected function process(string $action) { @@ -318,7 +278,25 @@ */ public function save(string $target = null, int $quality = 90): bool { - $target = empty($target) ? $this->image()->getPathname() : $target; + $original = $target; + $target = empty($target) ? $this->image()->getPathname() : $target; + + // If no new resource has been created, then we're + // simply copy the existing one. + if (empty($this->resource) && $quality === 100) + { + if ($original === null) + { + return true; + } + + $name = basename($target); + $path = pathinfo($target, PATHINFO_DIRNAME); + + return $this->image()->copy($path, $name); + } + + $this->ensureResource(); switch ($this->image()->imageType) { @@ -355,6 +333,17 @@ throw ImageException::forSaveFailed(); } break; + case IMAGETYPE_WEBP: + if (! function_exists('imagewebp')) + { + throw ImageException::forInvalidImageCreate(lang('images.webpNotSupported')); + } + + if (! @imagewebp($this->resource, $target)) + { + throw ImageException::forSaveFailed(); + } + break; default: throw ImageException::forInvalidImageCreate(); } @@ -396,6 +385,38 @@ $imageType = $this->image()->imageType; } + return $this->getImageResource($path, $imageType); + } + + //-------------------------------------------------------------------- + + /** + * Make the image resource object if needed + */ + protected function ensureResource() + { + if ($this->resource === null) + { + // if valid image type, make corresponding image resource + $this->resource = $this->getImageResource( + $this->image()->getPathname(), $this->image()->imageType + ); + } + } + + //-------------------------------------------------------------------- + + /** + * Check if image type is supported and return image resource + * + * @param string $path Image path + * @param integer $imageType Image type + * + * @return resource|boolean + * @throws ImageException + */ + protected function getImageResource(string $path, int $imageType) + { switch ($imageType) { case IMAGETYPE_GIF: @@ -419,6 +440,13 @@ } return imagecreatefrompng($path); + case IMAGETYPE_WEBP: + if (! function_exists('imagecreatefromwebp')) + { + throw ImageException::forInvalidImageCreate(lang('images.webpNotSupported')); + } + + return imagecreatefromwebp($path); default: throw ImageException::forInvalidImageCreate('Ima'); } @@ -529,6 +557,8 @@ * @param string $text * @param array $options * @param boolean $isShadow Whether we are drawing the dropshadow or actual text + * + * @return void */ protected function textOverlay(string $text, array $options = [], bool $isShadow = false) { diff --git a/system/Images/Handlers/ImageMagickHandler.php b/system/Images/Handlers/ImageMagickHandler.php index cc5f577..332cc2a 100644 --- a/system/Images/Handlers/ImageMagickHandler.php +++ b/system/Images/Handlers/ImageMagickHandler.php @@ -60,7 +60,7 @@ /** * Stores image resource in memory. * - * @var + * @var string */ protected $resource; @@ -69,8 +69,8 @@ /** * Constructor. * - * @param type $config - * @throws type + * @param \Config\Images $config + * @throws ImageException */ public function __construct($config = null) { @@ -106,7 +106,9 @@ $escape = ''; } - $action = $maintainRatio === true ? ' -resize ' . $this->width . 'x' . $this->height . ' "' . $source . '" "' . $destination . '"' : ' -resize ' . $this->width . 'x' . $this->height . "{$escape}! \"" . $source . '" "' . $destination . '"'; + $action = $maintainRatio === true + ? ' -resize ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . ' "' . $source . '" "' . $destination . '"' + : ' -resize ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . "{$escape}! \"" . $source . '" "' . $destination . '"'; $this->process($action); @@ -126,7 +128,13 @@ $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); $destination = $this->getResourcePath(); - $action = ' -crop ' . $this->width . 'x' . $this->height . '+' . $this->xAxis . '+' . $this->yAxis . ' "' . $source . '" "' . $destination . '"'; + $extent = ' '; + if ($this->xAxis >= $this->width || $this->yAxis > $this->height) + { + $extent = ' -background transparent -extent ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . ' '; + } + + $action = ' -crop ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . '+' . ($this->xAxis ?? 0) . '+' . ($this->yAxis ?? 0) . $extent . escapeshellarg($source) . ' ' . escapeshellarg($destination); $this->process($action); @@ -151,7 +159,7 @@ $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); $destination = $this->getResourcePath(); - $action = ' ' . $angle . ' "' . $source . '" "' . $destination . '"'; + $action = ' ' . $angle . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination); $this->process($action); @@ -172,12 +180,12 @@ */ public function _flatten(int $red = 255, int $green = 255, int $blue = 255) { - $flatten = "-background RGB({$red},{$green},{$blue}) -flatten"; + $flatten = "-background 'rgb({$red},{$green},{$blue})' -flatten"; $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); $destination = $this->getResourcePath(); - $action = ' ' . $flatten . ' "' . $source . '" "' . $destination . '"'; + $action = ' ' . $flatten . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination); $this->process($action); @@ -201,7 +209,7 @@ $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); $destination = $this->getResourcePath(); - $action = ' ' . $angle . ' "' . $source . '" "' . $destination . '"'; + $action = ' ' . $angle . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination); $this->process($action); @@ -244,6 +252,11 @@ throw ImageException::forInvalidImageLibraryPath($this->config->libraryPath); } + if ($action !== '-version') + { + $this->supportedFormatCheck(); + } + if (! preg_match('/convert$/i', $this->config->libraryPath)) { $this->config->libraryPath = rtrim($this->config->libraryPath, '/') . '/convert'; @@ -286,21 +299,29 @@ */ public function save(string $target = null, int $quality = 90): bool { - $target = empty($target) ? $this->image() : $target; + $original = $target; + $target = empty($target) ? $this->image()->getPathname() : $target; // If no new resource has been created, then we're // simply copy the existing one. - if (empty($this->resource)) + if (empty($this->resource) && $quality === 100) { + if ($original === null) + { + return true; + } + $name = basename($target); $path = pathinfo($target, PATHINFO_DIRNAME); return $this->image()->copy($path, $name); } + $this->ensureResource(); + // Copy the file through ImageMagick so that it has // a chance to convert file format. - $action = '"' . $this->resource . '" "' . $target . '"'; + $action = escapeshellarg($this->resource) . ' ' . escapeshellarg($target); $result = $this->process($action, $quality); @@ -335,12 +356,49 @@ $this->resource = WRITEPATH . 'cache/' . time() . '_' . bin2hex(random_bytes(10)) . '.png'; + $name = basename($this->resource); + $path = pathinfo($this->resource, PATHINFO_DIRNAME); + + $this->image()->copy($path, $name); + return $this->resource; } //-------------------------------------------------------------------- /** + * Make the image resource object if needed + * + * @throws \Exception + */ + protected function ensureResource() + { + $this->getResourcePath(); + + $this->supportedFormatCheck(); + } + + /** + * Check if given image format is supported + * + * @throws ImageException + */ + protected function supportedFormatCheck() + { + switch ($this->image()->imageType) + { + case IMAGETYPE_WEBP: + if (! in_array('WEBP', \Imagick::queryFormats())) + { + throw ImageException::forInvalidImageCreate(lang('images.webpNotSupported')); + } + break; + } + } + + //-------------------------------------------------------------------- + + /** * Handler-specific method for overlaying text on an image. * * @param string $text @@ -446,21 +504,60 @@ /** * Return the width of an image. * - * @return type + * @return integer */ public function _getWidth() { - return imagesx($this->resource); + return imagesx(imagecreatefromstring(file_get_contents($this->resource))); } /** * Return the height of an image. * - * @return type + * @return integer */ public function _getHeight() { - return imagesy($this->resource); + return imagesy(imagecreatefromstring(file_get_contents($this->resource))); } + //-------------------------------------------------------------------- + + /** + * Reads the EXIF information from the image and modifies the orientation + * so that displays correctly in the browser. This is especially an issue + * with images taken by smartphones who always store the image up-right, + * but set the orientation flag to display it correctly. + * + * @param boolean $silent If true, will ignore exceptions when PHP doesn't support EXIF. + * + * @return $this + */ + public function reorient(bool $silent = false) + { + $orientation = $this->getEXIF('Orientation', $silent); + + switch ($orientation) + { + case 2: + return $this->flip('horizontal'); + case 3: + return $this->rotate(180); + case 4: + return $this->rotate(180) + ->flip('horizontal'); + case 5: + return $this->rotate(90) + ->flip('horizontal'); + case 6: + return $this->rotate(90); + case 7: + return $this->rotate(270) + ->flip('horizontal'); + case 8: + return $this->rotate(270); + default: + return $this; + } + } } diff --git a/system/Images/Image.php b/system/Images/Image.php index f67e8b3..daec743 100644 --- a/system/Images/Image.php +++ b/system/Images/Image.php @@ -51,14 +51,14 @@ /** * The original image width in pixels. * - * @var + * @var integer|float */ public $origWidth; /** * The original image height in pixels. * - * @var + * @var integer|float */ public $origHeight; @@ -131,7 +131,7 @@ * * @param boolean $return * - * @return mixed + * @return array|boolean */ public function getProperties(bool $return = false) { @@ -143,9 +143,10 @@ } $types = [ - 1 => 'gif', - 2 => 'jpeg', - 3 => 'png', + IMAGETYPE_GIF => 'gif', + IMAGETYPE_JPEG => 'jpeg', + IMAGETYPE_PNG => 'png', + IMAGETYPE_WEBP => 'webp', ]; $mime = 'image/' . ($types[$vals[2]] ?? 'jpg'); diff --git a/system/Images/ImageHandlerInterface.php b/system/Images/ImageHandlerInterface.php index 7727b5f..8afbff7 100644 --- a/system/Images/ImageHandlerInterface.php +++ b/system/Images/ImageHandlerInterface.php @@ -52,6 +52,8 @@ * @param integer $height * @param boolean $maintainRatio If true, will get the closest match possible while keeping aspect ratio true. * @param string $masterDim + * + * @return $this */ public function resize(int $width, int $height, bool $maintainRatio = false, string $masterDim = 'auto'); @@ -69,7 +71,7 @@ * @param boolean $maintainRatio * @param string $masterDim * - * @return mixed + * @return $this */ public function crop(int $width = null, int $height = null, int $x = null, int $y = null, bool $maintainRatio = false, string $masterDim = 'auto'); @@ -92,7 +94,7 @@ * * @param float $angle * - * @return mixed + * @return $this */ public function rotate(float $angle); @@ -105,9 +107,10 @@ * @param integer $green * @param integer $blue * - * @return mixed + * @return $this */ public function flatten(int $red = 255, int $green = 255, int $blue = 255); + //-------------------------------------------------------------------- /** @@ -137,7 +140,7 @@ * * @param string $dir Direction to flip, either 'vertical' or 'horizontal' * - * @return mixed + * @return $this */ public function flip(string $dir = 'vertical'); @@ -161,7 +164,7 @@ * @param integer $height * @param string $position * - * @return boolean + * @return $this */ public function fit(int $width, int $height, string $position); @@ -201,7 +204,7 @@ * @param string $target * @param integer $quality * - * @return mixed + * @return boolean */ public function save(string $target = null, int $quality = 90); } diff --git a/system/Language/Language.php b/system/Language/Language.php index 9235610..3ee0c50 100644 --- a/system/Language/Language.php +++ b/system/Language/Language.php @@ -149,19 +149,7 @@ $parsedLine, ] = $this->parseLine($line, $this->locale); - foreach (explode('.', $parsedLine) as $row) - { - if (! isset($current)) - { - $current = $this->language[$this->locale][$file] ?? null; - } - - $output = $current[$row] ?? null; - if (is_array($output)) - { - $current = $output; - } - } + $output = $this->getTranslationOutput($this->locale, $file, $parsedLine); if ($output === null && strpos($this->locale, '-')) { @@ -172,14 +160,17 @@ $parsedLine, ] = $this->parseLine($line, $locale); - $output = $this->language[$locale][$file][$parsedLine] ?? null; + $output = $this->getTranslationOutput($locale, $file, $parsedLine); } // if still not found, try English - if (empty($output)) + if ($output === null) { - $this->parseLine($line, 'en'); - $output = $this->language['en'][$file][$parsedLine] ?? null; + [ + $file, + $parsedLine, + ] = $this->parseLine($line, 'en'); + $output = $this->getTranslationOutput('en', $file, $parsedLine); } $output = $output ?? $line; @@ -195,6 +186,42 @@ //-------------------------------------------------------------------- /** + * @return array|string|null + */ + private function getTranslationOutput(string $locale, string $file, string $parsedLine) + { + $output = $this->language[$locale][$file][$parsedLine] ?? null; + if ($output !== null) + { + return $output; + } + + foreach (explode('.', $parsedLine) as $row) + { + if (! isset($current)) + { + $current = $this->language[$locale][$file] ?? null; + } + + $output = $current[$row] ?? null; + if (is_array($output)) + { + $current = $output; + } + } + + if ($output !== null) + { + return $output; + } + + $row = current(explode('.', $parsedLine)); + $key = substr($parsedLine, strlen($row) + 1); + + return $this->language[$locale][$file][$row][$key] ?? null; + } + + /** * Parses the language string which should include the * filename as the first segment (separated by period). * @@ -311,7 +338,7 @@ */ protected function requireFile(string $path): array { - $files = Services::locator()->search($path); + $files = Services::locator()->search($path, 'php', false); $strings = []; foreach ($files as $file) diff --git a/system/Language/en/Core.php b/system/Language/en/Core.php index d5a49f9..3ab4e0f 100644 --- a/system/Language/en/Core.php +++ b/system/Language/en/Core.php @@ -15,8 +15,9 @@ */ return [ - 'invalidFile' => 'Invalid file: {0}', - 'copyError' => 'An error was encountered while attempting to replace the file({0}). Please make sure your file directory is writable.', - 'missingExtension' => '{0} extension is not loaded.', - 'noHandlers' => '{0} must provide at least one Handler.', + '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}', + 'missingExtension' => '{0} extension is not loaded.', + 'noHandlers' => '{0} must provide at least one Handler.', ]; diff --git a/system/Language/en/Email.php b/system/Language/en/Email.php index 6f9fcc4..db10a25 100644 --- a/system/Language/en/Email.php +++ b/system/Language/en/Email.php @@ -23,7 +23,7 @@ 'sendFailurePHPMail' => 'Unable to send email using PHP mail(). Your server might not be configured to send mail using this method.', 'sendFailureSendmail' => 'Unable to send email using PHP Sendmail. Your server might not be configured to send mail using this method.', 'sendFailureSmtp' => 'Unable to send email using PHP SMTP. Your server might not be configured to send mail using this method.', - 'sent' => 'Your message has been successfully sent using the following protocol: {0, string}', + 'sent' => 'Your message has been successfully sent using the following protocol: {0}', 'noSocket' => 'Unable to open a socket to Sendmail. Please check settings.', 'noHostname' => 'You did not specify a SMTP hostname.', 'SMTPError' => 'The following SMTP error was encountered: {0}', diff --git a/system/Language/en/Entity.php b/system/Language/en/Entity.php index 1e9fc5e..97bc74f 100644 --- a/system/Language/en/Entity.php +++ b/system/Language/en/Entity.php @@ -13,7 +13,6 @@ * @codeCoverageIgnore */ -return -[ +return [ 'tryingToAccessNonExistentProperty' => 'Trying to access non existent property {0} of {1}', ]; diff --git a/system/Language/en/Fabricator.php b/system/Language/en/Fabricator.php new file mode 100644 index 0000000..a3efbde --- /dev/null +++ b/system/Language/en/Fabricator.php @@ -0,0 +1,20 @@ + 'Invalid model supplied for fabrication.', + 'missingFormatters' => 'No valid formatters defined.', +]; diff --git a/system/Language/en/HTTP.php b/system/Language/en/HTTP.php index 2407c0b..1bc53b1 100644 --- a/system/Language/en/HTTP.php +++ b/system/Language/en/HTTP.php @@ -31,23 +31,23 @@ 'emptySupportedNegotiations' => 'You must provide an array of supported values to all Negotiations.', // RedirectResponse - 'invalidRoute' => '{0, string} route cannot be found while reverse-routing.', + 'invalidRoute' => '{0} route cannot be found while reverse-routing.', // DownloadResponse - 'cannotSetBinary' => 'When setting filepath can not set binary.', - 'cannotSetFilepath' => 'When setting binary can not set filepath: {0}', + 'cannotSetBinary' => 'When setting filepath cannot set binary.', + 'cannotSetFilepath' => 'When setting binary cannot set filepath: {0}', 'notFoundDownloadSource' => 'Not found download body source.', - 'cannotSetCache' => 'It does not supported caching for downloading.', - 'cannotSetStatusCode' => 'It does not supported change status code for downloading. code: {0}, reason: {1}', + 'cannotSetCache' => 'It does not support caching for downloading.', + 'cannotSetStatusCode' => 'It does not support change status code for downloading. code: {0}, reason: {1}', // Response 'missingResponseStatus' => 'HTTP Response is missing a status code', - 'invalidStatusCode' => '{0, string} is not a valid HTTP return status code', + 'invalidStatusCode' => '{0} is not a valid HTTP return status code', 'unknownStatusCode' => 'Unknown HTTP status code provided with no message: {0}', // URI 'cannotParseURI' => 'Unable to parse URI: {0}', - 'segmentOutOfRange' => 'Request URI segment is our of range: {0}', + 'segmentOutOfRange' => 'Request URI segment is out of range: {0}', 'invalidPort' => 'Ports must be between 0 and 65535. Given: {0}', 'malformedQueryString' => 'Query strings may not include URI fragments.', diff --git a/system/Language/en/Images.php b/system/Language/en/Images.php index 3dd8c0e..85327e6 100644 --- a/system/Language/en/Images.php +++ b/system/Language/en/Images.php @@ -21,11 +21,12 @@ 'gifNotSupported' => 'GIF images are often not supported due to licensing restrictions. You may have to use JPG or PNG images instead.', 'jpgNotSupported' => 'JPG images are not supported.', 'pngNotSupported' => 'PNG images are not supported.', + 'webpNotSupported' => 'WEBP images are not supported.', 'fileNotSupported' => 'The supplied file is not a supported image type.', 'unsupportedImageCreate' => 'Your server does not support the GD function required to process this type of image.', 'jpgOrPngRequired' => 'The image resize protocol specified in your preferences only works with JPEG or PNG image types.', 'rotateUnsupported' => 'Image rotation does not appear to be supported by your server.', - 'libPathInvalid' => 'The path to your image library is not correct. Please set the correct path in your image preferences. {0, string)', + 'libPathInvalid' => 'The path to your image library is not correct. Please set the correct path in your image preferences. {0}', 'imageProcessFailed' => 'Image processing failed. Please verify that your server supports the chosen protocol and that the path to your image library is correct.', 'rotationAngleRequired' => 'An angle of rotation is required to rotate the image.', 'invalidPath' => 'The path to the image is not correct.', diff --git a/system/Language/en/Seed.php b/system/Language/en/Seed.php new file mode 100644 index 0000000..98429b4 --- /dev/null +++ b/system/Language/en/Seed.php @@ -0,0 +1,21 @@ + 'Name the seeder file', + 'writeError' => 'Error trying to create {0} file, check if the directory is writable.', +]; diff --git a/system/Log/Handlers/ChromeLoggerHandler.php b/system/Log/Handlers/ChromeLoggerHandler.php index 9a293e1..168eae4 100644 --- a/system/Log/Handlers/ChromeLoggerHandler.php +++ b/system/Log/Handlers/ChromeLoggerHandler.php @@ -121,7 +121,6 @@ $request = Services::request(null, true); $this->json['request_uri'] = (string) $request->uri; - } //-------------------------------------------------------------------- diff --git a/system/Model.php b/system/Model.php index 4814217..8245fba 100644 --- a/system/Model.php +++ b/system/Model.php @@ -42,6 +42,7 @@ use Closure; use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\BaseResult; use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\Exceptions\DataException; @@ -266,45 +267,60 @@ */ /** + * Whether to trigger the defined callbacks + * + * @var boolean + */ + protected $allowCallbacks = true; + + /** + * Used by allowCallbacks() to override the + * model's allowCallbacks setting. + * + * @var boolean + */ + protected $tempAllowCallbacks; + + /** * Callbacks for beforeInsert * - * @var type + * @var array */ protected $beforeInsert = []; /** * Callbacks for afterInsert * - * @var type + * @var array */ protected $afterInsert = []; /** * Callbacks for beforeUpdate * - * @var type + * @var array */ protected $beforeUpdate = []; /** * Callbacks for afterUpdate * - * @var type + * @var array */ protected $afterUpdate = []; /** * Callbacks for afterFind * - * @var type + * @var array */ protected $afterFind = []; /** * Callbacks for beforeDelete * - * @var type + * @var array */ protected $beforeDelete = []; /** * Callbacks for afterDelete * - * @var type + * @var array */ protected $afterDelete = []; @@ -338,6 +354,7 @@ $this->tempReturnType = $this->returnType; $this->tempUseSoftDeletes = $this->useSoftDeletes; + $this->tempAllowCallbacks = $this->allowCallbacks; if (is_null($validation)) { @@ -555,8 +572,12 @@ else { $response = $this->insert($data, false); - // call insert directly if you want the ID or the record object - if ($response !== false) + + if ($response instanceof BaseResult) + { + $response = $response->resultID !== false; + } + elseif ($response !== false) { $response = true; } @@ -584,7 +605,7 @@ $properties = $data->toRawArray($onlyChanged); // Always grab the primary key otherwise updates will fail. - if (! empty($properties) && ! empty($primaryKey) && ! in_array($primaryKey, $properties)) + if (! empty($properties) && ! empty($primaryKey) && ! in_array($primaryKey, $properties) && ! empty($data->{$primaryKey})) { $properties[$primaryKey] = $data->{$primaryKey}; } @@ -658,7 +679,7 @@ * @param array|object $data * @param boolean $returnID Whether insert ID should be returned or not. * - * @return integer|string|boolean + * @return BaseResult|integer|string|false * @throws \ReflectionException */ public function insert($data = null, bool $returnID = true) @@ -695,6 +716,11 @@ $data = (array) $data; } + if (empty($data)) + { + throw DataException::forEmptyDataset('insert'); + } + // Validate data before saving. if ($this->skipValidation === false) { @@ -729,7 +755,7 @@ ->insert(); // If insertion succeeded then save the insert ID - if ($result) + if ($result->resultID) { $this->insertID = $this->db->insertID(); } @@ -754,9 +780,8 @@ * * @param array $set An associative array of insert values * @param boolean $escape Whether to escape values and identifiers - * - * @param integer $batchSize - * @param boolean $testing + * @param integer $batchSize The size of the batch to run + * @param boolean $testing True means only number of records is returned, false will execute the query * * @return integer|boolean Number of rows inserted or FALSE on failure */ @@ -773,7 +798,7 @@ } } - return $this->builder()->insertBatch($set, $escape, $batchSize, $testing); + return $this->builder()->testMode($testing)->insertBatch($set, $escape, $batchSize); } //-------------------------------------------------------------------- @@ -896,7 +921,7 @@ } } - return $this->builder()->updateBatch($set, $index, $batchSize, $returnSQL); + return $this->builder()->testMode($returnSQL)->updateBatch($set, $index, $batchSize); } //-------------------------------------------------------------------- @@ -908,7 +933,7 @@ * @param integer|string|array|null $id The rows primary key(s) * @param boolean $purge Allows overriding the soft deletes setting. * - * @return mixed + * @return BaseResult|boolean * @throws \CodeIgniter\Database\Exceptions\DatabaseException */ public function delete($id = null, bool $purge = false) @@ -1137,10 +1162,16 @@ * * @return array|null */ - public function paginate(int $perPage = null, string $group = 'default', int $page = 0, int $segment = 0) + public function paginate(int $perPage = null, string $group = 'default', int $page = null, int $segment = 0) { $pager = \Config\Services::pager(null, null, false); - $page = $page >= 1 ? $page : $pager->getCurrentPage($group); + + if ($segment) + { + $pager->setSegment($segment); + } + + $page = $page >= 1 ? $page : $pager->getCurrentPage($group); $total = $this->countAllResults(false); @@ -1324,7 +1355,7 @@ } // Still here? Grab the database-specific error, if any. - $error = $this->db->getError(); + $error = $this->db->error(); return $error['message'] ?? null; } @@ -1381,6 +1412,37 @@ //-------------------------------------------------------------------- /** + * Allows to set validation rules. + * It could be used when you have to change default or override current validate rules. + * + * @param array $validationRules + * + * @return void + */ + public function setValidationRules(array $validationRules) + { + $this->validationRules = $validationRules; + } + + //-------------------------------------------------------------------- + + /** + * Allows to set field wise validation rules. + * It could be used when you have to change default or override current validate rules. + * + * @param string $field + * @param string|array $fieldRules + * + * @return void + */ + public function setValidationRule(string $field, $fieldRules) + { + $this->validationRules[$field] = $fieldRules; + } + + //-------------------------------------------------------------------- + + /** * Should validation rules be removed before saving? * Most handy when doing updates. * @@ -1407,7 +1469,9 @@ */ public function validate($data): bool { - if ($this->skipValidation === true || empty($this->validationRules) || empty($data)) + $rules = $this->getValidationRules(); + + if ($this->skipValidation === true || empty($rules) || empty($data)) { return true; } @@ -1419,8 +1483,6 @@ $data = (array) $data; } - $rules = $this->validationRules; - // ValidationRules can be either a string, which is the group name, // or an array of rules. if (is_string($rules)) @@ -1550,6 +1612,13 @@ { $rules = $this->validationRules; + // ValidationRules can be either a string, which is the group name, + // or an array of rules. + if (is_string($rules)) + { + $rules = $this->validation->loadRuleGroup($rules); + } + if (isset($options['except'])) { $rules = array_diff_key($rules, array_flip($options['except'])); @@ -1591,12 +1660,35 @@ { $this->builder()->where($this->table . '.' . $this->deletedField, null); } - $this->tempUseSoftDeletes = $this->useSoftDeletes; + + // When $reset === false, the $tempUseSoftDeletes will be + // dependant on $useSoftDeletes value because we don't + // want to add the same "where" condition for the second time + $this->tempUseSoftDeletes = ($reset === true) + ? $this->useSoftDeletes + : ($this->useSoftDeletes === true + ? false + : $this->useSoftDeletes); return $this->builder()->testMode($test)->countAllResults($reset); } /** + * Sets $tempAllowCallbacks value so that we can temporarily override + * the setting. Resets after the next trigger. + * + * @param boolean $val + * + * @return Model + */ + public function allowCallbacks(bool $val = true) + { + $this->tempAllowCallbacks = $val; + + return $this; + } + + /** * A simple event trigger for Model Events that allows additional * data manipulation within the model. Specifically intended for * usage by child models this can be used to format data, @@ -1609,6 +1701,8 @@ * data for callback methods (like an array of key/value pairs to insert * or update, an array of results, etc) * + * If callbacks are not allowed then returns $eventData immediately. + * * @param string $event * @param array $eventData * @@ -1617,6 +1711,14 @@ */ protected function trigger(string $event, array $eventData) { + $allowed = $this->tempAllowCallbacks; + $this->tempAllowCallbacks = $this->allowCallbacks; + + if (! $allowed) + { + return $eventData; + } + // Ensure it's a valid event if (! isset($this->{$event}) || empty($this->{$event})) { diff --git a/system/Modules/Modules.php b/system/Modules/Modules.php new file mode 100644 index 0000000..73d6835 --- /dev/null +++ b/system/Modules/Modules.php @@ -0,0 +1,88 @@ +enabled) + { + return false; + } + + return in_array(strtolower($alias), $this->aliases); + } +} diff --git a/system/Pager/Pager.php b/system/Pager/Pager.php index 12a3a3e..9d81703 100644 --- a/system/Pager/Pager.php +++ b/system/Pager/Pager.php @@ -202,14 +202,21 @@ */ public function store(string $group, int $page, int $perPage = null, int $total, int $segment = 0) { - $this->segment[$group] = $segment; + if ($segment) + { + $this->setSegment($segment, $group); + } $this->ensureGroup($group, $perPage); + if ($segment > 0 && $this->groups[$group]['currentPage'] > 0) + { + $page = $this->groups[$group]['currentPage']; + } + $perPage = $perPage ?? $this->config->perPage; $pageCount = (int)ceil($total / $perPage); - $page = $page > $pageCount ? $pageCount : $page; - $this->groups[$group]['currentPage'] = $page; + $this->groups[$group]['currentPage'] = $page > $pageCount ? $pageCount : $page; $this->groups[$group]['perPage'] = $perPage; $this->groups[$group]['total'] = $total; $this->groups[$group]['pageCount'] = $pageCount; @@ -220,6 +227,23 @@ //-------------------------------------------------------------------- /** + * Sets segment for a group. + * + * @param integer $number + * @param string $group + * + * @return mixed + */ + public function setSegment(int $number, string $group = 'default') + { + $this->segment[$group] = $number; + + return $this; + } + + //-------------------------------------------------------------------- + + /** * Sets the path that an aliased group of links will use. * * @param string $path @@ -265,7 +289,7 @@ { $this->ensureGroup($group); - return $this->groups[$group]['currentPage']; + return $this->groups[$group]['currentPage'] ?: 1; } //-------------------------------------------------------------------- @@ -535,7 +559,7 @@ { try { - $this->groups[$group]['currentPage'] = $this->groups[$group]['uri']->getSegment($this->segment[$group]); + $this->groups[$group]['currentPage'] = (int) $this->groups[$group]['uri']->setSilent(false)->getSegment($this->segment[$group]); } catch (\CodeIgniter\HTTP\Exceptions\HTTPException $e) { diff --git a/system/Pager/PagerRenderer.php b/system/Pager/PagerRenderer.php index ed95c1b..0944356 100644 --- a/system/Pager/PagerRenderer.php +++ b/system/Pager/PagerRenderer.php @@ -365,15 +365,19 @@ */ public function getPreviousPage() { - if (!$this->hasPreviousPage()) { + if (! $this->hasPreviousPage()) + { return null; } $uri = clone $this->uri; - if ($this->segment === 0) { + if ($this->segment === 0) + { $uri->addQuery($this->pageSelector, $this->current - 1); - } else { + } + else + { $uri->setSegment($this->segment, $this->current - 1); } @@ -403,15 +407,19 @@ */ public function getNextPage() { - if (!$this->hasNextPage()) { + if (! $this->hasNextPage()) + { return null; } $uri = clone $this->uri; - if ($this->segment === 0) { + if ($this->segment === 0) + { $uri->addQuery($this->pageSelector, $this->current + 1); - } else { + } + else + { $uri->setSegment($this->segment, $this->current + 1); } diff --git a/system/RESTful/ResourceController.php b/system/RESTful/ResourceController.php index a3b7586..d13218c 100644 --- a/system/RESTful/ResourceController.php +++ b/system/RESTful/ResourceController.php @@ -176,7 +176,7 @@ { if (class_exists($this->modelName)) { - $this->model = new $this->modelName; + $this->model = model($this->modelName); } } diff --git a/system/RESTful/ResourcePresenter.php b/system/RESTful/ResourcePresenter.php index d594c14..91fdeb0 100644 --- a/system/RESTful/ResourcePresenter.php +++ b/system/RESTful/ResourcePresenter.php @@ -192,7 +192,7 @@ { if (class_exists($this->modelName)) { - $this->model = new $this->modelName; + $this->model = model($this->modelName); } } diff --git a/system/Router/Exceptions/RedirectException.php b/system/Router/Exceptions/RedirectException.php index 5c3f695..6114207 100644 --- a/system/Router/Exceptions/RedirectException.php +++ b/system/Router/Exceptions/RedirectException.php @@ -1,12 +1,53 @@ routes['*'][$to]['route']; } - else if (array_key_exists($to, $this->routes['get'])) + elseif (array_key_exists($to, $this->routes['get'])) { $to = $this->routes['get'][$to]['route']; } @@ -724,8 +725,8 @@ * $route->resource('users'); * }); * - * @param string $name The name to group/prefix the routes with. - * @param $params + * @param string $name The name to group/prefix the routes with. + * @param array ...$params * * @return void */ @@ -795,8 +796,8 @@ * POST /photos/{id}/delete delete * POST /photos/{id} update * - * @param string $name The name of the resource/controller to route to. - * @param array $options An list of possible ways to customize the routing. + * @param string $name The name of the resource/controller to route to. + * @param array|null $options An list of possible ways to customize the routing. * * @return RouteCollectionInterface */ @@ -826,15 +827,14 @@ // Make sure we capture back-references $id = '(' . trim($id, '()') . ')'; - $methods = isset($options['only']) ? is_string($options['only']) ? explode(',', $options['only']) : $options['only'] : ['index', 'show', 'create', 'update', 'delete', 'new', 'edit']; + $methods = isset($options['only']) ? (is_string($options['only']) ? explode(',', $options['only']) : $options['only']) : ['index', 'show', 'create', 'update', 'delete', 'new', 'edit']; if (isset($options['except'])) { $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']); - $c = count($methods); - for ($i = 0; $i < $c; $i ++) + foreach ($methods as $i => $method) { - if (in_array($methods[$i], $options['except'])) + if (in_array($method, $options['except'])) { unset($methods[$i]); } @@ -910,8 +910,8 @@ * GET /photos/remove/{id} remove show a form to confirm deletion of a specific photo object * POST /photos/delete/{id} delete deleting the specified photo object * - * @param string $name The name of the controller to route to. - * @param array $options An list of possible ways to customize the routing. + * @param string $name The name of the controller to route to. + * @param array|null $options An list of possible ways to customize the routing. * * @return RouteCollectionInterface */ @@ -941,15 +941,14 @@ // Make sure we capture back-references $id = '(' . trim($id, '()') . ')'; - $methods = isset($options['only']) ? is_string($options['only']) ? explode(',', $options['only']) : $options['only'] : ['index', 'show', 'new', 'create', 'edit', 'update', 'remove', 'delete']; + $methods = isset($options['only']) ? (is_string($options['only']) ? explode(',', $options['only']) : $options['only']) : ['index', 'show', 'new', 'create', 'edit', 'update', 'remove', 'delete']; if (isset($options['except'])) { $options['except'] = is_array($options['except']) ? $options['except'] : explode(',', $options['except']); - $c = count($methods); - for ($i = 0; $i < $c; $i ++) + foreach ($methods as $i => $method) { - if (in_array($methods[$i], $options['except'])) + if (in_array($method, $options['except'])) { unset($methods[$i]); } @@ -1011,7 +1010,7 @@ * @param array $verbs * @param string $from * @param string|array $to - * @param array $options + * @param array|null $options * * @return \CodeIgniter\Router\RouteCollectionInterface */ @@ -1034,7 +1033,7 @@ * * @param string $from * @param string|array $to - * @param array $options + * @param array|null $options * * @return \CodeIgniter\Router\RouteCollectionInterface */ @@ -1052,7 +1051,7 @@ * * @param string $from * @param string|array $to - * @param array $options + * @param array|null $options * * @return \CodeIgniter\Router\RouteCollectionInterface */ @@ -1070,7 +1069,7 @@ * * @param string $from * @param string|array $to - * @param array $options + * @param array|null $options * * @return \CodeIgniter\Router\RouteCollectionInterface */ @@ -1088,7 +1087,7 @@ * * @param string $from * @param string|array $to - * @param array $options + * @param array|null $options * * @return \CodeIgniter\Router\RouteCollectionInterface */ @@ -1106,7 +1105,7 @@ * * @param string $from * @param string|array $to - * @param array $options + * @param array|null $options * * @return \CodeIgniter\Router\RouteCollectionInterface */ @@ -1124,7 +1123,7 @@ * * @param string $from * @param string|array $to - * @param array $options + * @param array|null $options * * @return \CodeIgniter\Router\RouteCollectionInterface */ @@ -1142,7 +1141,7 @@ * * @param string $from * @param string|array $to - * @param array $options + * @param array|null $options * * @return \CodeIgniter\Router\RouteCollectionInterface */ @@ -1160,7 +1159,7 @@ * * @param string $from * @param string|array $to - * @param array $options + * @param array|null $options * * @return \CodeIgniter\Router\RouteCollectionInterface */ @@ -1179,7 +1178,7 @@ * @param string $env * @param \Closure $callback * - * @return RouteCollectionInterface + * @return \CodeIgniter\Router\RouteCollectionInterface */ public function environment(string $env, \Closure $callback): RouteCollectionInterface { @@ -1194,7 +1193,7 @@ //-------------------------------------------------------------------- /** - * Attempts to look up a route based on it's destination. + * Attempts to look up a route based on its destination. * * If a route exists: * @@ -1277,7 +1276,7 @@ */ protected function localizeRoute(string $route) :string { - return strtr($route, ['{locale}' => Services::language()->getLocale()]); + return strtr($route, ['{locale}' => Services::request()->getLocale()]); } //-------------------------------------------------------------------- @@ -1329,6 +1328,7 @@ * @param array|null $params * * @return string + * @throws \CodeIgniter\Router\Exceptions\RouterException */ protected function fillRouteParams(string $from, array $params = null): string { @@ -1348,7 +1348,7 @@ // the expected param type. $pos = strpos($from, $pattern); - if (preg_match("|{$pattern}|", $params[$index])) + if (preg_match('#^' . $pattern . '$#u', $params[$index])) { $from = substr_replace($from, $params[$index], $pos, strlen($pattern)); } @@ -1371,7 +1371,7 @@ * @param string $verb * @param string $from * @param string|array $to - * @param array $options + * @param array|null $options */ protected function create(string $verb, string $from, $to, array $options = null) { @@ -1439,18 +1439,22 @@ $from = str_ireplace(':' . $tag, $pattern, $from); } - // If no namespace found, add the default namespace - if (is_string($to) && (strpos($to, '\\') === false || strpos($to, '\\') > 0)) + //If is redirect, No processing + if (! isset($options['redirect'])) { - $namespace = $options['namespace'] ?? $this->defaultNamespace; - $to = trim($namespace, '\\') . '\\' . $to; - } + // If no namespace found, add the default namespace + if (is_string($to) && (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. - if (is_string($to)) - { - $to = '\\' . ltrim($to, '\\'); + // Always ensure that we escape our namespace so we're not pointing to + // \CodeIgniter\Routes\Controller::method. + if (is_string($to)) + { + $to = '\\' . ltrim($to, '\\'); + } } $name = $options['as'] ?? $from; @@ -1513,15 +1517,7 @@ return true; } - foreach ($subdomains as $subdomain) - { - if ($subdomain === $this->currentSubdomain) - { - return true; - } - } - - return false; + return in_array($this->currentSubdomain, $subdomains, true); } //-------------------------------------------------------------------- @@ -1532,6 +1528,8 @@ * * It's especially not perfect since it's possible to register a domain * with a period (.) as part of the domain name. + * + * @return mixed */ private function determineCurrentSubdomain() { diff --git a/system/Router/Router.php b/system/Router/Router.php index 3c6e6e0..3ef9150 100644 --- a/system/Router/Router.php +++ b/system/Router/Router.php @@ -171,6 +171,9 @@ : $this->controller; } + // Decode URL-encoded string + $uri = urldecode($uri); + if ($this->checkRoutes($uri)) { if ($this->collection->isFiltered($this->matchedRoute[0])) @@ -411,6 +414,8 @@ ? $key : ltrim($key, '/ '); + $matchedKey = $key; + // Are we dealing with a locale? if (strpos($key, '{locale}') !== false) { @@ -418,16 +423,17 @@ // Replace it with a regex so it // will actually match. - $key = str_replace('{locale}', '[^/]+', $key); + $key = str_replace('/', '\/', $key); + $key = str_replace('{locale}', '[^\/]+', $key); } // Does the RegEx match? - if (preg_match('#^' . $key . '$#', $uri, $matches)) + if (preg_match('#^' . $key . '$#u', $uri, $matches)) { // Is this route supposed to redirect to another? if ($this->collection->isRedirect($key)) { - throw new RedirectException(key($val), $this->collection->getRedirectCode($key)); + throw new RedirectException(is_array($val) ? key($val) : $val, $this->collection->getRedirectCode($key)); } // Store our locale so CodeIgniter object can // assign it to the Request. @@ -452,11 +458,11 @@ $this->params = $matches; $this->matchedRoute = [ - $key, + $matchedKey, $val, ]; - $this->matchedRouteOptions = $this->collection->getRoutesOptions($key); + $this->matchedRouteOptions = $this->collection->getRoutesOptions($matchedKey); return true; } @@ -467,12 +473,12 @@ if (strpos($val, '$') !== false && strpos($key, '(') !== false && strpos($key, '/') !== false) { $replacekey = str_replace('/(.*)', '', $key); - $val = preg_replace('#^' . $key . '$#', $val, $uri); + $val = preg_replace('#^' . $key . '$#u', $val, $uri); $val = str_replace($replacekey, str_replace('/', '\\', $replacekey), $val); } elseif (strpos($val, '$') !== false && strpos($key, '(') !== false) { - $val = preg_replace('#^' . $key . '$#', $val, $uri); + $val = preg_replace('#^' . $key . '$#u', $val, $uri); } elseif (strpos($val, '/') !== false) { @@ -490,11 +496,11 @@ $this->setRequest(explode('/', $val)); $this->matchedRoute = [ - $key, + $matchedKey, $val, ]; - $this->matchedRouteOptions = $this->collection->getRoutesOptions($key); + $this->matchedRouteOptions = $this->collection->getRoutesOptions($matchedKey); return true; } @@ -534,7 +540,7 @@ // has already been set. if (! empty($segments)) { - $this->method = array_shift($segments); + $this->method = array_shift($segments) ?: $this->method; } if (! empty($segments)) @@ -542,11 +548,13 @@ $this->params = $segments; } + $defaultNamespace = $this->collection->getDefaultNamespace(); + $controllerName = $this->controllerName(); if ($this->collection->getHTTPVerb() !== 'cli') { - $controller = '\\' . $this->collection->getDefaultNamespace(); + $controller = '\\' . $defaultNamespace; $controller .= $this->directory ? str_replace('/', '\\', $this->directory) : ''; - $controller .= $this->controllerName(); + $controller .= $controllerName; $controller = strtolower($controller); $methodName = strtolower($this->methodName()); @@ -569,7 +577,7 @@ } // Load the file so that it's available for CodeIgniter. - $file = APPPATH . 'Controllers/' . $this->directory . $this->controllerName() . '.php'; + $file = APPPATH . 'Controllers/' . $this->directory . $controllerName . '.php'; if (is_file($file)) { include_once $file; @@ -577,9 +585,9 @@ // Ensure the controller stores the fully-qualified class name // We have to check for a length over 1, since by default it will be '\' - if (strpos($this->controller, '\\') === false && strlen($this->collection->getDefaultNamespace()) > 1) + if (strpos($this->controller, '\\') === false && strlen($defaultNamespace) > 1) { - $this->controller = '\\' . ltrim(str_replace('/', '\\', $this->collection->getDefaultNamespace() . $this->directory . $this->controllerName()), '\\'); + $this->controller = '\\' . ltrim(str_replace('/', '\\', $defaultNamespace . $this->directory . $controllerName), '\\'); } } diff --git a/system/Session/Handlers/RedisHandler.php b/system/Session/Handlers/RedisHandler.php index 513e0d2..a6fcb33 100644 --- a/system/Session/Handlers/RedisHandler.php +++ b/system/Session/Handlers/RedisHandler.php @@ -125,7 +125,9 @@ $this->keyPrefix .= $this->ipAddress . ':'; } - $this->sessionExpiration = $config->sessionExpiration; + $this->sessionExpiration = empty($config->sessionExpiration) + ? (int) ini_get('session.gc_maxlifetime') + : (int) $config->sessionExpiration; } //-------------------------------------------------------------------- diff --git a/system/Session/Session.php b/system/Session/Session.php index 8e84546..d925bd7 100644 --- a/system/Session/Session.php +++ b/system/Session/Session.php @@ -557,7 +557,7 @@ public function push(string $key, array $data) { if ($this->has($key) && is_array($value = $this->get($key))) - { + { $this->set($key, array_merge($value, $data)); } } @@ -723,9 +723,9 @@ { if (is_array($key)) { - for ($i = 0, $c = count($key); $i < $c; $i ++) + foreach ($key as $sessionKey) { - if (! isset($_SESSION[$key[$i]])) + if (! isset($_SESSION[$sessionKey])) { return false; } diff --git a/system/Test/CIDatabaseTestCase.php b/system/Test/CIDatabaseTestCase.php index b1354ec..3d11ce3 100644 --- a/system/Test/CIDatabaseTestCase.php +++ b/system/Test/CIDatabaseTestCase.php @@ -167,35 +167,14 @@ if ($this->refresh === true) { - // If no namespace was specified then rollback/migrate all - if (empty($this->namespace)) - { - $this->migrations->setNamespace(null); + $this->regressDatabase(); - $this->migrations->regress(0, 'tests'); - - $this->migrations->latest('tests'); - } - - // 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->regress(0, 'tests'); - } - - foreach ($namespaces as $namespace) - { - $this->migrations->setNamespace($namespace); - $this->migrations->latest('tests'); - } - } + // Reset counts on faked items + Fabricator::resetCounts(); } + $this->migrateDatabase(); + if (! empty($this->seed)) { if (! empty($this->basePath)) @@ -235,6 +214,55 @@ //-------------------------------------------------------------------- /** + * Regress migrations as defined by the class + */ + protected function regressDatabase() + { + // 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 no namespace was specified then migrate all + if (empty($this->namespace)) + { + $this->migrations->setNamespace(null); + $this->migrations->latest('tests'); + } + // 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'); + } + } + } + + /** * Seeds that database with a specific seeder. * * @param string $name diff --git a/system/Test/CIUnitTestCase.php b/system/Test/CIUnitTestCase.php index 6e84295..d90b2e0 100644 --- a/system/Test/CIUnitTestCase.php +++ b/system/Test/CIUnitTestCase.php @@ -40,7 +40,10 @@ namespace CodeIgniter\Test; use CodeIgniter\Events\Events; -use Config\Paths; +use CodeIgniter\Session\Handlers\ArrayHandler; +use CodeIgniter\Test\Mock\MockEmail; +use CodeIgniter\Test\Mock\MockSession; +use Config\Services; use PHPUnit\Framework\TestCase; /** @@ -56,6 +59,37 @@ */ protected $app; + /** + * Methods to run during setUp. + * + * @var array of methods + */ + protected $setUpMethods = [ + 'mockEmail', + 'mockSession', + ]; + + /** + * Methods to run during tearDown. + * + * @var array of methods + */ + protected $tearDownMethods = []; + + //-------------------------------------------------------------------- + // Staging + //-------------------------------------------------------------------- + + /** + * Load the helpers. + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + helper(['url', 'test']); + } + protected function setUp(): void { parent::setUp(); @@ -65,9 +99,51 @@ $this->app = $this->createApplication(); } - helper('url'); + foreach ($this->setUpMethods as $method) + { + $this->$method(); + } } + protected function tearDown(): void + { + parent::tearDown(); + + foreach ($this->tearDownMethods as $method) + { + $this->$method(); + } + } + + //-------------------------------------------------------------------- + // Mocking + //-------------------------------------------------------------------- + + /** + * Injects the mock session driver into Services + */ + protected function mockSession() + { + $_SESSION = []; + + $config = config('App'); + $session = new MockSession(new ArrayHandler($config, '0.0.0.0'), $config); + + Services::injectMock('session', $session); + } + + /** + * Injects the mock email driver so no emails really send + */ + protected function mockEmail() + { + Services::injectMock('email', new MockEmail(config('Email'))); + } + + //-------------------------------------------------------------------- + // Assertions + //-------------------------------------------------------------------- + /** * Custom function to hook into CodeIgniter's Logging mechanism * to check if certain messages were logged during code execution. @@ -237,6 +313,10 @@ } } + //-------------------------------------------------------------------- + // Utility + //-------------------------------------------------------------------- + /** * Loads up an instance of CodeIgniter * and gets the environment setup. @@ -245,21 +325,17 @@ */ protected function createApplication() { - $paths = new Paths(); - return require realpath(__DIR__ . '/../') . '/bootstrap.php'; } - //-------------------------------------------------------------------- /** * Return first matching emitted header. * - * @param string $header Identifier of the header of interest - * @param bool $ignoreCase + * @param string $header Identifier of the header of interest + * @param boolean $ignoreCase * * @return string|null The value of the header found, null if not found */ - // protected function getHeaderEmitted(string $header, bool $ignoreCase = false): ?string { $found = false; @@ -282,5 +358,4 @@ return null; } - } diff --git a/system/Test/DOMParser.php b/system/Test/DOMParser.php index 343ef75..95b8896 100644 --- a/system/Test/DOMParser.php +++ b/system/Test/DOMParser.php @@ -142,8 +142,8 @@ // If Element is null, we're just scanning for text if (is_null($element)) { - $content = $this->dom->saveHTML(); - return strpos($content, $search) !== false; + $content = $this->dom->saveHTML($this->dom->documentElement); + return mb_strpos($content, $search) !== false; } $result = $this->doXPath($search, $element); diff --git a/system/Test/Fabricator.php b/system/Test/Fabricator.php new file mode 100644 index 0000000..3791a37 --- /dev/null +++ b/system/Test/Fabricator.php @@ -0,0 +1,648 @@ + formatter + * @param string|null $locale Locale for Faker provider + * + * @throws \InvalidArgumentException + */ + public function __construct($model, array $formatters = null, string $locale = null) + { + if (is_string($model)) + { + // Create a new model instance + $model = model($model, false); + } + + if (! is_object($model)) + { + throw new \InvalidArgumentException(lang('Fabricator.invalidModel')); + } + + $this->model = $model; + + // If no locale was specified then use the App default + if (is_null($locale)) + { + $locale = config('App')->defaultLocale; + } + + // There is no easy way to retrieve the locale from Faker so we will store it + $this->locale = $locale; + + // Create the locale-specific Generator + $this->faker = Factory::create($this->locale); + + // Determine eligible date fields + foreach (['createdField', 'updatedField', 'deletedField'] as $field) + { + if (! empty($this->model->$field)) + { + $this->dateFields[] = $this->model->$field; + } + } + + // Set the formatters + $this->setFormatters($formatters); + } + + //-------------------------------------------------------------------- + + /** + * Reset internal counts + */ + public static function resetCounts() + { + self::$tableCounts = []; + } + + /** + * Get the count for a specific table + * + * @param string $table Name of the target table + * + * @return integer + */ + public static function getCount(string $table): int + { + return empty(self::$tableCounts[$table]) ? 0 : self::$tableCounts[$table]; + } + + /** + * Set the count for a specific table + * + * @param string $table Name of the target table + * @param integer $count Count value + * + * @return integer The new count value + */ + public static function setCount(string $table, int $count): int + { + self::$tableCounts[$table] = $count; + return $count; + } + + /** + * Increment the count for a table + * + * @param string $table Name of the target table + * + * @return integer The new count value + */ + public static function upCount(string $table): int + { + return self::setCount($table, self::getCount($table) + 1); + } + + /** + * Decrement the count for a table + * + * @param string $table Name of the target table + * + * @return integer The new count value + */ + public static function downCount(string $table): int + { + return self::setCount($table, self::getCount($table) - 1); + } + + //-------------------------------------------------------------------- + + /** + * Returns the model instance + * + * @return object Framework or compatible model + */ + public function getModel() + { + return $this->model; + } + + /** + * Returns the locale + * + * @return string + */ + public function getLocale(): string + { + return $this->locale; + } + + /** + * Returns the Faker generator + * + * @return Faker\Generator + */ + public function getFaker(): Generator + { + return $this->faker; + } + + //-------------------------------------------------------------------- + + /** + * Return and reset tempOverrides + * + * @return array + */ + public function getOverrides(): array + { + $overrides = $this->tempOverrides ?? $this->overrides; + + $this->tempOverrides = $this->overrides; + + return $overrides; + } + + /** + * Set the overrides, once or persistent + * + * @param array $overrides Array of [field => value] + * @param boolean $persist Whether these overrides should persist through the next operation + * + * @return $this + */ + public function setOverrides(array $overrides = [], $persist = true): self + { + if ($persist) + { + $this->overrides = $overrides; + } + + $this->tempOverrides = $overrides; + + return $this; + } + + //-------------------------------------------------------------------- + + /** + * Returns the current formatters + * + * @return array|null + */ + public function getFormatters(): ?array + { + return $this->formatters; + } + + /** + * Set the formatters to use. Will attempt to autodetect if none are available. + * + * @param array|null $formatters Array of [field => formatter], or null to detect + * + * @return $this + */ + public function setFormatters(array $formatters = null): self + { + if (! is_null($formatters)) + { + $this->formatters = $formatters; + } + elseif (method_exists($this->model, 'fake')) + { + $this->formatters = null; + } + else + { + $formatters = $this->detectFormatters(); + } + + return $this; + } + + /** + * Try to identify the appropriate Faker formatter for each field. + * + * @return $this + */ + protected function detectFormatters(): self + { + $this->formatters = []; + + if (! empty($this->model->allowedFields)) + { + foreach ($this->model->allowedFields as $field) + { + $this->formatters[$field] = $this->guessFormatter($field); + } + } + + return $this; + } + + /** + * Guess at the correct formatter to match a field name. + * + * @param $field Name of the field + * + * @return string Name of the formatter + */ + protected function guessFormatter($field): string + { + // First check for a Faker formatter of the same name - covers things like "email" + try + { + $this->faker->getFormatter($field); + return $field; + } + catch (\InvalidArgumentException $e) + { + // No match, keep going + } + + // Next look for known model fields + if (in_array($field, $this->dateFields)) + { + switch ($this->model->dateFormat) + { + case 'datetime': + return 'date'; + break; + + case 'date': + return 'date'; + break; + + case 'int': + return 'unixTime'; + break; + } + } + elseif ($field === $this->model->primaryKey) + { + return 'numberBetween'; + } + + // Check some common partials + foreach (['email', 'name', 'title', 'text', 'date', 'url'] as $term) + { + if (stripos($field, $term) !== false) + { + return $term; + } + } + + if (stripos($field, 'phone') !== false) + { + return 'phoneNumber'; + } + + // Nothing left, use the default + return $this->defaultFormatter; + } + + //-------------------------------------------------------------------- + + /** + * Generate new entities with faked data + * + * @param integer|null $count Optional number to create a collection + * + * @return array|object An array or object (based on returnType), or an array of returnTypes + */ + public function make(int $count = null) + { + // If a singleton was requested then go straight to it + if (is_null($count)) + { + return $this->model->returnType === 'array' + ? $this->makeArray() + : $this->makeObject(); + } + + $return = []; + + for ($i = 0; $i < $count; $i++) + { + $return[] = $this->model->returnType === 'array' + ? $this->makeArray() + : $this->makeObject(); + } + + return $return; + } + + /** + * Generate an array of faked data + * + * @return array An array of faked data + * + * @throws \RuntimeException + */ + public function makeArray() + { + if (! is_null($this->formatters)) + { + $result = []; + + foreach ($this->formatters as $field => $formatter) + { + $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; + } + } + + // Nothing left to do but give up + else + { + throw new \RuntimeException(lang('Fabricator.missingFormatters')); + } + + // Replace overridden fields + return array_merge($result, $this->getOverrides()); + } + + /** + * Generate an object of faked data + * + * @param string|null $className Class name of the object to create; null to use model default + * + * @return object An instance of the class with faked data + * + * @throws \RuntimeException + */ + public function makeObject(string $className = null): object + { + if (is_null($className)) + { + if ($this->model->returnType === 'object' || $this->model->returnType === 'array') + { + $className = 'stdClass'; + } + else + { + $className = $this->model->returnType; + } + } + + // If using the model's fake() method then check it for the correct return type + if (is_null($this->formatters) && method_exists($this->model, 'fake')) + { + $result = $this->model->fake($this->faker); + + if ($result instanceof $className) + { + // Set overrides manually + foreach ($this->getOverrides() as $key => $value) + { + $result->{$key} = $value; + } + + return $result; + } + } + + // Get the array values and apply them to the object + $array = $this->makeArray(); + $object = new $className(); + + // Check for the entity method + if (method_exists($object, 'fill')) + { + $object->fill($array); + } + else + { + foreach ($array as $key => $value) + { + $object->{$key} = $value; + } + } + + return $object; + } + + //-------------------------------------------------------------------- + + /** + * Generate new entities from the database + * + * @param integer|null $count Optional number to create a collection + * @param array $override Array of data to add/override + * @param boolean $mock Whether to execute or mock the insertion + * + * @return array|object An array or object (based on returnType), or an array of returnTypes + */ + public function create(int $count = null, bool $mock = false) + { + // Intercept mock requests + if ($mock) + { + return $this->createMock($count); + } + + $ids = []; + + // Iterate over new entities and insert each one, storing insert IDs + foreach ($this->make($count ?? 1) as $result) + { + if ($id = $this->model->insert($result, true)) + { + $ids[] = $id; + self::upCount($this->model->table); + } + } + + // If the model defines a "withDeleted" method for handling soft deletes then use it + if (method_exists($this->model, 'withDeleted')) + { + $this->model->withDeleted(); + } + + return $this->model->find(is_null($count) ? reset($ids) : $ids); + } + + /** + * Generate new database entities without actually inserting them + * + * @param integer|null $count Optional number to create a collection + * + * @return array|object An array or object (based on returnType), or an array of returnTypes + */ + protected function createMock(int $count = null) + { + switch ($this->model->dateFormat) + { + case 'datetime': + $datetime = date('Y-m-d H:i:s'); + case 'date': + $datetime = date('Y-m-d'); + default: + $datetime = time(); + } + + // Determine which fields we will need + $fields = []; + + if (! empty($this->model->useTimestamps)) + { + $fields[$this->model->createdField] = $datetime; + $fields[$this->model->updatedField] = $datetime; + } + + if (! empty($this->model->useSoftDeletes)) + { + $fields[$this->model->deletedField] = null; + } + + // Iterate over new entities and add the necessary fields + $return = []; + foreach ($this->make($count ?? 1) as $i => $result) + { + // Set the ID + $fields[$this->model->primaryKey] = $i; + + // Merge fields + if (is_array($result)) + { + $result = array_merge($result, $fields); + } + else + { + foreach ($fields as $key => $value) + { + $result->{$key} = $value; + } + } + + $return[] = $result; + } + + return is_null($count) ? reset($return) : $return; + } +} diff --git a/system/Test/FeatureResponse.php b/system/Test/FeatureResponse.php index 9f2b193..6c919b7 100644 --- a/system/Test/FeatureResponse.php +++ b/system/Test/FeatureResponse.php @@ -39,7 +39,7 @@ namespace CodeIgniter\Test; use CodeIgniter\HTTP\RedirectResponse; -use CodeIgniter\HTTP\Response; +use CodeIgniter\HTTP\ResponseInterface; use Config\Format; use PHPUnit\Framework\TestCase; @@ -52,7 +52,7 @@ /** * The response. * - * @var \CodeIgniter\HTTP\Response + * @var \CodeIgniter\HTTP\ResponseInterface */ public $response; @@ -66,9 +66,9 @@ /** * Constructor. * - * @param Response $response + * @param ResponseInterface $response */ - public function __construct(Response $response = null) + public function __construct(ResponseInterface $response = null) { $this->response = $response; @@ -91,15 +91,17 @@ */ public function isOK(): bool { + $status = $this->response->getStatusCode(); + // Only 200 and 300 range status codes // are considered valid. - if ($this->response->getStatusCode() >= 400 || $this->response->getStatusCode() < 200) + if ($status >= 400 || $status < 200) { return false; } - // Empty bodies are not considered valid. - if (empty($this->response->getBody())) + // Empty bodies are not considered valid, unless in redirects + if ($status < 300 && empty($this->response->getBody())) { return false; } @@ -128,6 +130,30 @@ } /** + * 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'); + } + elseif ($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 diff --git a/system/Test/FeatureTestCase.php b/system/Test/FeatureTestCase.php index cf981df..ee70910 100644 --- a/system/Test/FeatureTestCase.php +++ b/system/Test/FeatureTestCase.php @@ -38,24 +38,17 @@ namespace CodeIgniter\Test; -use CodeIgniter\Events\Events; -use CodeIgniter\HTTP\IncomingRequest; -use CodeIgniter\HTTP\Request; -use CodeIgniter\HTTP\URI; -use CodeIgniter\HTTP\UserAgent; -use Config\App; -use Config\Services; - /** * Class FeatureTestCase * - * Provides additional utilities for doing full HTTP testing + * Provides a base class with the trait for doing full HTTP testing * against your application. * * @package CodeIgniter\Test */ class FeatureTestCase extends CIDatabaseTestCase { + use FeatureTestTrait; /** * If present, will override application @@ -79,258 +72,4 @@ * @var boolean */ protected $clean = true; - - /** - * Sets a RouteCollection that will override - * the application's route collection. - * - * Example routes: - * [ - * ['get', 'home', 'Home::index'] - * ] - * - * @param array $routes - * - * @return $this - */ - 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; - } - - /** - * Sets any values that should exist during this session. - * - * @param array $values - * - * @return $this - */ - public function withSession(array $values) - { - $this->session = $values; - - return $this; - } - - /** - * Don't run any events while running this test. - * - * @return $this - */ - 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 \CodeIgniter\Test\FeatureResponse - * @throws \CodeIgniter\Router\Exceptions\RedirectException - * @throws \Exception - */ - public function call(string $method, string $path, array $params = null) - { - // Clean up any open output buffers - // not relevant to unit testing - // @codeCoverageIgnoreStart - if (\ob_get_level() > 0 && $this->clean) - { - \ob_end_clean(); - } - // @codeCoverageIgnoreEnd - - // Simulate having a blank session - $_SESSION = []; - $_SERVER['REQUEST_METHOD'] = $method; - - $request = $this->setupRequest($method, $path, $params); - $request = $this->populateGlobals($method, $request, $params); - - // Make sure the RouteCollection knows what method we're using... - if (! empty($this->routes)) - { - $this->routes->setHTTPVerb($method); - } - - // Make sure any other classes that might call the request - // instance get the right one. - Services::injectMock('request', $request); - - $response = $this->app - ->setRequest($request) - ->run($this->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); - - return new FeatureResponse($response); - } - - /** - * Performs a GET request. - * - * @param string $path - * @param array|null $params - * - * @return \CodeIgniter\Test\FeatureResponse - * @throws \CodeIgniter\Router\Exceptions\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 \CodeIgniter\Test\FeatureResponse - * @throws \CodeIgniter\Router\Exceptions\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 \CodeIgniter\Test\FeatureResponse - * @throws \CodeIgniter\Router\Exceptions\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 \CodeIgniter\Test\FeatureResponse - * @throws \CodeIgniter\Router\Exceptions\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 \CodeIgniter\Test\FeatureResponse - * @throws \CodeIgniter\Router\Exceptions\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 \CodeIgniter\Test\FeatureResponse - * @throws \CodeIgniter\Router\Exceptions\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 - * @param array|null $params - * - * @return \CodeIgniter\HTTP\IncomingRequest - */ - protected function setupRequest(string $method, string $path = null, array $params = null): IncomingRequest - { - $config = config(App::class); - $uri = new URI($config->baseURL . '/' . trim($path, '/ ')); - - $request = new IncomingRequest($config, clone($uri), $params, new UserAgent()); - $request->uri = $uri; - - $request->setMethod($method); - $request->setProtocolVersion('1.1'); - - 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 \CodeIgniter\HTTP\Request $request - * @param array|null $params - * - * @return \CodeIgniter\HTTP\Request - * @throws \ReflectionException - */ - protected function populateGlobals(string $method, Request $request, array $params = null) - { - $request->setGlobal('get', $this->getPrivateProperty($request->uri, 'query')); - if ($method !== 'get') - { - $request->setGlobal($method, $params); - } - - $_SESSION = $this->session; - - return $request; - } - } diff --git a/system/Test/FeatureTestTrait.php b/system/Test/FeatureTestTrait.php new file mode 100644 index 0000000..b58b5cc --- /dev/null +++ b/system/Test/FeatureTestTrait.php @@ -0,0 +1,338 @@ +resetRoutes(); + foreach ($routes as $route) + { + $collection->{$route[0]}($route[1], $route[2]); + } + } + + $this->routes = $collection; + + return $this; + } + + /** + * Sets any values that should exist during this session. + * + * @param array|null Array of values, or null to use the current $_SESSION + * + * @return $this + */ + public function withSession(array $values = null) + { + $this->session = is_null($values) ? $_SESSION : $values; + + return $this; + } + + /** + * Don't run any events while running this test. + * + * @return $this + */ + 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 \CodeIgniter\Test\FeatureResponse + * @throws \CodeIgniter\Router\Exceptions\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->populateGlobals($method, $request, $params); + + // Make sure the RouteCollection knows what method we're using... + $routes = $this->routes ?: Services::routes(); + $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 \CodeIgniter\Test\FeatureResponse + * @throws \CodeIgniter\Router\Exceptions\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 \CodeIgniter\Test\FeatureResponse + * @throws \CodeIgniter\Router\Exceptions\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 \CodeIgniter\Test\FeatureResponse + * @throws \CodeIgniter\Router\Exceptions\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 \CodeIgniter\Test\FeatureResponse + * @throws \CodeIgniter\Router\Exceptions\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 \CodeIgniter\Test\FeatureResponse + * @throws \CodeIgniter\Router\Exceptions\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 \CodeIgniter\Test\FeatureResponse + * @throws \CodeIgniter\Router\Exceptions\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 \CodeIgniter\HTTP\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; + } + + /** + * 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 \CodeIgniter\HTTP\Request $request + * @param array|null $params + * + * @return \CodeIgniter\HTTP\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'); + + $request->setGlobal('get', $get); + if ($method !== 'get') + { + $request->setGlobal($method, $params); + } + + $request->setGlobal('request', $params); + + $_SESSION = $this->session ?? []; + + return $request; + } +} diff --git a/system/Test/Interfaces/FabricatorModel.php b/system/Test/Interfaces/FabricatorModel.php new file mode 100644 index 0000000..fdf7500 --- /dev/null +++ b/system/Test/Interfaces/FabricatorModel.php @@ -0,0 +1,109 @@ +table with a primary key + * matching $id. + * + * @param mixed|array|null $id One primary key or an array of primary keys + * + * @return array|object|null The resulting row of data, or null. + */ + public function find($id = null); + + /** + * Inserts data into the current table. If an object is provided, + * it will attempt to convert it to an array. + * + * @param array|object $data + * @param boolean $returnID Whether insert ID should be returned or not. + * + * @return integer|string|boolean + * @throws \ReflectionException + */ + public function insert($data = null, bool $returnID = true); + + /** + * The following properties and methods are optional, but if present should + * adhere to their definitions. + * + * @property array $allowedFields + * @property string $useSoftDeletes + * @property string $useTimestamps + * @property string $createdField + * @property string $updatedField + * @property string $deletedField + */ + + /* + * Sets $useSoftDeletes value so that we can temporarily override + * the softdeletes settings. Can be used for all find* methods. + * + * @param boolean $val + * + * @return Model + */ + // public function withDeleted($val = true); + + /** + * Faked data for Fabricator. + * + * @param Generator $faker + * + * @return array|object + */ + // public function fake(Generator &$faker); +} diff --git a/system/Test/Mock/MockEmail.php b/system/Test/Mock/MockEmail.php index fdbd14f..ab62a77 100644 --- a/system/Test/Mock/MockEmail.php +++ b/system/Test/Mock/MockEmail.php @@ -1,24 +1,31 @@ returnValue) { - $this->clear(); + $this->setArchiveValues(); + + if ($autoClear) + { + $this->clear(); + } + + Events::trigger('email', $this->archive); } - $this->archive = get_object_vars($this); - return true; + return $this->returnValue; } } diff --git a/system/Test/bootstrap.php b/system/Test/bootstrap.php index 83585f3..89bdc7b 100644 --- a/system/Test/bootstrap.php +++ b/system/Test/bootstrap.php @@ -39,12 +39,15 @@ } // Load necessary components +require_once SYSTEMPATH . 'Config/AutoloadConfig.php'; require_once APPPATH . 'Config/Autoload.php'; require_once APPPATH . 'Config/Constants.php'; +require_once SYSTEMPATH . 'Modules/Modules.php'; require_once APPPATH . 'Config/Modules.php'; require_once SYSTEMPATH . 'Autoloader/Autoloader.php'; require_once SYSTEMPATH . 'Config/BaseService.php'; +require_once SYSTEMPATH . 'Config/Services.php'; require_once APPPATH . 'Config/Services.php'; // Use Config\Services as CodeIgniter\Services diff --git a/system/Throttle/Throttler.php b/system/Throttle/Throttler.php index 7698bdd..c843434 100644 --- a/system/Throttle/Throttler.php +++ b/system/Throttle/Throttler.php @@ -169,9 +169,9 @@ $tokens += $rate * $elapsed; $tokens = $tokens > $capacity ? $capacity : $tokens; - // If $tokens > 0, then we are safe to perform the action, but + // If $tokens >= 1, then we are safe to perform the action, but // we need to decrement the number of available tokens. - if ($tokens > 0) + if ($tokens >= 1) { $this->cache->save($tokenName, $tokens - $cost, $seconds); $this->cache->save($tokenName . 'Time', time(), $seconds); @@ -209,5 +209,4 @@ { return $this->testTime ?? time(); } - } diff --git a/system/Validation/FileRules.php b/system/Validation/FileRules.php index c929088..61bedb9 100644 --- a/system/Validation/FileRules.php +++ b/system/Validation/FileRules.php @@ -84,22 +84,38 @@ */ public function uploaded(string $blank = null, string $name): bool { - $file = $this->request->getFile($name); - - if (is_null($file)) + if (! ($files = $this->request->getFileMultiple($name))) { - return false; + $files = [$this->request->getFile($name)]; } - if (ENVIRONMENT === 'testing') + foreach ($files as $file) { - return $file->getError() === 0; + if (is_null($file)) + { + return false; + } + + if (ENVIRONMENT === 'testing') + { + if ($file->getError() !== 0) + { + return false; + } + } + else + { + // Note: cannot unit test this; no way to over-ride ENVIRONMENT? + // @codeCoverageIgnoreStart + if (! $file->isValid()) + { + return false; + } + // @codeCoverageIgnoreEnd + } } - // Note: cannot unit test this; no way to over-ride ENVIRONMENT? - // @codeCoverageIgnoreStart - return $file->isValid(); - // @codeCoverageIgnoreEnd + return true; } //-------------------------------------------------------------------- @@ -136,6 +152,11 @@ return true; } + if ($file->getError() === UPLOAD_ERR_INI_SIZE) + { + return false; + } + if ($file->getSize() / 1024 > $params[0]) { return false; diff --git a/system/Validation/Rules.php b/system/Validation/Rules.php index 371634b..9621676 100644 --- a/system/Validation/Rules.php +++ b/system/Validation/Rules.php @@ -168,7 +168,10 @@ if (! empty($where_field) && ! empty($where_value)) { - $row = $row->where($where_field, $where_value); + if (! preg_match('/^\{(\w+)\}$/', $where_value)) + { + $row = $row->where($where_field, $where_value); + } } return (bool) ($row->get() @@ -228,7 +231,10 @@ if (! empty($ignoreField) && ! empty($ignoreValue)) { - $row = $row->where("{$ignoreField} !=", $ignoreValue); + if (! preg_match('/^\{(\w+)\}$/', $ignoreValue)) + { + $row = $row->where("{$ignoreField} !=", $ignoreValue); + } } return (bool) ($row->get() diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index e425608..16fb5be 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -250,7 +250,38 @@ { if (! in_array('required', $rules) && (is_array($value) ? empty($value) : (trim($value) === ''))) { - return true; + $passed = true; + + foreach ($rules as $rule) + { + if (preg_match('/(.*?)\[(.*)\]/', $rule, $match)) + { + $rule = $match[1]; + $param = $match[2]; + + if (! in_array($rule, ['required_with', 'required_without'])) + { + continue; + } + + // Check in our rulesets + foreach ($this->ruleSetInstances as $set) + { + if (! method_exists($set, $rule)) + { + continue; + } + + $passed = $passed && $set->$rule($value, $param, $data); + break; + } + } + } + + if ($passed === true) + { + return true; + } } $rules = array_diff($rules, ['permit_empty']); @@ -593,7 +624,7 @@ throw ValidationException::forGroupNotArray($group); } - $this->rules = $this->config->$group; + $this->setRules($this->config->$group); // If {group}_errors exists in the config file, // then override our custom errors with them. diff --git a/system/bootstrap.php b/system/bootstrap.php index e014ad0..9298baf 100644 --- a/system/bootstrap.php +++ b/system/bootstrap.php @@ -118,12 +118,15 @@ if (! class_exists(Config\Autoload::class, false)) { + require_once SYSTEMPATH . 'Config/AutoloadConfig.php'; require_once APPPATH . 'Config/Autoload.php'; + require_once SYSTEMPATH . 'Modules/Modules.php'; require_once APPPATH . 'Config/Modules.php'; } require_once SYSTEMPATH . 'Autoloader/Autoloader.php'; require_once SYSTEMPATH . 'Config/BaseService.php'; +require_once SYSTEMPATH . 'Config/Services.php'; require_once APPPATH . 'Config/Services.php'; // Use Config\Services as CodeIgniter\Services