From 21f3e4da61bfdc6f7ed9c5ca5576e76f93487826 Mon Sep 17 00:00:00 2001
From: Bruno Moreira <5393+pocketarc@users.noreply.github.com>
Date: Sun, 1 Nov 2026 10:27:40 -0500
Subject: [PATCH 01/11] docs: Tell upgraders to replace user_agents.php and
mimes.php. Follow-up to #58 and #60.
---
tests/codeigniter/libraries/Useragent_test.php | 7 +++++++
user_guide_src/source/installation/upgrade_320.rst | 6 ++++++
2 files changed, 13 insertions(+)
diff --git a/tests/codeigniter/libraries/Useragent_test.php b/tests/codeigniter/libraries/Useragent_test.php
index 8c5b28b9f7..7ffe9fc8dc 100644
--- a/tests/codeigniter/libraries/Useragent_test.php
+++ b/tests/codeigniter/libraries/Useragent_test.php
@@ -142,4 +142,11 @@ public function test_edge()
$this->assertEquals('Edge', $this->agent->browser());
}
+ public function test_vivaldi()
+ {
+ $this->agent->parse('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Vivaldi/6.5.3206.48');
+ $this->assertEquals('Vivaldi', $this->agent->browser());
+ $this->assertEquals('6.5.3206.48', $this->agent->version());
+ }
+
}
\ No newline at end of file
diff --git a/user_guide_src/source/installation/upgrade_320.rst b/user_guide_src/source/installation/upgrade_320.rst
index 2226d8588c..9630267c64 100644
--- a/user_guide_src/source/installation/upgrade_320.rst
+++ b/user_guide_src/source/installation/upgrade_320.rst
@@ -270,3 +270,9 @@ entries by key needs review.
.. note:: In 3.2-dev, this function was broken and returned entries holding
only a ``relative_path`` key, so there is nothing to migrate if you are
coming from there.
+
+Step 16: Replace config/user_agents.php and config/mimes.php
+============================================================
+
+These config files have received some updates. Please copy them to
+*application/config/user_agents.php* and *application/config/mimes.php*.
From 5afeb2a1c3fdcffc3efef88c1287937fb4ead918 Mon Sep 17 00:00:00 2001
From: Bruno Moreira <5393+pocketarc@users.noreply.github.com>
Date: Sun, 1 Nov 2026 10:27:50 -0500
Subject: [PATCH 02/11] fix: Stop set_cookie() from turning arrays into an
'Array' cookie. Follow-up to #62.
---
system/core/Input.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/system/core/Input.php b/system/core/Input.php
index a2fca18f61..90552e8ee6 100644
--- a/system/core/Input.php
+++ b/system/core/Input.php
@@ -390,7 +390,7 @@ public function set_cookie($name, $value = '', $expire = 0, $domain = '', $path
'httponly' => $httponly,
'samesite' => $samesite,
);
- setcookie($prefix.$name, (string) $value, $setcookie_options);
+ setcookie($prefix.$name, $value === NULL ? '' : $value, $setcookie_options);
}
// --------------------------------------------------------------------
From 82c8750153a33a748f9b820c87cd01a4a66793bf Mon Sep 17 00:00:00 2001
From: Bruno Moreira <5393+pocketarc@users.noreply.github.com>
Date: Sun, 1 Nov 2026 10:27:57 -0500
Subject: [PATCH 03/11] test: Cover comments on renamed Postgres columns.
Follow-up to #68.
---
.../database/Postgre_forge_test.php | 56 +++++++++++++++++++
1 file changed, 56 insertions(+)
create mode 100644 tests/codeigniter/database/Postgre_forge_test.php
diff --git a/tests/codeigniter/database/Postgre_forge_test.php b/tests/codeigniter/database/Postgre_forge_test.php
new file mode 100644
index 0000000000..03e8ffcd84
--- /dev/null
+++ b/tests/codeigniter/database/Postgre_forge_test.php
@@ -0,0 +1,56 @@
+assertTrue($forge->modify_column('users', array(
+ 'old_name' => array('name' => 'new_name', 'comment' => 'Renamed column')
+ )));
+
+ $this->assertSame(array(
+ 'ALTER TABLE "users" RENAME COLUMN "old_name" TO "new_name"',
+ 'COMMENT ON COLUMN "users"."new_name" IS \'Renamed column\''
+ ), $db->queries);
+ }
+
+ public function forge_provider()
+ {
+ return array(array('CI_DB_postgre_forge'), array('CI_DB_pdo_pgsql_forge'));
+ }
+
+}
+
+class Postgre_forge_db_stub {
+
+ public $dbprefix = '';
+ public $db_debug = FALSE;
+ public $queries = array();
+
+ public function version()
+ {
+ return '15.0';
+ }
+
+ public function escape_identifiers($item)
+ {
+ return '"'.$item.'"';
+ }
+
+ public function escape($str)
+ {
+ return "'".str_replace("'", "''", $str)."'";
+ }
+
+ public function query($sql)
+ {
+ $this->queries[] = $sql;
+ return TRUE;
+ }
+}
From dc57655e63d069fe88280faa2ac14a4f92030727 Mon Sep 17 00:00:00 2001
From: Bruno Moreira <5393+pocketarc@users.noreply.github.com>
Date: Sun, 1 Nov 2026 10:41:54 -0500
Subject: [PATCH 04/11] docs: Document the is_array rule and array field
upgrades, with tests. Follow-up to #75.
---
system/libraries/Form_validation.php | 2 -
.../libraries/Form_validation_test.php | 98 +++++++++++++++++++
.../source/installation/upgrade_320.rst | 17 ++++
.../source/libraries/form_validation.rst | 21 +++-
4 files changed, 134 insertions(+), 4 deletions(-)
diff --git a/system/libraries/Form_validation.php b/system/libraries/Form_validation.php
index 48a9d035b1..9896740e6f 100644
--- a/system/libraries/Form_validation.php
+++ b/system/libraries/Form_validation.php
@@ -587,8 +587,6 @@ protected function _reduce_array($array, $keys, $i = 0)
/**
* Re-populate the _POST array with our finalized and processed data
*
- * Arrays require bracket notation or an explicit is_array rule.
- *
* @return void
*/
protected function _reset_data_array(&$data)
diff --git a/tests/codeigniter/libraries/Form_validation_test.php b/tests/codeigniter/libraries/Form_validation_test.php
index 1bafd50c35..6e1029cc6b 100644
--- a/tests/codeigniter/libraries/Form_validation_test.php
+++ b/tests/codeigniter/libraries/Form_validation_test.php
@@ -642,6 +642,104 @@ public function test_validated_data_assignment()
$_POST = array();
}
+ public function test_set_rules_null_label_uses_field_name()
+ {
+ $_POST = array('email' => '');
+ $this->form_validation->set_message('required', 'The {field} field is required.');
+ $this->form_validation->set_rules('email', NULL, 'required');
+
+ $this->assertFalse($this->form_validation->run());
+ $this->assertEquals('
The email field is required.
', $this->form_validation->error('email'));
+
+ $_POST = array();
+ }
+
+ public function test_is_array_rule_on_bracket_field()
+ {
+ $_POST = array('tags' => array('a', 'b'));
+ $this->form_validation->set_rules('tags[]', 'Tags', 'required|is_array');
+ $this->assertTrue($this->form_validation->run());
+
+ $this->form_validation->reset_validation();
+ $this->form_validation->set_data(array('tags' => array()));
+ $this->form_validation->set_rules('tags[]', 'Tags', 'required|is_array');
+ $this->assertFalse($this->form_validation->run());
+
+ $_POST = array();
+ }
+
+ public function test_is_array_rule_on_bracket_field_passes_whole_array_to_callable()
+ {
+ $seen = NULL;
+ $_POST = array('tags' => array('a', 'b'));
+ $this->form_validation->set_rules('tags[]', 'Tags', array(
+ 'is_array',
+ array('capture', function ($value) use (&$seen) {
+ $seen = $value;
+ return TRUE;
+ })
+ ));
+
+ $this->assertTrue($this->form_validation->run());
+ $this->assertEquals(array('a', 'b'), $seen);
+
+ $_POST = array();
+ }
+
+ public function test_is_array_rule_on_keyed_field()
+ {
+ $_POST = array('user' => array('roles' => array('admin', 'editor')));
+ $this->form_validation->set_rules('user[roles]', 'Roles', 'required|is_array');
+
+ $this->assertTrue($this->form_validation->run());
+ $this->assertEquals(array('admin', 'editor'), $_POST['user']['roles']);
+
+ $_POST = array();
+ }
+
+ public function test_is_array_rule_keeps_array_after_run()
+ {
+ $_POST = array('tags' => array('a', 'b'));
+ $this->form_validation->set_rules('tags', 'Tags', 'required|is_array');
+
+ $this->assertTrue($this->form_validation->run());
+ $this->assertEquals(array('a', 'b'), $_POST['tags']);
+
+ $this->form_validation->reset_validation();
+ $this->form_validation->set_data(array('tags' => array('a', 'b')));
+ $this->form_validation->set_rules('tags', 'Tags', 'is_array');
+ $data_processed = NULL;
+
+ $this->assertTrue($this->form_validation->run('', $data_processed));
+ $this->assertEquals(array('a', 'b'), $data_processed['tags']);
+
+ $_POST = array();
+ }
+
+ public function test_array_input_without_is_array_rule()
+ {
+ $_POST = array('name' => array('x'), 'other' => 'y');
+ $this->form_validation->set_rules('name', 'Name', 'alpha');
+ $this->form_validation->set_rules('other', 'Other', 'required');
+
+ $this->assertTrue($this->form_validation->run());
+ $this->assertNull($_POST['name']);
+
+ $this->assertFalse($this->run_rules(
+ array(array('field' => 'ids[]', 'label' => 'IDs', 'rules' => 'numeric')),
+ array('ids' => array('1', 'x'))
+ ));
+
+ $this->form_validation->reset_validation();
+ $_POST = array('names' => array(' a ', ' b '));
+ $this->form_validation->set_rules('names[]', 'Names', 'trim|required');
+
+ $this->assertTrue($this->form_validation->run());
+ $this->assertEquals(array('a', 'b'), $_POST['names']);
+
+ $_POST = array();
+ }
+
/**
* Run rules
*
diff --git a/user_guide_src/source/installation/upgrade_320.rst b/user_guide_src/source/installation/upgrade_320.rst
index 9630267c64..391da8c733 100644
--- a/user_guide_src/source/installation/upgrade_320.rst
+++ b/user_guide_src/source/installation/upgrade_320.rst
@@ -276,3 +276,20 @@ Step 16: Replace config/user_agents.php and config/mimes.php
These config files have received some updates. Please copy them to
*application/config/user_agents.php* and *application/config/mimes.php*.
+
+Step 17: Check form validation rules for array fields
+=====================================================
+
+This only affects form fields that receive arrays. If you don't validate
+array input, you can skip this step.
+
+If you are upgrading from 3.1.x, check validation rules for field names
+without brackets, such as ``tags``. Submitted arrays are now set to
+``NULL`` in ``$_POST`` after a successful ``run()``; 3.1.x kept them in
+``$_POST``. Add ``is_array`` to accept and keep the array, or use ``tags[]``
+to validate each element.
+
+If you are upgrading from 3.2-dev, check rules that include ``is_array``
+for bracketed field names such as ``items[]``. Rules and callbacks now
+receive the whole array; previously only the first element was validated
+and passed to callbacks. See :ref:`using-arrays-as-field-names`.
diff --git a/user_guide_src/source/libraries/form_validation.rst b/user_guide_src/source/libraries/form_validation.rst
index 5f30817eb1..cc329cda05 100644
--- a/user_guide_src/source/libraries/form_validation.rst
+++ b/user_guide_src/source/libraries/form_validation.rst
@@ -957,6 +957,21 @@ When you use a helper function you'll include the bracket as well::
+Without the ``is_array`` rule, rules for bracketed field names run once
+per array element. With ``is_array``, every rule in the list, including
+callbacks and callables, receives the whole array instead. This applies
+to field names with or without brackets, such as ``tags``, ``tags[]``
+and ``user[roles]``::
+
+ $this->form_validation->set_rules('tags', 'Tags', 'required|is_array');
+
+The ``required`` rule combined with ``is_array`` rejects an empty array.
+
+For field names without brackets, array input is otherwise treated as an
+empty value. After a successful ``run()``, that value is set to ``NULL``
+in ``$_POST``, or in the output variable passed as the second parameter
+to ``run()``. Adding ``is_array`` accepts and keeps the array.
+
**************
Rule Reference
@@ -969,6 +984,8 @@ use:
Rule Parameter Description Example
========================= ========== ============================================================================================= =======================
**required** No Returns FALSE if the form element is empty.
+**is_array** No Returns FALSE if the form element is not an array.
+ See :ref:`using-arrays-as-field-names` for details on validating entire arrays.
**matches** Yes Returns FALSE if the form element does not match the one in the parameter. matches[form_item]
**regex_match** Yes Returns FALSE if the form element does not match the regular expression. regex_match[/regex/]
**differs** Yes Returns FALSE if the form element does not differ from the one in the parameter. differs[form_item]
@@ -1009,8 +1026,8 @@ Rule Parameter Description
**valid_base64** No Returns FALSE if the supplied string contains anything other than valid Base64 characters.
========================= ========== ============================================================================================= =======================
-.. note:: These rules can also be called as discrete methods. For
- example::
+.. note:: These rules can also be called as discrete methods, except for
+ ``is_array``, which is a native PHP function. For example::
$this->form_validation->required($string);
From 7f60d50666523d80470ed553fa97372c3e483483 Mon Sep 17 00:00:00 2001
From: Bruno Moreira <5393+pocketarc@users.noreply.github.com>
Date: Sun, 1 Nov 2026 10:51:48 -0500
Subject: [PATCH 05/11] fix: Match Redis session save path schemes
case-insensitively. Follow-up to #66.
---
system/libraries/Session/drivers/Session_redis_driver.php | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/system/libraries/Session/drivers/Session_redis_driver.php b/system/libraries/Session/drivers/Session_redis_driver.php
index bc25293509..e20413a05f 100644
--- a/system/libraries/Session/drivers/Session_redis_driver.php
+++ b/system/libraries/Session/drivers/Session_redis_driver.php
@@ -134,15 +134,17 @@ public function __construct(&$params)
{
log_message('error', 'Session: No Redis save path configured.');
}
- elseif (preg_match('#^unix://([^\?]+)(?\?.+)?$#', $this->_config['save_path'], $matches))
+ elseif (preg_match('#^unix://([^\?]+)(?\?.+)?$#i', $this->_config['save_path'], $matches))
{
$save_path = array('path' => $matches[1]);
}
- elseif (preg_match('#^(?:(tcp|tls|ssl)://)?([^:?]+)(?:\:(\d+))?(?\?.+)?#', $this->_config['save_path'], $matches))
+ elseif (preg_match('#^(?:(tcp|tls|ssl)://)?([^:?]+)(?:\:(\d+))?(?\?.+)?#i', $this->_config['save_path'], $matches))
{
+ $scheme = strtolower($matches[1]);
+
// phpredis needs the tls:// or ssl:// scheme in the host to enable encryption; tcp:// is the default
$save_path = array(
- 'host' => (in_array($matches[1], array('tls', 'ssl'), TRUE) ? $matches[1].'://' : '').$matches[2],
+ 'host' => (in_array($scheme, array('tls', 'ssl'), TRUE) ? $scheme.'://' : '').$matches[2],
'port' => empty($matches[3]) ? NULL : $matches[3],
'timeout' => 0.0 // We always pass this to Redis::connect(), so it needs to exist
);
From 5d9c22dddb305e21de1ec97e2780cec861e73d4e Mon Sep 17 00:00:00 2001
From: Bruno Moreira <5393+pocketarc@users.noreply.github.com>
Date: Sun, 1 Nov 2026 11:39:52 -0500
Subject: [PATCH 06/11] fix: Preserve WebP transparency when watermarking.
Follow-up to #71.
---
system/libraries/Image_lib.php | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/system/libraries/Image_lib.php b/system/libraries/Image_lib.php
index 948d084f9c..1f3c4815ae 100644
--- a/system/libraries/Image_lib.php
+++ b/system/libraries/Image_lib.php
@@ -1251,8 +1251,7 @@ public function overlay_watermark()
imagecopymerge($src_img, $wm_img, $x_axis, $y_axis, 0, 0, $wm_width, $wm_height, $this->wm_opacity);
}
- // We can preserve transparency for PNG images
- if ($this->image_type === 3)
+ if ($this->image_type === 3 OR $this->image_type === 18)
{
imagealphablending($src_img, FALSE);
imagesavealpha($src_img, TRUE);
@@ -1425,8 +1424,7 @@ public function text_watermark()
imagestring($src_img, $this->wm_font_size, $x_axis, $y_axis, $this->wm_text, $txt_color);
}
- // We can preserve transparency for PNG images
- if ($this->image_type === 3)
+ if ($this->image_type === 3 OR $this->image_type === 18)
{
imagealphablending($src_img, FALSE);
imagesavealpha($src_img, TRUE);
From 126a0b93e40373074da36d327441239576e82ff1 Mon Sep 17 00:00:00 2001
From: Bruno Moreira <5393+pocketarc@users.noreply.github.com>
Date: Sun, 1 Nov 2026 12:39:11 -0500
Subject: [PATCH 07/11] fix: Stop a missing cache_path from caching to the
filesystem root. Follow-up to #63.
---
system/core/Output.php | 22 ++++++++---
system/libraries/Cache/drivers/Cache_file.php | 2 +-
tests/codeigniter/core/Output_test.php | 31 +++++++++++++++
.../codeigniter/libraries/Cache_file_test.php | 39 +++++++++++++++++++
.../source/installation/upgrade_320.rst | 13 +++++++
5 files changed, 100 insertions(+), 7 deletions(-)
create mode 100644 tests/codeigniter/libraries/Cache_file_test.php
diff --git a/system/core/Output.php b/system/core/Output.php
index 7a908bf25c..7c4a759c2b 100644
--- a/system/core/Output.php
+++ b/system/core/Output.php
@@ -558,8 +558,7 @@ public function _display($output = NULL)
public function _write_cache($output)
{
$CI =& get_instance();
- $path = $CI->config->item('cache_path');
- $cache_path = ($path === '') ? APPPATH.'cache'.DIRECTORY_SEPARATOR : rtrim($path, '/\\').DIRECTORY_SEPARATOR;
+ $cache_path = $this->_get_cache_path($CI->config->item('cache_path'));
if ( ! is_dir($cache_path) OR ! is_really_writable($cache_path))
{
@@ -660,8 +659,7 @@ public function _write_cache($output)
*/
public function _display_cache(&$CFG, &$URI)
{
- $path = $CFG->item('cache_path');
- $cache_path = ($path === '') ? APPPATH.'cache'.DIRECTORY_SEPARATOR : rtrim($path, '/\\').DIRECTORY_SEPARATOR;
+ $cache_path = $this->_get_cache_path($CFG->item('cache_path'));
// Build the file path. The file name is an MD5 hash of the full URI
$uri = $CFG->item('base_url').$CFG->slash_item('index_page').$URI->uri_string;
@@ -738,8 +736,7 @@ public function _display_cache(&$CFG, &$URI)
public function delete_cache($uri = '')
{
$CI =& get_instance();
- $path = $CI->config->item('cache_path');
- $cache_path = ($path === '') ? APPPATH.'cache'.DIRECTORY_SEPARATOR : rtrim($path, '/\\').DIRECTORY_SEPARATOR;
+ $cache_path = $this->_get_cache_path($CI->config->item('cache_path'));
if ( ! is_dir($cache_path))
{
@@ -777,6 +774,19 @@ public function delete_cache($uri = '')
// --------------------------------------------------------------------
+ /**
+ * Get cache path
+ *
+ * @param string $path The configured cache_path value
+ * @return string Cache directory path with a trailing directory separator
+ */
+ protected function _get_cache_path($path)
+ {
+ return empty($path) ? APPPATH.'cache'.DIRECTORY_SEPARATOR : rtrim($path, '/\\').DIRECTORY_SEPARATOR;
+ }
+
+ // --------------------------------------------------------------------
+
/**
* Set Cache Header
*
diff --git a/system/libraries/Cache/drivers/Cache_file.php b/system/libraries/Cache/drivers/Cache_file.php
index 3a4be98a9b..8dabac9dec 100644
--- a/system/libraries/Cache/drivers/Cache_file.php
+++ b/system/libraries/Cache/drivers/Cache_file.php
@@ -66,7 +66,7 @@ public function __construct()
$CI =& get_instance();
$CI->load->helper('file');
$path = $CI->config->item('cache_path');
- $this->_cache_path = ($path === '') ? APPPATH.'cache/' : $path;
+ $this->_cache_path = empty($path) ? APPPATH.'cache'.DIRECTORY_SEPARATOR : rtrim($path, '/\\').DIRECTORY_SEPARATOR;
}
// ------------------------------------------------------------------------
diff --git a/tests/codeigniter/core/Output_test.php b/tests/codeigniter/core/Output_test.php
index 8c99c1fc5f..7de57afa57 100644
--- a/tests/codeigniter/core/Output_test.php
+++ b/tests/codeigniter/core/Output_test.php
@@ -60,4 +60,35 @@ public function test_get_header()
);
}
+ // --------------------------------------------------------------------
+
+ public function test_delete_cache_without_trailing_slash()
+ {
+ $this->ci_set_config('cache_path', APPPATH.'pagecache');
+ $this->ci_set_config('base_url', 'http://example.com/');
+ $this->ci_set_config('index_page', 'index.php');
+
+ $cache_file = md5('http://example.com/index.php/foo');
+ $dir = $this->ci_vfs_mkdir('pagecache', $this->ci_app_root);
+ vfsStream::newFile($cache_file)->at($dir)->setContent('cached');
+
+ $this->assertTrue($this->output->delete_cache('foo'));
+ $this->assertFalse($dir->hasChild($cache_file));
+ }
+
+ // --------------------------------------------------------------------
+
+ public function test_delete_cache_without_cache_path()
+ {
+ $this->ci_set_config('base_url', 'http://example.com/');
+ $this->ci_set_config('index_page', 'index.php');
+
+ $cache_file = md5('http://example.com/index.php/foo');
+ $dir = $this->ci_vfs_mkdir('cache', $this->ci_app_root);
+ vfsStream::newFile($cache_file)->at($dir)->setContent('cached');
+
+ $this->assertTrue($this->output->delete_cache('foo'));
+ $this->assertFalse($dir->hasChild($cache_file));
+ }
+
}
diff --git a/tests/codeigniter/libraries/Cache_file_test.php b/tests/codeigniter/libraries/Cache_file_test.php
new file mode 100644
index 0000000000..fa635316b1
--- /dev/null
+++ b/tests/codeigniter/libraries/Cache_file_test.php
@@ -0,0 +1,39 @@
+helper('file');
+
+ $loader = $this->getMockBuilder('CI_Loader')->setMethods(array('helper'))->getMock();
+ $this->ci_instance_var('load', $loader);
+ }
+
+ // --------------------------------------------------------------------
+
+ public function test_cache_path_without_trailing_slash()
+ {
+ $this->ci_set_config('cache_path', APPPATH.'filecache');
+ $dir = $this->ci_vfs_mkdir('filecache', $this->ci_app_root);
+ $cache = new CI_Cache_file();
+
+ $this->assertTrue($cache->save('foo', 'bar'));
+ $this->assertTrue($dir->hasChild('foo'));
+ $this->assertEquals('bar', $cache->get('foo'));
+ }
+
+ // --------------------------------------------------------------------
+
+ public function test_cache_path_without_config_key()
+ {
+ $this->ci_set_config();
+ $dir = $this->ci_vfs_mkdir('cache', $this->ci_app_root);
+ $cache = new CI_Cache_file();
+
+ $this->assertTrue($cache->save('foo', 'bar'));
+ $this->assertTrue($dir->hasChild('foo'));
+ }
+
+}
diff --git a/user_guide_src/source/installation/upgrade_320.rst b/user_guide_src/source/installation/upgrade_320.rst
index 391da8c733..8db400a5e6 100644
--- a/user_guide_src/source/installation/upgrade_320.rst
+++ b/user_guide_src/source/installation/upgrade_320.rst
@@ -293,3 +293,16 @@ If you are upgrading from 3.2-dev, check rules that include ``is_array``
for bracketed field names such as ``items[]``. Rules and callbacks now
receive the whole array; previously only the first element was validated
and passed to callbacks. See :ref:`using-arrays-as-field-names`.
+
+Step 18: Check full-page caching with a custom cache_path
+=========================================================
+
+If you set ``$config['cache_path']`` without a trailing slash, such as
+``/var/cache/ci``, cached pages are now served. Previously, cache files
+were written but never read. This does not affect configurations where
+``cache_path`` is empty or already ends with a slash.
+
+Before upgrading, check that pages calling ``$this->output->cache()`` do
+not include per-user content, such as account details or CSRF tokens.
+Cached output can be served to other visitors. See
+:doc:`Web Page Caching <../general/caching>`.
From 375bb51fbfa23ec5b7f50cef2020c41dd7a9857e Mon Sep 17 00:00:00 2001
From: Bruno Moreira <5393+pocketarc@users.noreply.github.com>
Date: Sun, 1 Nov 2026 13:00:42 -0500
Subject: [PATCH 08/11] fix: Keep CAPTCHA filenames backward compatible with
time. Follow-up to #74.
---
system/helpers/captcha_helper.php | 3 +--
.../helpers/captcha_helper_test.php | 24 ++-----------------
.../source/installation/upgrade_320.rst | 17 +++++++++++++
3 files changed, 20 insertions(+), 24 deletions(-)
diff --git a/system/helpers/captcha_helper.php b/system/helpers/captcha_helper.php
index 8d24ac93bd..f94ea8799e 100644
--- a/system/helpers/captcha_helper.php
+++ b/system/helpers/captcha_helper.php
@@ -133,8 +133,7 @@ function create_captcha($data)
@closedir($current_dir);
// This variable will later be used later to determine whether we write to disk or output a data:image URI
- // %F is locale-independent, unlike float-to-string conversion on PHP < 8.0
- $img_filename = sprintf('%.6F', $now).'.png';
+ $img_filename = $now.'.png';
}
else
{
diff --git a/tests/codeigniter/helpers/captcha_helper_test.php b/tests/codeigniter/helpers/captcha_helper_test.php
index f2d2599f53..0f748ac9b1 100644
--- a/tests/codeigniter/helpers/captcha_helper_test.php
+++ b/tests/codeigniter/helpers/captcha_helper_test.php
@@ -38,14 +38,13 @@ public function tear_down()
// --------------------------------------------------------------------
- public function test_create_captcha_writes_image_with_fractional_timestamp_filename()
+ public function test_create_captcha_names_image_after_time()
{
$captcha = $this->_create_captcha();
$this->assertTrue(is_array($captcha));
- $this->assertEquals(1, preg_match('#^\d{10}\.\d{6}\.png$#', $captcha['filename']));
+ $this->assertSame($captcha['time'].'.png', $captcha['filename']);
$this->assertTrue(is_file($this->_img_path.$captcha['filename']));
- $this->assertSame(sprintf('%.6F', $captcha['time']).'.png', $captcha['filename']);
}
// --------------------------------------------------------------------
@@ -148,25 +147,6 @@ public function test_create_captcha_respects_custom_expiration()
// --------------------------------------------------------------------
- public function test_create_captcha_filename_is_locale_independent()
- {
- $original = setlocale(LC_NUMERIC, 0);
- $locale = setlocale(LC_NUMERIC, 'de_DE.UTF-8', 'de_DE.utf8', 'de_DE', 'de-DE', 'German_Germany.1252', 'German');
-
- if ($locale === FALSE)
- {
- $this->markTestSkipped('No locale with a comma decimal separator is available.');
- }
-
- $captcha = $this->_create_captcha();
- setlocale(LC_NUMERIC, $original);
-
- $this->assertEquals(1, preg_match('#^\d{10}\.\d{6}\.png$#', $captcha['filename']));
- $this->assertTrue(is_file($this->_img_path.$captcha['filename']));
- }
-
- // --------------------------------------------------------------------
-
private function _create_captcha(array $data = array())
{
return create_captcha(array_merge(array(
diff --git a/user_guide_src/source/installation/upgrade_320.rst b/user_guide_src/source/installation/upgrade_320.rst
index 8db400a5e6..0b8b516c24 100644
--- a/user_guide_src/source/installation/upgrade_320.rst
+++ b/user_guide_src/source/installation/upgrade_320.rst
@@ -306,3 +306,20 @@ Before upgrading, check that pages calling ``$this->output->cache()`` do
not include per-user content, such as account details or CSRF tokens.
Cached output can be served to other visitors. See
:doc:`Web Page Caching <../general/caching>`.
+
+Step 19: Check CAPTCHA image handling
+=====================================
+
+The :doc:`CAPTCHA Helper <../helpers/captcha_helper>` function
+:php:func:`create_captcha()` now correctly removes expired images from
+``img_path`` when it runs. It matches filenames consisting of a 10-digit
+timestamp with an optional fractional part (``.`` or ``,`` separator),
+followed by ``.png`` or ``.jpg`` (case-insensitive). Keep ``img_path``
+dedicated to CAPTCHA images: any matching file is deleted after
+``expiration`` seconds (default 7200). Other files are left alone.
+
+If you are upgrading from 3.1.x, check code that assumes JPEG output.
+Since 3.2-dev, ``create_captcha()`` always writes PNG files; 3.1.x used
+JPEG when ``imagejpeg()`` was available. Replace filenames built from
+``$cap['time']`` plus ``.jpg`` with the returned ``$cap['filename']``,
+which contains the exact filename (``$cap['time']`` plus ``.png``).
From 9ee53626a1e4c324f10d30bda56916c6d20ef2cd Mon Sep 17 00:00:00 2001
From: Bruno Moreira <5393+pocketarc@users.noreply.github.com>
Date: Sun, 1 Nov 2026 14:16:26 -0500
Subject: [PATCH 09/11] docs: Note that inline CAPTCHA images work again, with
a test. Follow-up to #67.
---
tests/codeigniter/helpers/captcha_helper_test.php | 14 ++++++++++++++
user_guide_src/source/installation/upgrade_320.rst | 6 ++++++
2 files changed, 20 insertions(+)
diff --git a/tests/codeigniter/helpers/captcha_helper_test.php b/tests/codeigniter/helpers/captcha_helper_test.php
index 0f748ac9b1..7b4d4abf82 100644
--- a/tests/codeigniter/helpers/captcha_helper_test.php
+++ b/tests/codeigniter/helpers/captcha_helper_test.php
@@ -147,6 +147,20 @@ public function test_create_captcha_respects_custom_expiration()
// --------------------------------------------------------------------
+ public function test_create_captcha_returns_data_uri_without_paths()
+ {
+ $captcha = create_captcha(array('word' => 'test'));
+
+ $this->assertTrue(is_array($captcha));
+ $this->assertNull($captcha['filename']);
+ $this->assertEquals(1, preg_match('#src="data:image/png;base64,([A-Za-z0-9+/=]+)"#', $captcha['image'], $match));
+ $this->assertSame("\x89PNG", substr(base64_decode($match[1]), 0, 4));
+
+ $this->assertFalse(create_captcha(array('word' => 'test', 'img_url' => 'http://example.com/captcha/')));
+ }
+
+ // --------------------------------------------------------------------
+
private function _create_captcha(array $data = array())
{
return create_captcha(array_merge(array(
diff --git a/user_guide_src/source/installation/upgrade_320.rst b/user_guide_src/source/installation/upgrade_320.rst
index 0b8b516c24..170acc0127 100644
--- a/user_guide_src/source/installation/upgrade_320.rst
+++ b/user_guide_src/source/installation/upgrade_320.rst
@@ -323,3 +323,9 @@ Since 3.2-dev, ``create_captcha()`` always writes PNG files; 3.1.x used
JPEG when ``imagejpeg()`` was available. Replace filenames built from
``$cap['time']`` plus ``.jpg`` with the returned ``$cap['filename']``,
which contains the exact filename (``$cap['time']`` plus ``.png``).
+
+Omitting both ``img_path`` and ``img_url`` returns the image inline as a
+``data:image/png;base64,...`` URI again, as described in the CAPTCHA helper
+documentation. This mode was added in 3.2-dev in 2017, but a 2020 merge
+accidentally disabled it, causing ``create_captcha()`` to return ``FALSE``
+instead.
From ccb8d7c8729410c49198b254991cb7eda4711165 Mon Sep 17 00:00:00 2001
From: Bruno Moreira <5393+pocketarc@users.noreply.github.com>
Date: Sun, 1 Nov 2026 15:14:49 -0500
Subject: [PATCH 10/11] fix: Keep Redis counters compatible with 3.1.x and
existing keys. Follow-up to #69.
---
.../libraries/Cache/drivers/Cache_redis.php | 42 +++--
.../libraries/Cache_redis_test.php | 144 ++++++++++++++++++
2 files changed, 171 insertions(+), 15 deletions(-)
create mode 100644 tests/codeigniter/libraries/Cache_redis_test.php
diff --git a/system/libraries/Cache/drivers/Cache_redis.php b/system/libraries/Cache/drivers/Cache_redis.php
index ac92d5ad8e..2dd68e6deb 100644
--- a/system/libraries/Cache/drivers/Cache_redis.php
+++ b/system/libraries/Cache/drivers/Cache_redis.php
@@ -163,11 +163,17 @@ public function get($key)
{
$data = $this->_redis->hMGet($key, array('__ci_type', '__ci_value'));
- if ($data === FALSE || $this->_redis->sIsMember('_ci_redis_serialized', $key))
+ if ($this->_redis->sIsMember('_ci_redis_serialized', $key))
{
return FALSE;
}
+ // Also serves scalars that 3.1.x cached as plain strings
+ if ($data === FALSE)
+ {
+ return $this->_redis->get($key);
+ }
+
switch ($data['__ci_type'])
{
case 'array':
@@ -261,15 +267,7 @@ public function delete($key)
*/
public function increment($id, $offset = 1)
{
- $value = $this->_redis->hIncrBy($id, '__ci_value', $offset);
-
- if ($value !== FALSE)
- {
- $this->_redis->hSet($id, '__ci_type', 'integer');
- return $value;
- }
-
- return FALSE;
+ return $this->_increment($id, $offset);
}
// ------------------------------------------------------------------------
@@ -283,15 +281,29 @@ public function increment($id, $offset = 1)
*/
public function decrement($id, $offset = 1)
{
- $value = $this->_redis->hIncrBy($id, '__ci_value', -$offset);
+ return $this->_increment($id, -$offset);
+ }
- if ($value !== FALSE)
+ // ------------------------------------------------------------------------
+
+ /**
+ * Add to a counter
+ *
+ * @param string $id Cache ID
+ * @param int $offset Amount to add (negative to subtract)
+ * @return mixed New value on success or FALSE on failure
+ */
+ protected function _increment($id, $offset)
+ {
+ $value = $this->_redis->incrBy($id, $offset);
+
+ if ($value === FALSE)
{
- $this->_redis->hSet($id, '__ci_type', 'integer');
- return $value;
+ $value = $this->_redis->hIncrBy($id, '__ci_value', $offset);
+ $value === FALSE OR $this->_redis->hSet($id, '__ci_type', 'integer');
}
- return FALSE;
+ return $value;
}
// ------------------------------------------------------------------------
diff --git a/tests/codeigniter/libraries/Cache_redis_test.php b/tests/codeigniter/libraries/Cache_redis_test.php
new file mode 100644
index 0000000000..814d0e63c7
--- /dev/null
+++ b/tests/codeigniter/libraries/Cache_redis_test.php
@@ -0,0 +1,144 @@
+redis = new Cache_redis_server_stub();
+
+ $class = new ReflectionClass('CI_Cache_redis');
+ $this->cache = $class->newInstanceWithoutConstructor();
+
+ $property = new ReflectionProperty('CI_Cache_redis', '_redis');
+ if (PHP_VERSION_ID < 80100)
+ {
+ $property->setAccessible(TRUE);
+ }
+ $property->setValue($this->cache, $this->redis);
+ }
+
+ // --------------------------------------------------------------------
+
+ public function test_increment_creates_plain_counter()
+ {
+ $this->assertSame(1, $this->cache->increment('hits'));
+ $this->assertSame(3, $this->cache->increment('hits', 2));
+ $this->assertSame(2, $this->cache->decrement('hits'));
+
+ $this->assertSame('2', $this->redis->strings['hits']);
+ $this->assertEquals(2, $this->cache->get('hits'));
+ }
+
+ // --------------------------------------------------------------------
+
+ public function test_increment_existing_plain_counter()
+ {
+ $this->redis->strings['hits'] = '5';
+
+ $this->assertSame(6, $this->cache->increment('hits'));
+ $this->assertSame(4, $this->cache->decrement('hits', 2));
+ }
+
+ // --------------------------------------------------------------------
+
+ public function test_increment_saved_value()
+ {
+ $this->redis->hashes['hits'] = array('__ci_type' => 'integer', '__ci_value' => '5');
+
+ $this->assertSame(6, $this->cache->increment('hits'));
+ $this->assertSame(5, $this->cache->decrement('hits'));
+ $this->assertSame(5, $this->cache->get('hits'));
+ }
+
+ // --------------------------------------------------------------------
+
+ public function test_increment_non_numeric_value()
+ {
+ $this->redis->strings['name'] = 'bar';
+ $this->redis->hashes['data'] = array('__ci_type' => 'string', '__ci_value' => 'bar');
+
+ $this->assertFalse($this->cache->increment('name'));
+ $this->assertFalse($this->cache->increment('data'));
+ $this->assertSame('string', $this->redis->hashes['data']['__ci_type']);
+ }
+
+}
+
+class Cache_redis_server_stub {
+
+ public $strings = array();
+ public $hashes = array();
+
+ public function incrBy($key, $offset)
+ {
+ if (isset($this->hashes[$key]) OR (isset($this->strings[$key]) && ! ctype_digit(ltrim($this->strings[$key], '-'))))
+ {
+ return FALSE;
+ }
+
+ $value = (isset($this->strings[$key]) ? (int) $this->strings[$key] : 0) + $offset;
+ $this->strings[$key] = (string) $value;
+ return $value;
+ }
+
+ public function hIncrBy($key, $field, $offset)
+ {
+ if (isset($this->strings[$key]) OR (isset($this->hashes[$key][$field]) && ! ctype_digit(ltrim($this->hashes[$key][$field], '-'))))
+ {
+ return FALSE;
+ }
+
+ $value = (isset($this->hashes[$key][$field]) ? (int) $this->hashes[$key][$field] : 0) + $offset;
+ $this->hashes[$key][$field] = (string) $value;
+ return $value;
+ }
+
+ public function hSet($key, $field, $value)
+ {
+ if (isset($this->strings[$key]))
+ {
+ return FALSE;
+ }
+
+ $this->hashes[$key][$field] = (string) $value;
+ return 1;
+ }
+
+ public function hMGet($key, $fields)
+ {
+ if (isset($this->strings[$key]))
+ {
+ return FALSE;
+ }
+
+ $values = array();
+ foreach ($fields as $field)
+ {
+ $values[$field] = isset($this->hashes[$key][$field]) ? $this->hashes[$key][$field] : FALSE;
+ }
+
+ return $values;
+ }
+
+ public function get($key)
+ {
+ if (isset($this->hashes[$key]))
+ {
+ return FALSE;
+ }
+
+ return isset($this->strings[$key]) ? $this->strings[$key] : FALSE;
+ }
+
+ public function sIsMember($key, $member)
+ {
+ return FALSE;
+ }
+
+ public function close()
+ {
+ return TRUE;
+ }
+}
From a0db28a4e25d44ec7e5ff29fcbec21204f1037f5 Mon Sep 17 00:00:00 2001
From: Bruno Moreira <5393+pocketarc@users.noreply.github.com>
Date: Sun, 1 Nov 2026 16:42:00 -0500
Subject: [PATCH 11/11] fix: Stop inline CAPTCHA images failing on PHP 5.4 and
5.5. Follow-up to #67.
---
system/helpers/captcha_helper.php | 17 +++--------------
1 file changed, 3 insertions(+), 14 deletions(-)
diff --git a/system/helpers/captcha_helper.php b/system/helpers/captcha_helper.php
index f94ea8799e..bade01724e 100644
--- a/system/helpers/captcha_helper.php
+++ b/system/helpers/captcha_helper.php
@@ -345,20 +345,9 @@ function create_captcha($data)
}
else
{
- // I don't see an easier way to get the image contents without writing to file
- $buffer = fopen('php://memory', 'wb+');
- imagepng($im, $buffer);
- rewind($buffer);
- $img_src = '';
-
- // fread() will return an empty string (not FALSE) after the entire contents are read
- while (strlen($read = fread($buffer, 4096)))
- {
- $img_src .= $read;
- }
-
- fclose($buffer);
- $img_src = 'data:image/png;base64,'.base64_encode($img_src);
+ ob_start();
+ imagepng($im);
+ $img_src = 'data:image/png;base64,'.base64_encode(ob_get_clean());
}
$img_class = trim($img_class);