From c283d1a290588d8c513d006771c1c910b424ef6e Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 25 Feb 2025 13:58:12 +0100 Subject: [PATCH 01/10] docs: add changelog and upgrade for v4.7.0 (#9464) --- user_guide_src/source/changelogs/index.rst | 1 + user_guide_src/source/changelogs/v4.7.0.rst | 84 +++++++++++++++++++ .../source/installation/upgrade_470.rst | 55 ++++++++++++ .../source/installation/upgrading.rst | 1 + 4 files changed, 141 insertions(+) create mode 100644 user_guide_src/source/changelogs/v4.7.0.rst create mode 100644 user_guide_src/source/installation/upgrade_470.rst diff --git a/user_guide_src/source/changelogs/index.rst b/user_guide_src/source/changelogs/index.rst index ee417164aa87..252d7943bdc2 100644 --- a/user_guide_src/source/changelogs/index.rst +++ b/user_guide_src/source/changelogs/index.rst @@ -12,6 +12,7 @@ See all the changes. .. toctree:: :titlesonly: + v4.7.0 v4.6.1 v4.6.0 v4.5.8 diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst new file mode 100644 index 000000000000..06d2a9a8ce39 --- /dev/null +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -0,0 +1,84 @@ +############# +Version 4.7.0 +############# + +Release Date: Unreleased + +**4.7.0 release of CodeIgniter4** + +.. contents:: + :local: + :depth: 3 + +********** +Highlights +********** + +- TBD + +******** +BREAKING +******** + +Behavior Changes +================ + +Interface Changes +================= + +Method Signature Changes +======================== + +************ +Enhancements +************ + +Commands +======== + +Testing +======= + +Database +======== + +Query Builder +------------- + +Forge +----- + +Others +------ + +Model +===== + +Libraries +========= + +Helpers and Functions +===================== + +Others +====== + +*************** +Message Changes +*************** + +******* +Changes +******* + +************ +Deprecations +************ + +********** +Bugs Fixed +********** + +See the repo's +`CHANGELOG.md `_ +for a complete list of bugs fixed. diff --git a/user_guide_src/source/installation/upgrade_470.rst b/user_guide_src/source/installation/upgrade_470.rst new file mode 100644 index 000000000000..2882870a18f4 --- /dev/null +++ b/user_guide_src/source/installation/upgrade_470.rst @@ -0,0 +1,55 @@ +############################# +Upgrading from 4.6.x to 4.7.0 +############################# + +Please refer to the upgrade instructions corresponding to your installation method. + +- :ref:`Composer Installation App Starter Upgrading ` +- :ref:`Composer Installation Adding CodeIgniter4 to an Existing Project Upgrading ` +- :ref:`Manual Installation Upgrading ` + +.. contents:: + :local: + :depth: 2 + +********************** +Mandatory File Changes +********************** + +**************** +Breaking Changes +**************** + +********************* +Breaking Enhancements +********************* + +************* +Project Files +************* + +Some files in the **project space** (root, app, public, writable) received updates. Due to +these files being outside of the **system** scope they will not be changed without your intervention. + +.. note:: There are some third-party CodeIgniter modules available to assist + with merging changes to the project space: + `Explore on Packagist `_. + +Content Changes +=============== + +The following files received significant changes (including deprecations or visual adjustments) +and it is recommended that you merge the updated versions with your application: + +Config +------ + +- @TODO + +All Changes +=========== + +This is a list of all files in the **project space** that received changes; +many will be simple comments or formatting that have no effect on the runtime: + +- @TODO diff --git a/user_guide_src/source/installation/upgrading.rst b/user_guide_src/source/installation/upgrading.rst index a2125cba81a3..6053d5a036fb 100644 --- a/user_guide_src/source/installation/upgrading.rst +++ b/user_guide_src/source/installation/upgrading.rst @@ -16,6 +16,7 @@ See also :doc:`./backward_compatibility_notes`. backward_compatibility_notes + upgrade_470 upgrade_461 upgrade_460 upgrade_458 From b1f2bee6ba3e530d5c0f1963242f2f2cbd65a4fa Mon Sep 17 00:00:00 2001 From: ip-qi <57226580+ip-qi@users.noreply.github.com> Date: Mon, 3 Mar 2025 08:08:12 +0000 Subject: [PATCH 02/10] feat: add email/smtp plain auth method (#9462) * -revert: auto corrected (composer cs-fix) comments and auto added function return types -comment: add depreceation comment for failedSMTPLogin email error message -refactor: improve authorization validation flow and email error response messages -added $SMTPAuthMethod in Config\Email file with default value of login * Update system/Email/Email.php apply suggestion for declaring string type of $SMTPAuthMethod Co-authored-by: John Paul E. Balandan, CPA * Update user_guide_src/source/libraries/email.rst Apply Suggestion for description of SMTPAuthMethod Co-authored-by: Michal Sniatala * docs: update changelog * fix: php-cs violations * Update user_guide_src/source/changelogs/v4.7.0.rst Apply suggestions for changelog Co-authored-by: Michal Sniatala --------- Co-authored-by: John Paul E. Balandan, CPA Co-authored-by: Michal Sniatala --- app/Config/Email.php | 5 ++ system/Email/Email.php | 56 ++++++++++++++++----- system/Language/en/Email.php | 42 +++++++++------- user_guide_src/source/changelogs/v4.7.0.rst | 5 ++ user_guide_src/source/libraries/email.rst | 3 +- 5 files changed, 79 insertions(+), 32 deletions(-) diff --git a/app/Config/Email.php b/app/Config/Email.php index 4dce650b32ec..77c573ad3acb 100644 --- a/app/Config/Email.php +++ b/app/Config/Email.php @@ -30,6 +30,11 @@ class Email extends BaseConfig */ public string $SMTPHost = ''; + /** + * Which SMTP authentication method to use: login, plain + */ + public string $SMTPAuthMethod = 'login'; + /** * SMTP Username */ diff --git a/system/Email/Email.php b/system/Email/Email.php index 24c3af4cd376..d5a8cc525ba1 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -279,6 +279,11 @@ class Email */ protected $SMTPAuth = false; + /** + * Which SMTP authentication method to use: login, plain + */ + protected string $SMTPAuthMethod = 'login'; + /** * Whether to send a Reply-To header * @@ -2019,45 +2024,72 @@ protected function SMTPAuthenticate() return true; } - if ($this->SMTPUser === '' && $this->SMTPPass === '') { + // If no username or password is set + if ($this->SMTPUser === '' || $this->SMTPPass === '') { $this->setErrorMessage(lang('Email.noSMTPAuth')); return false; } - $this->sendData('AUTH LOGIN'); + // normalize in case user entered capital words LOGIN/PLAIN + $this->SMTPAuthMethod = strtolower($this->SMTPAuthMethod); + + // Validate supported authentication methods + $validMethods = ['login', 'plain']; + if (! in_array($this->SMTPAuthMethod, $validMethods, true)) { + $this->setErrorMessage(lang('Email.invalidSMTPAuthMethod', [$this->SMTPAuthMethod])); + + return false; + } + + // send initial 'AUTH' command + $this->sendData('AUTH ' . strtoupper($this->SMTPAuthMethod)); $reply = $this->getSMTPData(); if (str_starts_with($reply, '503')) { // Already authenticated return true; } + // if 'AUTH' command is unsuported by the server if (! str_starts_with($reply, '334')) { - $this->setErrorMessage(lang('Email.failedSMTPLogin', [$reply])); + $this->setErrorMessage(lang('Email.failureSMTPAuthMethod', [strtoupper($this->SMTPAuthMethod)])); return false; } - $this->sendData(base64_encode($this->SMTPUser)); - $reply = $this->getSMTPData(); + switch ($this->SMTPAuthMethod) { + case 'login': + $this->sendData(base64_encode($this->SMTPUser)); + $reply = $this->getSMTPData(); - if (! str_starts_with($reply, '334')) { - $this->setErrorMessage(lang('Email.SMTPAuthUsername', [$reply])); + if (! str_starts_with($reply, '334')) { + $this->setErrorMessage(lang('Email.SMTPAuthUsername', [$reply])); - return false; + return false; + } + + $this->sendData(base64_encode($this->SMTPPass)); + break; + + case 'plain': + // send credentials as the single second command + $authString = "\0" . $this->SMTPUser . "\0" . $this->SMTPPass; + + $this->sendData(base64_encode($authString)); + break; } - $this->sendData(base64_encode($this->SMTPPass)); $reply = $this->getSMTPData(); + if (! str_starts_with($reply, '235')) { // Authentication failed + $errorMessage = $this->SMTPAuthMethod === 'plain' ? 'Email.SMTPAuthCredentials' : 'Email.SMTPAuthPassword'; - if (! str_starts_with($reply, '235')) { - $this->setErrorMessage(lang('Email.SMTPAuthPassword', [$reply])); + $this->setErrorMessage(lang($errorMessage, [$reply])); return false; } if ($this->SMTPKeepAlive) { - $this->SMTPAuth = false; + $this->SMTPAuth = false; // Prevent re-authentication for keep-alive sessions } return true; diff --git a/system/Language/en/Email.php b/system/Language/en/Email.php index 23a97a75f23c..44d4c03cae3e 100644 --- a/system/Language/en/Email.php +++ b/system/Language/en/Email.php @@ -13,23 +13,27 @@ // Email language settings return [ - 'mustBeArray' => 'The email validation method must be passed an array.', - 'invalidAddress' => 'Invalid email address: "{0}"', - 'attachmentMissing' => 'Unable to locate the following email attachment: "{0}"', - 'attachmentUnreadable' => 'Unable to open this attachment: "{0}"', - 'noFrom' => 'Cannot send mail with no "From" header.', - 'noRecipients' => 'You must include recipients: To, Cc, or Bcc', - '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 Sendmail. Your server might not be configured to send mail using this method.', - 'sendFailureSmtp' => 'Unable to send email using 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}', - '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}', - 'noSMTPAuth' => 'Error: You must assign an SMTP username and password.', - 'failedSMTPLogin' => 'Failed to send AUTH LOGIN command. Error: {0}', - 'SMTPAuthUsername' => 'Failed to authenticate username. Error: {0}', - 'SMTPAuthPassword' => 'Failed to authenticate password. Error: {0}', - 'SMTPDataFailure' => 'Unable to send data: {0}', - 'exitStatus' => 'Exit status code: {0}', + 'mustBeArray' => 'The email validation method must be passed an array.', + 'invalidAddress' => 'Invalid email address: "{0}"', + 'attachmentMissing' => 'Unable to locate the following email attachment: "{0}"', + 'attachmentUnreadable' => 'Unable to open this attachment: "{0}"', + 'noFrom' => 'Cannot send mail with no "From" header.', + 'noRecipients' => 'You must include recipients: To, Cc, or Bcc', + '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 Sendmail. Your server might not be configured to send mail using this method.', + 'sendFailureSmtp' => 'Unable to send email using 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}', + '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}', + 'noSMTPAuth' => 'Error: You must assign an SMTP username and password.', + 'invalidSMTPAuthMethod' => 'Error: SMTP authorization method "{0}" is not supported in codeigniter, set either "login" or "plain" authorization method', + 'failureSMTPAuthMethod' => 'Unable to initiate AUTH command. Your server might not be configured to use AUTH {0} authentication method.', + 'SMTPAuthCredentials' => 'Failed to authenticate user credentials. Error: {0}', + 'SMTPAuthUsername' => 'Failed to authenticate username. Error: {0}', + 'SMTPAuthPassword' => 'Failed to authenticate password. Error: {0}', + 'SMTPDataFailure' => 'Unable to send data: {0}', + 'exitStatus' => 'Exit status code: {0}', + // @deprecated + 'failedSMTPLogin' => 'Failed to send AUTH LOGIN command. Error: {0}', ]; diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 06d2a9a8ce39..ae6797af7884 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -57,6 +57,8 @@ Model Libraries ========= +**Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option + Helpers and Functions ===================== @@ -67,6 +69,9 @@ Others Message Changes *************** +- Added ``Email.invalidSMTPAuthMethod`` and ``Email.failureSMTPAuthMethod`` +- Deprecated ``Email.failedSMTPLogin`` + ******* Changes ******* diff --git a/user_guide_src/source/libraries/email.rst b/user_guide_src/source/libraries/email.rst index ec21b79ddcf2..b89d764e50f9 100644 --- a/user_guide_src/source/libraries/email.rst +++ b/user_guide_src/source/libraries/email.rst @@ -39,7 +39,7 @@ Here is a basic example demonstrating how you might send email: Setting Email Preferences ========================= -There are 21 different preferences available to tailor how your email +There are 22 different preferences available to tailor how your email messages are sent. You can either set them manually as described here, or automatically via preferences stored in your config file, described in `Email Preferences`_. @@ -120,6 +120,7 @@ Preference Default Value Options Description or ``smtp`` **mailPath** /usr/sbin/sendmail The server path to Sendmail. **SMTPHost** SMTP Server Hostname. +**SMTPAuthMethod** login ``login``, ``plain`` SMTP Authentication Method. (Available since 4.7.0) **SMTPUser** SMTP Username. **SMTPPass** SMTP Password. **SMTPPort** 25 SMTP Port. (If set to ``465``, TLS will be used for the connection From e15078c6622d03b1ed27eced62f5c8f9d9f80374 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Tue, 22 Apr 2025 08:46:48 +0200 Subject: [PATCH 03/10] feat: rewrite `ImageMagickHandler` to rely solely on the PHP `imagick` extension (#9526) * feat: rewrite ImageMagickHandler to rely solely on the PHP imagick extension * apply the code suggestions from the review Co-authored-by: John Paul E. Balandan, CPA --------- Co-authored-by: John Paul E. Balandan, CPA --- app/Config/Images.php | 2 + system/Images/Exceptions/ImageException.php | 2 + system/Images/Handlers/ImageMagickHandler.php | 517 ++++++++++-------- system/Language/en/Images.php | 4 +- .../system/Images/ImageMagickHandlerTest.php | 62 +-- user_guide_src/source/changelogs/v4.7.0.rst | 9 +- user_guide_src/source/libraries/images.rst | 6 - utils/phpstan-baseline/argument.type.neon | 7 +- utils/phpstan-baseline/empty.notAllowed.neon | 7 +- utils/phpstan-baseline/loader.neon | 2 +- .../method.childReturnType.neon | 7 +- .../missingType.iterableValue.neon | 7 +- .../nullCoalesce.property.neon | 12 +- .../phpstan-baseline/property.phpDocType.neon | 2 +- 14 files changed, 325 insertions(+), 321 deletions(-) diff --git a/app/Config/Images.php b/app/Config/Images.php index a33ddadb9a5c..68e415e049e0 100644 --- a/app/Config/Images.php +++ b/app/Config/Images.php @@ -16,6 +16,8 @@ class Images extends BaseConfig /** * The path to the image library. * Required for ImageMagick, GraphicsMagick, or NetPBM. + * + * @deprecated 4.7.0 No longer used. */ public string $libraryPath = '/usr/local/bin/convert'; diff --git a/system/Images/Exceptions/ImageException.php b/system/Images/Exceptions/ImageException.php index 91bf416d20ac..46651fbf706d 100644 --- a/system/Images/Exceptions/ImageException.php +++ b/system/Images/Exceptions/ImageException.php @@ -100,6 +100,8 @@ public static function forSaveFailed() /** * Thrown when the image library path is invalid. * + * @deprecated 4.7.0 No longer used. + * * @return static */ public static function forInvalidImageLibraryPath(?string $path = null) diff --git a/system/Images/Handlers/ImageMagickHandler.php b/system/Images/Handlers/ImageMagickHandler.php index f1448efa7f8a..08886937a4b1 100644 --- a/system/Images/Handlers/ImageMagickHandler.php +++ b/system/Images/Handlers/ImageMagickHandler.php @@ -13,27 +13,30 @@ namespace CodeIgniter\Images\Handlers; -use CodeIgniter\I18n\Time; use CodeIgniter\Images\Exceptions\ImageException; use Config\Images; -use Exception; use Imagick; +use ImagickDraw; +use ImagickDrawException; +use ImagickException; +use ImagickPixel; +use ImagickPixelException; /** - * Class ImageMagickHandler - * - * FIXME - This needs conversion & unit testing, to use the imagick extension + * Image handler for Imagick extension. */ class ImageMagickHandler extends BaseHandler { /** - * Stores image resource in memory. + * Stores Imagick instance. * - * @var string|null + * @var Imagick|null */ protected $resource; /** + * Constructor. + * * @param Images $config * * @throws ImageException @@ -42,25 +45,95 @@ public function __construct($config = null) { parent::__construct($config); - if (! extension_loaded('imagick') && ! class_exists(Imagick::class)) { - throw ImageException::forMissingExtension('IMAGICK'); // @codeCoverageIgnore + if (! extension_loaded('imagick')) { + throw ImageException::forMissingExtension('IMAGICK'); // @codeCoverageIgnore } + } - $cmd = $this->config->libraryPath; + /** + * Loads the image for manipulation. + * + * @return void + * + * @throws ImageException + */ + protected function ensureResource() + { + if (! $this->resource instanceof Imagick) { + // Verify that we have a valid image + $this->image(); - if ($cmd === '') { - throw ImageException::forInvalidImageLibraryPath($cmd); - } + try { + $this->resource = new Imagick(); + $this->resource->readImage($this->image()->getPathname()); - if (preg_match('/convert$/i', $cmd) !== 1) { - $cmd = rtrim($cmd, '\/') . '/convert'; + // Check for valid image + if ($this->resource->getImageWidth() === 0 || $this->resource->getImageHeight() === 0) { + throw ImageException::forInvalidImageCreate($this->image()->getPathname()); + } - $this->config->libraryPath = $cmd; + $this->supportedFormatCheck(); + } catch (ImagickException $e) { + throw ImageException::forInvalidImageCreate($e->getMessage()); + } } + } + + /** + * Handles all the grunt work of resizing, etc. + * + * @param string $action Type of action to perform + * @param int $quality Quality setting for Imagick operations + * + * @return $this + * + * @throws ImageException + */ + protected function process(string $action, int $quality = 100) + { + $this->image(); + + $this->ensureResource(); + + try { + switch ($action) { + case 'resize': + $this->resource->resizeImage( + $this->width, + $this->height, + Imagick::FILTER_LANCZOS, + 0, + ); + break; - if (! is_file($cmd)) { - throw ImageException::forInvalidImageLibraryPath($cmd); + case 'crop': + $width = $this->width; + $height = $this->height; + $xAxis = $this->xAxis ?? 0; + $yAxis = $this->yAxis ?? 0; + + $this->resource->cropImage( + $width, + $height, + $xAxis, + $yAxis, + ); + + // Reset canvas to cropped size + $this->resource->setImagePage(0, 0, 0, 0); + break; + } + + // Handle transparency for supported image types + if (in_array($this->image()->imageType, $this->supportTransparency, true) + && $this->resource->getImageAlphaChannel() === Imagick::ALPHACHANNEL_UNDEFINED) { + $this->resource->setImageAlphaChannel(Imagick::ALPHACHANNEL_OPAQUE); + } + } catch (ImagickException) { + throw ImageException::forImageProcessFailed(); } + + return $this; } /** @@ -68,50 +141,56 @@ public function __construct($config = null) * * @return ImageMagickHandler * - * @throws Exception + * @throws ImagickException */ public function _resize(bool $maintainRatio = false) { - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); + if ($maintainRatio) { + // If maintaining a ratio, we need a custom approach + $this->ensureResource(); - $escape = '\\'; + // Use thumbnailImage which preserves an aspect ratio + $this->resource->thumbnailImage($this->width, $this->height, true); - if (PHP_OS_FAMILY === 'Windows') { - $escape = ''; + return $this; } - $action = $maintainRatio - ? ' -resize ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . ' "' . $source . '" "' . $destination . '"' - : ' -resize ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . "{$escape}! \"" . $source . '" "' . $destination . '"'; - - $this->process($action); - - return $this; + // Use the common process() method for normal resizing + return $this->process('resize'); } /** * Crops the image. * - * @return bool|ImageMagickHandler + * @return $this * - * @throws Exception + * @throws ImagickException */ public function _crop() { - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); + // Use the common process() method for cropping + $result = $this->process('crop'); - $extent = ' '; - if ($this->xAxis >= $this->width || $this->yAxis > $this->height) { - $extent = ' -background transparent -extent ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . ' '; - } + // Handle a case where crop dimensions exceed the original image size + if ($this->resource instanceof Imagick) { + $imgWidth = $this->resource->getImageWidth(); + $imgHeight = $this->resource->getImageHeight(); - $action = ' -crop ' . ($this->width ?? 0) . 'x' . ($this->height ?? 0) . '+' . ($this->xAxis ?? 0) . '+' . ($this->yAxis ?? 0) . $extent . escapeshellarg($source) . ' ' . escapeshellarg($destination); + if ($this->xAxis >= $imgWidth || $this->yAxis >= $imgHeight) { + // Create transparent background + $background = new Imagick(); + $background->newImage($this->width, $this->height, new ImagickPixel('transparent')); + $background->setImageFormat($this->resource->getImageFormat()); - $this->process($action); + // Composite our image on the background + $background->compositeImage($this->resource, Imagick::COMPOSITE_OVER, 0, 0); - return $this; + // Replace our resource + $this->resource = $background; + } + } + + return $result; } /** @@ -120,18 +199,18 @@ public function _crop() * * @return $this * - * @throws Exception + * @throws ImagickException */ protected function _rotate(int $angle) { - $angle = '-rotate ' . $angle; - - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); + $this->ensureResource(); - $action = ' ' . $angle . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination); + // Create transparent background + $this->resource->setImageBackgroundColor(new ImagickPixel('transparent')); + $this->resource->rotateImage(new ImagickPixel('transparent'), $angle); - $this->process($action); + // Reset canvas dimensions + $this->resource->setImagePage($this->resource->getImageWidth(), $this->resource->getImageHeight(), 0, 0); return $this; } @@ -141,88 +220,100 @@ protected function _rotate(int $angle) * * @return $this * - * @throws Exception + * @throws ImagickException|ImagickPixelException */ protected function _flatten(int $red = 255, int $green = 255, int $blue = 255) { - $flatten = "-background 'rgb({$red},{$green},{$blue})' -flatten"; - - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); - - $action = ' ' . $flatten . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination); + $this->ensureResource(); - $this->process($action); + // Create background + $bg = new ImagickPixel("rgb({$red},{$green},{$blue})"); + + // Create a new canvas with the background color + $canvas = new Imagick(); + $canvas->newImage( + $this->resource->getImageWidth(), + $this->resource->getImageHeight(), + $bg, + $this->resource->getImageFormat(), + ); + + // Composite our image on the background + $canvas->compositeImage( + $this->resource, + Imagick::COMPOSITE_OVER, + 0, + 0, + ); + + // Replace our resource with the flattened version + $this->resource->clear(); + $this->resource = $canvas; return $this; } /** - * Flips an image along it's vertical or horizontal axis. + * Flips an image along its vertical or horizontal axis. * * @return $this * - * @throws Exception + * @throws ImagickException */ protected function _flip(string $direction) { - $angle = $direction === 'horizontal' ? '-flop' : '-flip'; - - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); - - $action = ' ' . $angle . ' ' . escapeshellarg($source) . ' ' . escapeshellarg($destination); + $this->ensureResource(); - $this->process($action); + if ($direction === 'horizontal') { + $this->resource->flopImage(); + } else { + $this->resource->flipImage(); + } return $this; } /** - * Get driver version + * Get a driver version + * + * @return string */ - public function getVersion(): string + public function getVersion() { - $versionString = $this->process('-version')[0]; - preg_match('/ImageMagick\s(?P[\S]+)/', $versionString, $matches); + $version = Imagick::getVersion(); + + if (preg_match('/ImageMagick\s+(\d+\.\d+\.\d+)/', $version['versionString'], $matches)) { + return $matches[1]; + } - return $matches['version']; + return ''; } /** - * Handles all of the grunt work of resizing, etc. + * Check if a given image format is supported * - * @return array Lines of output from shell command + * @return void * - * @throws Exception + * @throws ImageException */ - protected function process(string $action, int $quality = 100): array + protected function supportedFormatCheck() { - if ($action !== '-version') { - $this->supportedFormatCheck(); - } - - $cmd = $this->config->libraryPath; - $cmd .= $action === '-version' ? ' ' . $action : ' -quality ' . $quality . ' ' . $action; - - $retval = 1; - $output = []; - // exec() might be disabled - if (function_usable('exec')) { - @exec($cmd, $output, $retval); + if (! $this->resource instanceof Imagick) { + return; } - // Did it work? - if ($retval > 0) { - throw ImageException::forImageProcessFailed(); + switch ($this->image()->imageType) { + case IMAGETYPE_WEBP: + if (! in_array('WEBP', Imagick::queryFormats(), true)) { + throw ImageException::forInvalidImageCreate(lang('images.webpNotSupported')); + } + break; } - - return $output; } /** - * Saves any changes that have been made to file. If no new filename is - * provided, the existing image is overwritten, otherwise a copy of the + * Saves any changes that have been made to the file. If no new filename is + * provided, the existing image is overwritten; otherwise a copy of the * file is made at $target. * * Example: @@ -230,6 +321,8 @@ protected function process(string $action, int $quality = 100): array * ->save(); * * @param non-empty-string|null $target + * + * @throws ImagickException */ public function save(?string $target = null, int $quality = 90): bool { @@ -238,7 +331,7 @@ public function save(?string $target = null, int $quality = 90): bool // If no new resource has been created, then we're // simply copy the existing one. - if (empty($this->resource) && $quality === 100) { + if (! $this->resource instanceof Imagick && $quality === 100) { if ($original === null) { return true; } @@ -251,192 +344,172 @@ public function save(?string $target = null, int $quality = 90): bool $this->ensureResource(); - // Copy the file through ImageMagick so that it has - // a chance to convert file format. - $action = escapeshellarg($this->resource) . ' ' . escapeshellarg($target); - - $this->process($action, $quality); - - unlink($this->resource); - - return true; - } + $this->resource->setImageCompressionQuality($quality); - /** - * Get Image Resource - * - * This simply creates an image resource handle - * based on the type of image being processed. - * Since ImageMagick is used on the cli, we need to - * ensure we have a temporary file on the server - * that we can use. - * - * To ensure we can use all features, like transparency, - * during the process, we'll use a PNG as the temp file type. - * - * @return string - * - * @throws Exception - */ - protected function getResourcePath() - { - if ($this->resource !== null) { - return $this->resource; + if ($target !== null) { + $extension = pathinfo($target, PATHINFO_EXTENSION); + $this->resource->setImageFormat($extension); } - $this->resource = WRITEPATH . 'cache/' . Time::now()->getTimestamp() . '_' . bin2hex(random_bytes(10)) . '.png'; + try { + $result = $this->resource->writeImage($target); - $name = basename($this->resource); - $path = pathinfo($this->resource, PATHINFO_DIRNAME); + chmod($target, $this->filePermissions); - $this->image()->copy($path, $name); + $this->resource->clear(); + $this->resource = null; - return $this->resource; - } - - /** - * Make the image resource object if needed - * - * @return void - * - * @throws Exception - */ - protected function ensureResource() - { - $this->getResourcePath(); - - $this->supportedFormatCheck(); - } - - /** - * Check if given image format is supported - * - * @return void - * - * @throws ImageException - */ - protected function supportedFormatCheck() - { - switch ($this->image()->imageType) { - case IMAGETYPE_WEBP: - if (! in_array('WEBP', Imagick::queryFormats(), true)) { - throw ImageException::forInvalidImageCreate(lang('images.webpNotSupported')); - } - break; + return $result; + } catch (ImagickException) { + throw ImageException::forSaveFailed(); } } /** * Handler-specific method for overlaying text on an image. * - * @throws Exception + * @throws ImagickDrawException|ImagickException|ImagickPixelException */ protected function _text(string $text, array $options = []) { - $xAxis = 0; - $yAxis = 0; - $gravity = ''; - $cmd = ''; - - // Reverse the vertical offset - // When the image is positioned at the bottom - // we don't want the vertical offset to push it - // further down. We want the reverse, so we'll - // invert the offset. Note: The horizontal - // offset flips itself automatically - if ($options['vAlign'] === 'bottom') { - $options['vOffset'] *= -1; + $this->ensureResource(); + + $draw = new ImagickDraw(); + + if (isset($options['fontPath'])) { + $draw->setFont($options['fontPath']); } - if ($options['hAlign'] === 'right') { - $options['hOffset'] *= -1; + if (isset($options['fontSize'])) { + $draw->setFontSize($options['fontSize']); } - // Font - if (! empty($options['fontPath'])) { - $cmd .= " -font '{$options['fontPath']}'"; + if (isset($options['color'])) { + $color = $options['color']; + + // Shorthand hex, #f00 + if (strlen($color) === 3) { + $color = implode('', array_map(str_repeat(...), str_split($color), [2, 2, 2])); + } + + [$r, $g, $b] = sscanf("#{$color}", '#%02x%02x%02x'); + $opacity = $options['opacity'] ?? 1.0; + $draw->setFillColor(new ImagickPixel("rgba({$r},{$g},{$b},{$opacity})")); } - if (isset($options['hAlign'], $options['vAlign'])) { + // Calculate text positioning + $imgWidth = $this->resource->getImageWidth(); + $imgHeight = $this->resource->getImageHeight(); + $xAxis = 0; + $yAxis = 0; + + // Default padding + $padding = $options['padding'] ?? 0; + + if (isset($options['hAlign'])) { + $hOffset = $options['hOffset'] ?? 0; + switch ($options['hAlign']) { case 'left': - $xAxis = $options['hOffset'] + $options['padding']; - $yAxis = $options['vOffset'] + $options['padding']; - $gravity = $options['vAlign'] === 'top' ? 'NorthWest' : 'West'; - if ($options['vAlign'] === 'bottom') { - $gravity = 'SouthWest'; - $yAxis = $options['vOffset'] - $options['padding']; - } + $xAxis = $hOffset + $padding; + $draw->setTextAlignment(Imagick::ALIGN_LEFT); break; case 'center': - $xAxis = $options['hOffset'] + $options['padding']; - $yAxis = $options['vOffset'] + $options['padding']; - $gravity = $options['vAlign'] === 'top' ? 'North' : 'Center'; - if ($options['vAlign'] === 'bottom') { - $yAxis = $options['vOffset'] - $options['padding']; - $gravity = 'South'; - } + $xAxis = $imgWidth / 2 + $hOffset; + $draw->setTextAlignment(Imagick::ALIGN_CENTER); break; case 'right': - $xAxis = $options['hOffset'] - $options['padding']; - $yAxis = $options['vOffset'] + $options['padding']; - $gravity = $options['vAlign'] === 'top' ? 'NorthEast' : 'East'; - if ($options['vAlign'] === 'bottom') { - $gravity = 'SouthEast'; - $yAxis = $options['vOffset'] - $options['padding']; - } + $xAxis = $imgWidth - $hOffset - $padding; + $draw->setTextAlignment(Imagick::ALIGN_RIGHT); break; } + } - $xAxis = $xAxis >= 0 ? '+' . $xAxis : $xAxis; - $yAxis = $yAxis >= 0 ? '+' . $yAxis : $yAxis; + if (isset($options['vAlign'])) { + $vOffset = $options['vOffset'] ?? 0; - $cmd .= " -gravity {$gravity} -geometry {$xAxis}{$yAxis}"; - } + switch ($options['vAlign']) { + case 'top': + $yAxis = $vOffset + $padding + ($options['fontSize'] ?? 16); + break; - // Color - if (isset($options['color'])) { - [$r, $g, $b] = sscanf("#{$options['color']}", '#%02x%02x%02x'); + case 'middle': + $yAxis = $imgHeight / 2 + $vOffset; + break; - $cmd .= " -fill 'rgba({$r},{$g},{$b},{$options['opacity']})'"; + case 'bottom': + // Note: Vertical offset is inverted for bottom alignment as per original implementation + $yAxis = $vOffset < 0 ? $imgHeight + $vOffset - $padding : $imgHeight - $vOffset - $padding; + break; + } } - // Font Size - use points.... - if (isset($options['fontSize'])) { - $cmd .= " -pointsize {$options['fontSize']}"; - } + if (isset($options['withShadow'])) { + $shadow = clone $draw; + + if (isset($options['shadowColor'])) { + $shadowColor = $options['shadowColor']; - // Text - $cmd .= " -annotate 0 '{$text}'"; + // Shorthand hex, #f00 + if (strlen($shadowColor) === 3) { + $shadowColor = implode('', array_map(str_repeat(...), str_split($shadowColor), [2, 2, 2])); + } - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); + [$sr, $sg, $sb] = sscanf("#{$shadowColor}", '#%02x%02x%02x'); + $shadow->setFillColor(new ImagickPixel("rgb({$sr},{$sg},{$sb})")); + } else { + $shadow->setFillColor(new ImagickPixel('rgba(0,0,0,0.5)')); + } - $cmd = " '{$source}' {$cmd} '{$destination}'"; + $offset = $options['shadowOffset'] ?? 3; - $this->process($cmd); + $this->resource->annotateImage( + $shadow, + $xAxis + $offset, + $yAxis + $offset, + 0, + $text, + ); + } + + // Draw the main text + $this->resource->annotateImage( + $draw, + $xAxis, + $yAxis, + 0, + $text, + ); } /** * Return the width of an image. * * @return int + * + * @throws ImagickException */ public function _getWidth() { - return imagesx(imagecreatefromstring(file_get_contents($this->resource))); + $this->ensureResource(); + + return $this->resource->getImageWidth(); } /** * Return the height of an image. * * @return int + * + * @throws ImagickException */ public function _getHeight() { - return imagesy(imagecreatefromstring(file_get_contents($this->resource))); + $this->ensureResource(); + + return $this->resource->getImageHeight(); } /** diff --git a/system/Language/en/Images.php b/system/Language/en/Images.php index 41dbe973aaa5..cdfe9ccbb181 100644 --- a/system/Language/en/Images.php +++ b/system/Language/en/Images.php @@ -24,7 +24,6 @@ '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}"', '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.', @@ -33,4 +32,7 @@ 'saveFailed' => 'Unable to save the image. Please make sure the image and file directory are writable.', 'invalidDirection' => 'Flip direction can be only "vertical" or "horizontal". Given: "{0}"', 'exifNotSupported' => 'Reading EXIF data is not supported by this PHP installation.', + + // @deprecated + 'libPathInvalid' => 'The path to your image library is not correct. Please set the correct path in your image preferences. "{0}"', ]; diff --git a/tests/system/Images/ImageMagickHandlerTest.php b/tests/system/Images/ImageMagickHandlerTest.php index 14cb4bc57980..924095253e48 100644 --- a/tests/system/Images/ImageMagickHandlerTest.php +++ b/tests/system/Images/ImageMagickHandlerTest.php @@ -16,11 +16,9 @@ use CodeIgniter\Config\Services; use CodeIgniter\Images\Exceptions\ImageException; use CodeIgniter\Images\Handlers\BaseHandler; -use CodeIgniter\Images\Handlers\ImageMagickHandler; use CodeIgniter\Test\CIUnitTestCase; use Config\Images; use Imagick; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresPhpExtension; @@ -46,6 +44,10 @@ final class ImageMagickHandlerTest extends CIUnitTestCase protected function setUp(): void { + if (! extension_loaded('imagick')) { + $this->markTestSkipped('The IMAGICK extension is not available.'); + } + $this->root = WRITEPATH . 'cache/'; // cleanup everything @@ -58,54 +60,10 @@ protected function setUp(): void $this->path = $this->origin . 'ci-logo.png'; // get our locally available `convert` - $config = new Images(); - $found = false; - - foreach ([ - '/usr/bin/convert', - trim((string) shell_exec('which convert')), - $config->libraryPath, - ] as $convert) { - if (is_file($convert)) { - $config->libraryPath = $convert; - - $found = true; - break; - } - } - - if (! $found) { - $this->markTestSkipped('Cannot test imagick as there is no available convert program.'); - } - + $config = new Images(); $this->handler = Services::image('imagick', $config, false); } - #[DataProvider('provideNonexistentLibraryPathTerminatesProcessing')] - public function testNonexistentLibraryPathTerminatesProcessing(string $path, string $invalidPath): void - { - $this->expectException(ImageException::class); - $this->expectExceptionMessage(lang('Images.libPathInvalid', [$invalidPath])); - - $config = new Images(); - - $config->libraryPath = $path; - - new ImageMagickHandler($config); - } - - /** - * @return iterable> - */ - public static function provideNonexistentLibraryPathTerminatesProcessing(): iterable - { - yield 'empty string' => ['', '']; - - yield 'invalid file' => ['/var/log/convert', '/var/log/convert']; - - yield 'nonexistent file' => ['/var/www/file', '/var/www/file/convert']; - } - public function testGetVersion(): void { $version = $this->handler->getVersion(); @@ -458,13 +416,12 @@ public function testImageReorientLandscape(): void $this->handler->withFile($source); $this->handler->reorient(); + $this->handler->save($result); - $resource = imagecreatefromstring(file_get_contents($this->handler->getResource())); + $resource = imagecreatefromjpeg($result); $point = imagecolorat($resource, 0, 0); $rgb = imagecolorsforindex($resource, $point); - $this->handler->save($result); - $this->assertSame(['red' => 62, 'green' => 62, 'blue' => 62, 'alpha' => 0], $rgb); } } @@ -477,13 +434,12 @@ public function testImageReorientPortrait(): void $this->handler->withFile($source); $this->handler->reorient(); + $this->handler->save($result); - $resource = imagecreatefromstring(file_get_contents($this->handler->getResource())); + $resource = imagecreatefromjpeg($result); $point = imagecolorat($resource, 0, 0); $rgb = imagecolorsforindex($resource, $point); - $this->handler->save($result); - $this->assertSame(['red' => 62, 'green' => 62, 'blue' => 62, 'alpha' => 0], $rgb); } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index ae6797af7884..eb12d62068cb 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -57,7 +57,8 @@ Model Libraries ========= -**Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option +**Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. +**Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. Helpers and Functions ===================== @@ -70,7 +71,7 @@ Message Changes *************** - Added ``Email.invalidSMTPAuthMethod`` and ``Email.failureSMTPAuthMethod`` -- Deprecated ``Email.failedSMTPLogin`` +- Deprecated ``Email.failedSMTPLogin`` and ``Image.libPathInvalid`` ******* Changes @@ -80,6 +81,10 @@ Changes Deprecations ************ +- **Image:** + - The config property ``Config\Image::libraryPath`` has been deprecated. No longer used. + - The exception method ``CodeIgniter\Images\Exceptions\ImageException::forInvalidImageLibraryPath`` has been deprecated. No longer used. + ********** Bugs Fixed ********** diff --git a/user_guide_src/source/libraries/images.rst b/user_guide_src/source/libraries/images.rst index a439c3321a0b..f263eb173d06 100644 --- a/user_guide_src/source/libraries/images.rst +++ b/user_guide_src/source/libraries/images.rst @@ -36,9 +36,6 @@ The available Handlers are as follows: - ``gd`` The GD/GD2 image library - ``imagick`` The ImageMagick library. -If using the ImageMagick library, you must set the path to the library on your -server in **app/Config/Images.php**. - .. note:: The ImageMagick handler requires the imagick extension. ******************* @@ -263,6 +260,3 @@ The possible options that are recognized are as follows: - ``vOffset`` Additional offset on the y axis, in pixels - ``fontPath`` The full server path to the TTF font you wish to use. System font will be used if none is given. - ``fontSize`` The font size to use. When using the GD handler with the system font, valid values are between ``1`` to ``5``. - -.. note:: The ImageMagick driver does not recognize full server path for fontPath. Instead, simply provide the - name of one of the installed system fonts that you wish to use, i.e., Calibri. diff --git a/utils/phpstan-baseline/argument.type.neon b/utils/phpstan-baseline/argument.type.neon index 6e6aa7de51fc..96d04deeddc6 100644 --- a/utils/phpstan-baseline/argument.type.neon +++ b/utils/phpstan-baseline/argument.type.neon @@ -1,4 +1,4 @@ -# total 146 errors +# total 144 errors parameters: ignoreErrors: @@ -212,11 +212,6 @@ parameters: count: 2 path: ../../tests/system/Images/GDHandlerTest.php - - - message: '#^Parameter \#1 \$filename of function file_get_contents expects string, resource given\.$#' - count: 2 - path: ../../tests/system/Images/ImageMagickHandlerTest.php - - message: '#^Parameter \#2 \$message of method CodeIgniter\\Log\\Handlers\\ChromeLoggerHandler\:\:handle\(\) expects string, stdClass given\.$#' count: 1 diff --git a/utils/phpstan-baseline/empty.notAllowed.neon b/utils/phpstan-baseline/empty.notAllowed.neon index ea06afa8b720..f9d874b1855f 100644 --- a/utils/phpstan-baseline/empty.notAllowed.neon +++ b/utils/phpstan-baseline/empty.notAllowed.neon @@ -1,4 +1,4 @@ -# total 251 errors +# total 243 errors parameters: ignoreErrors: @@ -282,11 +282,6 @@ parameters: count: 2 path: ../../system/Images/Handlers/BaseHandler.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - count: 8 - path: ../../system/Images/Handlers/ImageMagickHandler.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' count: 1 diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index 316b3203b5a8..1f7ef749cff2 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 3334 errors +# total 3314 errors includes: - argument.type.neon - assign.propertyType.neon diff --git a/utils/phpstan-baseline/method.childReturnType.neon b/utils/phpstan-baseline/method.childReturnType.neon index f66d38d4d6e4..e1e241985912 100644 --- a/utils/phpstan-baseline/method.childReturnType.neon +++ b/utils/phpstan-baseline/method.childReturnType.neon @@ -1,4 +1,4 @@ -# total 37 errors +# total 36 errors parameters: ignoreErrors: @@ -142,11 +142,6 @@ parameters: count: 1 path: ../../system/Images/Handlers/ImageMagickHandler.php - - - message: '#^Return type \(bool\|CodeIgniter\\Images\\Handlers\\ImageMagickHandler\) of method CodeIgniter\\Images\\Handlers\\ImageMagickHandler\:\:_crop\(\) should be covariant with return type \(\$this\(CodeIgniter\\Images\\Handlers\\BaseHandler\)\) of method CodeIgniter\\Images\\Handlers\\BaseHandler\:\:_crop\(\)$#' - count: 1 - path: ../../system/Images/Handlers/ImageMagickHandler.php - - message: '#^Return type \(array\|bool\|float\|int\|object\|string\|null\) of method CodeIgniter\\Model\:\:__call\(\) should be covariant with return type \(\$this\(CodeIgniter\\BaseModel\)\|null\) of method CodeIgniter\\BaseModel\:\:__call\(\)$#' count: 1 diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 5dfa442af4ab..9d13bf8e6299 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1582 errors +# total 1581 errors parameters: ignoreErrors: @@ -4532,11 +4532,6 @@ parameters: count: 1 path: ../../system/Images/Handlers/BaseHandler.php - - - message: '#^Method CodeIgniter\\Images\\Handlers\\ImageMagickHandler\:\:process\(\) return type has no value type specified in iterable type array\.$#' - count: 1 - path: ../../system/Images/Handlers/ImageMagickHandler.php - - message: '#^Method CodeIgniter\\Images\\Image\:\:getProperties\(\) return type has no value type specified in iterable type array\.$#' count: 1 diff --git a/utils/phpstan-baseline/nullCoalesce.property.neon b/utils/phpstan-baseline/nullCoalesce.property.neon index 6caf5f23ef1d..05d64f32e0a0 100644 --- a/utils/phpstan-baseline/nullCoalesce.property.neon +++ b/utils/phpstan-baseline/nullCoalesce.property.neon @@ -1,4 +1,4 @@ -# total 20 errors +# total 12 errors parameters: ignoreErrors: @@ -27,16 +27,6 @@ parameters: count: 1 path: ../../system/HTTP/URI.php - - - message: '#^Property CodeIgniter\\Images\\Handlers\\BaseHandler\:\:\$height \(int\) on left side of \?\? is not nullable\.$#' - count: 4 - path: ../../system/Images/Handlers/ImageMagickHandler.php - - - - message: '#^Property CodeIgniter\\Images\\Handlers\\BaseHandler\:\:\$width \(int\) on left side of \?\? is not nullable\.$#' - count: 4 - path: ../../system/Images/Handlers/ImageMagickHandler.php - - message: '#^Property CodeIgniter\\Throttle\\Throttler\:\:\$testTime \(int\) on left side of \?\? is not nullable\.$#' count: 1 diff --git a/utils/phpstan-baseline/property.phpDocType.neon b/utils/phpstan-baseline/property.phpDocType.neon index dda215c49436..6e00157b9feb 100644 --- a/utils/phpstan-baseline/property.phpDocType.neon +++ b/utils/phpstan-baseline/property.phpDocType.neon @@ -163,7 +163,7 @@ parameters: path: ../../system/HTTP/IncomingRequest.php - - message: '#^PHPDoc type string\|null of property CodeIgniter\\Images\\Handlers\\ImageMagickHandler\:\:\$resource is not the same as PHPDoc type resource\|null of overridden property CodeIgniter\\Images\\Handlers\\BaseHandler\:\:\$resource\.$#' + message: '#^PHPDoc type Imagick\|null of property CodeIgniter\\Images\\Handlers\\ImageMagickHandler\:\:\$resource is not the same as PHPDoc type resource\|null of overridden property CodeIgniter\\Images\\Handlers\\BaseHandler\:\:\$resource\.$#' count: 1 path: ../../system/Images/Handlers/ImageMagickHandler.php From 9d12f04fbc6f747069ed658d1f14b37f3630f010 Mon Sep 17 00:00:00 2001 From: christianberkman <39840601+christianberkman@users.noreply.github.com> Date: Fri, 25 Apr 2025 17:12:18 +0300 Subject: [PATCH 04/10] feat: add `Time::addCalendarMonths()` and `Time::subCalendarMonths()` methods (#9528) * feat: [I18n\Time] addCalendarMonths() * Remove return type * fix addCalendarMonths() return type * make addCalendarMonths() bi-directional and add subCalendarMonths() * update userguide and changelog * add tests for addCalendarMonths() and subCalendarMonths() * user guide: functions -> methods Co-authored-by: John Paul E. Balandan, CPA * revert change for TimeTrait::setTimeNow() * Update TimeTrait.php revert change to TimeTrait::setTimeNow() * update user guide * Update user_guide_src/source/libraries/time.rst Co-authored-by: Michal Sniatala --------- Co-authored-by: John Paul E. Balandan, CPA Co-authored-by: Michal Sniatala --- system/I18n/TimeTrait.php | 33 ++++++++++++++++++++ tests/system/I18n/TimeTest.php | 32 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 5 +++ user_guide_src/source/libraries/time.rst | 18 +++++++++++ user_guide_src/source/libraries/time/031.php | 2 ++ 5 files changed, 90 insertions(+) diff --git a/system/I18n/TimeTrait.php b/system/I18n/TimeTrait.php index 42278c960c43..6d3e7d6bb98d 100644 --- a/system/I18n/TimeTrait.php +++ b/system/I18n/TimeTrait.php @@ -749,6 +749,39 @@ public function addMonths(int $months) return $time->add(DateInterval::createFromDateString("{$months} months")); } + /** + * Returns a new Time instance with $months calendar months added to the time. + */ + public function addCalendarMonths(int $months): static + { + $time = clone $this; + + $year = (int) $time->getYear(); + $month = (int) $time->getMonth(); + $day = (int) $time->getDay(); + + // Adjust total months since year 0 + $totalMonths = ($year * 12 + $month - 1) + $months; + + // Recalculate year and month + $newYear = intdiv($totalMonths, 12); + $newMonth = $totalMonths % 12 + 1; + + // Get last day of new month + $lastDayOfMonth = cal_days_in_month(CAL_GREGORIAN, $newMonth, $newYear); + $correctedDay = min($day, $lastDayOfMonth); + + return static::create($newYear, $newMonth, $correctedDay, (int) $this->getHour(), (int) $this->getMinute(), (int) $this->getSecond(), $this->getTimezone(), $this->locale); + } + + /** + * Returns a new Time instance with $months calendar months subtracted from the time + */ + public function subCalendarMonths(int $months): static + { + return $this->addCalendarMonths(-$months); + } + /** * Returns a new Time instance with $years added to the time. * diff --git a/tests/system/I18n/TimeTest.php b/tests/system/I18n/TimeTest.php index af45abcbe4b8..4f76636ac7eb 100644 --- a/tests/system/I18n/TimeTest.php +++ b/tests/system/I18n/TimeTest.php @@ -812,6 +812,22 @@ public function testCanAddMonthsOverYearBoundary(): void $this->assertSame('2018-02-10 13:20:33', $newTime->toDateTimeString()); } + public function testCanAddCalendarMonths(): void + { + $time = Time::parse('January 31, 2017 13:20:33', 'America/Chicago'); + $newTime = $time->addCalendarMonths(1); + $this->assertSame('2017-01-31 13:20:33', $time->toDateTimeString()); + $this->assertSame('2017-02-28 13:20:33', $newTime->toDateTimeString()); + } + + public function testCanAddCalendarMonthsOverYearBoundary(): void + { + $time = Time::parse('January 31, 2017 13:20:33', 'America/Chicago'); + $newTime = $time->addCalendarMonths(13); + $this->assertSame('2017-01-31 13:20:33', $time->toDateTimeString()); + $this->assertSame('2018-02-28 13:20:33', $newTime->toDateTimeString()); + } + public function testCanAddYears(): void { $time = Time::parse('January 10, 2017 13:20:33', 'America/Chicago'); @@ -860,6 +876,22 @@ public function testCanSubtractMonths(): void $this->assertSame('2016-10-10 13:20:33', $newTime->toDateTimeString()); } + public function testCanSubtractCalendarMonths(): void + { + $time = Time::parse('March 31, 2017 13:20:33', 'America/Chicago'); + $newTime = $time->subCalendarMonths(1); + $this->assertSame('2017-03-31 13:20:33', $time->toDateTimeString()); + $this->assertSame('2017-02-28 13:20:33', $newTime->toDateTimeString()); + } + + public function testCanSubtractCalendarMonthsOverYearBoundary(): void + { + $time = Time::parse('March 31, 2017 13:20:33', 'America/Chicago'); + $newTime = $time->subCalendarMonths(13); + $this->assertSame('2017-03-31 13:20:33', $time->toDateTimeString()); + $this->assertSame('2016-02-29 13:20:33', $newTime->toDateTimeString()); + } + public function testCanSubtractYears(): void { $time = Time::parse('January 10, 2017 13:20:33', 'America/Chicago'); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index eb12d62068cb..13017b93db93 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -33,6 +33,11 @@ Method Signature Changes Enhancements ************ +Libraries +========= + +- **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` + Commands ======== diff --git a/user_guide_src/source/libraries/time.rst b/user_guide_src/source/libraries/time.rst index 17af069c8c5f..c3ddb3c16d39 100644 --- a/user_guide_src/source/libraries/time.rst +++ b/user_guide_src/source/libraries/time.rst @@ -325,6 +325,24 @@ modify the existing Time instance, but will return a new instance. .. literalinclude:: time/031.php +addCalendarMonths() / subCalendarMonths() +----------------------------------------- + +Modifies the current Time by adding or subtracting whole calendar months. These methods can be useful if you +require no calendar months are skipped in recurring dates. Refer to the table below for a comparison between +``addMonths()`` and ``addCalendarMonths()`` for an initial date of ``2025-01-31``. + +======= =========== =================== +$months addMonths() addCalendarMonths() +======= =========== =================== +1 2025-03-03 2025-02-28 +2 2025-03-31 2025-03-31 +3 2025-05-01 2025-04-30 +4 2025-05-31 2025-05-31 +5 2025-07-01 2025-06-30 +6 2025-07-31 2025-07-31 +======= =========== =================== + Comparing Two Times =================== diff --git a/user_guide_src/source/libraries/time/031.php b/user_guide_src/source/libraries/time/031.php index 3737ae67a3c1..914ff279871d 100644 --- a/user_guide_src/source/libraries/time/031.php +++ b/user_guide_src/source/libraries/time/031.php @@ -5,6 +5,7 @@ $time = $time->addHours(12); $time = $time->addDays(21); $time = $time->addMonths(14); +$time = $time->addCalendarMonths(2); $time = $time->addYears(5); $time = $time->subSeconds(23); @@ -12,4 +13,5 @@ $time = $time->subHours(12); $time = $time->subDays(21); $time = $time->subMonths(14); +$time = $time->subCalendarMonths(2); $time = $time->subYears(5); From ac89c61d8ba4c185c987fdf40c6e5be4619f44f9 Mon Sep 17 00:00:00 2001 From: Michal Sniatala Date: Thu, 8 May 2025 20:47:40 +0200 Subject: [PATCH 05/10] feat: add `clearMetadata()` method to provide privacy options when using imagick handler (#9538) * feat: add clearMetadata() method to provide privacy options when using imagick handler * simplify tests * update clearMetadata() * fix test * add clearMetadata to the interface * update method description --- system/Images/Handlers/BaseHandler.php | 10 ++++++ system/Images/Handlers/ImageMagickHandler.php | 16 +++++++++ system/Images/ImageHandlerInterface.php | 7 ++++ tests/system/Images/GDHandlerTest.php | 9 +++++ .../system/Images/ImageMagickHandlerTest.php | 36 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 3 ++ user_guide_src/source/libraries/images.rst | 15 ++++++++ .../source/libraries/images/015.php | 6 ++++ utils/phpstan-baseline/loader.neon | 2 +- utils/phpstan-baseline/varTag.type.neon | 7 +++- 10 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 user_guide_src/source/libraries/images/015.php diff --git a/system/Images/Handlers/BaseHandler.php b/system/Images/Handlers/BaseHandler.php index de3e15974a1b..e38c5836ccca 100644 --- a/system/Images/Handlers/BaseHandler.php +++ b/system/Images/Handlers/BaseHandler.php @@ -769,4 +769,14 @@ public function getHeight() { return ($this->resource !== null) ? $this->_getHeight() : $this->height; } + + /** + * Placeholder method for implementing metadata clearing logic. + * + * This method should be implemented to remove or reset metadata as needed. + */ + public function clearMetadata(): static + { + return $this; + } } diff --git a/system/Images/Handlers/ImageMagickHandler.php b/system/Images/Handlers/ImageMagickHandler.php index 08886937a4b1..2e8b56acd858 100644 --- a/system/Images/Handlers/ImageMagickHandler.php +++ b/system/Images/Handlers/ImageMagickHandler.php @@ -537,4 +537,20 @@ public function reorient(bool $silent = false) default => $this, }; } + + /** + * Clears metadata from the image. + * + * @return $this + * + * @throws ImagickException + */ + public function clearMetadata(): static + { + $this->ensureResource(); + + $this->resource->stripImage(); + + return $this; + } } diff --git a/system/Images/ImageHandlerInterface.php b/system/Images/ImageHandlerInterface.php index 93b2009d6a93..a96977214ec8 100644 --- a/system/Images/ImageHandlerInterface.php +++ b/system/Images/ImageHandlerInterface.php @@ -149,4 +149,11 @@ public function text(string $text, array $options = []); * @return bool */ public function save(?string $target = null, int $quality = 90); + + /** + * Clear metadata before saving image as a new file. + * + * @return $this + */ + public function clearMetadata(): static; } diff --git a/tests/system/Images/GDHandlerTest.php b/tests/system/Images/GDHandlerTest.php index 121ce040904a..d85d1a142ffd 100644 --- a/tests/system/Images/GDHandlerTest.php +++ b/tests/system/Images/GDHandlerTest.php @@ -454,4 +454,13 @@ public function testImageReorientPortrait(): void $this->assertSame(['red' => 62, 'green' => 62, 'blue' => 62, 'alpha' => 0], $rgb); } } + + public function testClearMetadataReturnsSelf(): void + { + $this->handler->withFile($this->path); + + $result = $this->handler->clearMetadata(); + + $this->assertSame($this->handler, $result); + } } diff --git a/tests/system/Images/ImageMagickHandlerTest.php b/tests/system/Images/ImageMagickHandlerTest.php index 924095253e48..be6bee2cf798 100644 --- a/tests/system/Images/ImageMagickHandlerTest.php +++ b/tests/system/Images/ImageMagickHandlerTest.php @@ -443,4 +443,40 @@ public function testImageReorientPortrait(): void $this->assertSame(['red' => 62, 'green' => 62, 'blue' => 62, 'alpha' => 0], $rgb); } } + + public function testClearMetadataEnsuresResource(): void + { + $this->expectException(ImageException::class); + $this->handler->clearMetadata(); + } + + public function testClearMetadataReturnsSelf(): void + { + $this->handler->withFile($this->path); + + $result = $this->handler->clearMetadata(); + + $this->assertSame($this->handler, $result); + } + + public function testClearMetadata(): void + { + $this->handler->withFile($this->origin . 'Steveston_dusk.JPG'); + /** @var Imagick $imagick */ + $imagick = $this->handler->getResource(); + $before = $imagick->getImageProperties(); + + $this->assertGreaterThan(40, count($before)); + + $this->handler + ->clearMetadata() + ->save($this->root . 'exif-info-no-metadata.jpg'); + + $this->handler->withFile($this->root . 'exif-info-no-metadata.jpg'); + /** @var Imagick $imagick */ + $imagick = $this->handler->getResource(); + $after = $imagick->getImageProperties(); + + $this->assertLessThanOrEqual(5, count($after)); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 13017b93db93..2f48a1007161 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -26,6 +26,8 @@ Behavior Changes Interface Changes ================= +- **Images:** The ``ImageHandlerInterface`` now includes a new method: ``clearMetadata()``. If you've implemented your own handler from scratch, you will need to provide an implementation for this method to ensure compatibility. + Method Signature Changes ======================== @@ -64,6 +66,7 @@ Libraries **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. +**Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. Helpers and Functions ===================== diff --git a/user_guide_src/source/libraries/images.rst b/user_guide_src/source/libraries/images.rst index f263eb173d06..b2b6b0238942 100644 --- a/user_guide_src/source/libraries/images.rst +++ b/user_guide_src/source/libraries/images.rst @@ -260,3 +260,18 @@ The possible options that are recognized are as follows: - ``vOffset`` Additional offset on the y axis, in pixels - ``fontPath`` The full server path to the TTF font you wish to use. System font will be used if none is given. - ``fontSize`` The font size to use. When using the GD handler with the system font, valid values are between ``1`` to ``5``. + +Clearing Image Metadata +======================= + +This method removes metadata (EXIF, XMP, ICC, IPTC, comments, etc.) from an image. + +.. important:: The GD image library automatically strips all metadata during processing, + so this method has no additional effect when using the GD handler. + This behavior is built into GD itself and cannot be modified. + +Some essential technical metadata (dimensions, color depth) will be regenerated during save operations +as they're required for image display. However, all privacy-sensitive information such as GPS location, +camera details, and timestamps will be completely removed. + +.. literalinclude:: images/015.php diff --git a/user_guide_src/source/libraries/images/015.php b/user_guide_src/source/libraries/images/015.php new file mode 100644 index 000000000000..446deab2d35f --- /dev/null +++ b/user_guide_src/source/libraries/images/015.php @@ -0,0 +1,6 @@ +withFile('/path/to/image/mypic.jpg') + ->clearMetadata() + ->save('/path/to/new/image.jpg'); diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index e4506414128e..f93eac55feb4 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 3253 errors +# total 3255 errors includes: - argument.type.neon - assign.propertyType.neon diff --git a/utils/phpstan-baseline/varTag.type.neon b/utils/phpstan-baseline/varTag.type.neon index 4be1089ace00..099fce1db55c 100644 --- a/utils/phpstan-baseline/varTag.type.neon +++ b/utils/phpstan-baseline/varTag.type.neon @@ -1,7 +1,12 @@ -# total 2 errors +# total 4 errors parameters: ignoreErrors: + - + message: '#^PHPDoc tag @var with type Imagick is not subtype of type resource\.$#' + count: 2 + path: ../../tests/system/Images/ImageMagickHandlerTest.php + - message: '#^PHPDoc tag @var with type Tests\\Support\\Entity\\UserWithCasts is not subtype of type list\\|null\.$#' count: 1 From f7d614285cf67719ca914b21ac53b374bcf138a5 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Fri, 16 May 2025 01:52:47 +0700 Subject: [PATCH 06/10] feat: add `dns_cache_timeout` for option `CURLRequest` (#9553) * feat: added dns_cache_timeout for option CURLRequest * tests: added option when set to not numeric * docs: added dns_cache_timeout options to CURLRequest * Update user_guide_src/source/libraries/curlrequest.rst Co-authored-by: John Paul E. Balandan, CPA * fix: more test coverage dns_cache_timeout options CURLRequest * fix: specified variable doctype * fix: more coverage * tests: added more coverage * docs: added notes libcurl * docs: remove notes PHP * Update tests/system/HTTP/CURLRequestTest.php Co-authored-by: John Paul E. Balandan, CPA * Update tests/system/HTTP/CURLRequestTest.php Co-authored-by: John Paul E. Balandan, CPA * Update user_guide_src/source/libraries/curlrequest.rst Co-authored-by: John Paul E. Balandan, CPA --------- Co-authored-by: John Paul E. Balandan, CPA --- system/HTTP/CURLRequest.php | 5 ++ tests/system/HTTP/CURLRequestTest.php | 71 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 11 ++- .../source/libraries/curlrequest.rst | 12 ++++ .../source/libraries/curlrequest/037.php | 4 ++ 5 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 user_guide_src/source/libraries/curlrequest/037.php diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index 8a21d77262a0..ef5d10880ae6 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -616,6 +616,11 @@ protected function setCURLOptions(array $curlOptions = [], array $config = []) } } + // DNS Cache Timeout + if (isset($config['dns_cache_timeout']) && is_numeric($config['dns_cache_timeout']) && $config['dns_cache_timeout'] >= -1) { + $curlOptions[CURLOPT_DNS_CACHE_TIMEOUT] = (int) $config['dns_cache_timeout']; + } + // Timeout $curlOptions[CURLOPT_TIMEOUT_MS] = (float) $config['timeout'] * 1000; diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index e72a1d37d17c..b44fbe4db99f 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -22,6 +22,7 @@ use Config\CURLRequest as ConfigCURLRequest; use CURLFile; use PHPUnit\Framework\Attributes\BackupGlobals; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; /** @@ -1212,6 +1213,76 @@ public function testForceResolveIPUnknown(): void $this->assertSame(\CURL_IPRESOLVE_WHATEVER, $options[CURLOPT_IPRESOLVE]); } + /** + * @return iterable + * + * @see https://curl.se/libcurl/c/CURLOPT_DNS_CACHE_TIMEOUT.html + */ + public static function provideDNSCacheTimeout(): iterable + { + yield from [ + 'valid timeout (integer)' => [ + 'input' => 160, + 'expectedHasKey' => true, + 'expectedValue' => 160, + ], + 'valid timeout (numeric string)' => [ + 'input' => '180', + 'expectedHasKey' => true, + 'expectedValue' => 180, + ], + 'valid timeout (zero / disabled)' => [ + 'input' => 0, + 'expectedHasKey' => true, + 'expectedValue' => 0, + ], + 'valid timeout (zero / disabled using string)' => [ + 'input' => '0', + 'expectedHasKey' => true, + 'expectedValue' => 0, + ], + 'valid timeout (forever)' => [ + 'input' => -1, + 'expectedHasKey' => true, + 'expectedValue' => -1, + ], + 'valid timeout (forever using string)' => [ + 'input' => '-1', + 'expectedHasKey' => true, + 'expectedValue' => -1, + ], + 'invalid timeout (null)' => [ + 'input' => null, + 'expectedHasKey' => false, + ], + 'invalid timeout (string)' => [ + 'input' => 'is_wrong', + 'expectedHasKey' => false, + ], + 'invalid timeout (negative number / below -1)' => [ + 'input' => -2, + 'expectedHasKey' => false, + ], + ]; + } + + #[DataProvider('provideDNSCacheTimeout')] + public function testDNSCacheTimeoutOption(int|string|null $input, bool $expectedHasKey, ?int $expectedValue = null): void + { + $this->request->request('POST', '/post', [ + 'dns_cache_timeout' => $input, + ]); + + $options = $this->request->curl_options; + + if ($expectedHasKey) { + $this->assertArrayHasKey(CURLOPT_DNS_CACHE_TIMEOUT, $options); + $this->assertSame($expectedValue, $options[CURLOPT_DNS_CACHE_TIMEOUT]); + } else { + $this->assertArrayNotHasKey(CURLOPT_DNS_CACHE_TIMEOUT, $options); + } + } + public function testCookieOption(): void { $holder = SUPPORTPATH . 'HTTP/Files/CookiesHolder.txt'; diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 2f48a1007161..a91cb468f542 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -38,6 +38,10 @@ Enhancements Libraries ========= +- **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. +- **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. +- **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. +- **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` Commands @@ -61,13 +65,6 @@ Others Model ===== -Libraries -========= - -**Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. -**Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. -**Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. - Helpers and Functions ===================== diff --git a/user_guide_src/source/libraries/curlrequest.rst b/user_guide_src/source/libraries/curlrequest.rst index 49540613e1cc..45de3ae5cdd6 100644 --- a/user_guide_src/source/libraries/curlrequest.rst +++ b/user_guide_src/source/libraries/curlrequest.rst @@ -251,6 +251,18 @@ Allows you to pause a number of milliseconds before sending the request: .. literalinclude:: curlrequest/023.php +dns_cache_timeout +================= + +.. versionadded:: 4.7.0 + +By default, CodeIgniter does not change the DNS Cache Timeout value (``120`` seconds). If you need to +modify this value, you can do so by passing an amount of time in seconds with the ``dns_cache_timeout`` option. + +.. literalinclude:: curlrequest/037.php + +.. note:: Based on the `libcurl `__ documentation, you can set to zero (``0``) to completely disable caching, or set to ``-1`` to make the cached entries remain forever. + form_params =========== diff --git a/user_guide_src/source/libraries/curlrequest/037.php b/user_guide_src/source/libraries/curlrequest/037.php new file mode 100644 index 000000000000..7d71d90bec70 --- /dev/null +++ b/user_guide_src/source/libraries/curlrequest/037.php @@ -0,0 +1,4 @@ +request('GET', '/', ['dns_cache_timeout' => 360]); // seconds From 59a48193462e2ee0c9372fd8209a7391149de402 Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Fri, 16 May 2025 12:53:50 +0700 Subject: [PATCH 07/10] feat: added `fresh_connect` options to `CURLRequest` (#9559) * feat: added options fresh_connect to CURLRequest * docs: added options fresh_connect to CURLRequest * docs: mention default value --- system/HTTP/CURLRequest.php | 6 ++++- tests/system/HTTP/CURLRequestTest.php | 22 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.0.rst | 1 + .../source/libraries/curlrequest.rst | 9 ++++++++ .../source/libraries/curlrequest/038.php | 4 ++++ 5 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 user_guide_src/source/libraries/curlrequest/038.php diff --git a/system/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index ef5d10880ae6..f160f0932733 100644 --- a/system/HTTP/CURLRequest.php +++ b/system/HTTP/CURLRequest.php @@ -365,7 +365,6 @@ public function send(string $method, string $url) $curlOptions[CURLOPT_URL] = $url; $curlOptions[CURLOPT_RETURNTRANSFER] = true; $curlOptions[CURLOPT_HEADER] = true; - $curlOptions[CURLOPT_FRESH_CONNECT] = true; // Disable @file uploads in post data. $curlOptions[CURLOPT_SAFE_UPLOAD] = true; @@ -621,6 +620,11 @@ protected function setCURLOptions(array $curlOptions = [], array $config = []) $curlOptions[CURLOPT_DNS_CACHE_TIMEOUT] = (int) $config['dns_cache_timeout']; } + // Fresh Connect (default true) + $curlOptions[CURLOPT_FRESH_CONNECT] = isset($config['fresh_connect']) && is_bool($config['fresh_connect']) + ? $config['fresh_connect'] + : true; + // Timeout $curlOptions[CURLOPT_TIMEOUT_MS] = (float) $config['timeout'] * 1000; diff --git a/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index b44fbe4db99f..508c4f2f314f 100644 --- a/tests/system/HTTP/CURLRequestTest.php +++ b/tests/system/HTTP/CURLRequestTest.php @@ -583,6 +583,28 @@ public function testProxyuOption(): void $this->assertTrue($options[CURLOPT_HTTPPROXYTUNNEL]); } + public function testFreshConnectDefault(): void + { + $this->request->request('get', 'http://example.com'); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_FRESH_CONNECT, $options); + $this->assertTrue($options[CURLOPT_FRESH_CONNECT]); + } + + public function testFreshConnectFalseOption(): void + { + $this->request->request('get', 'http://example.com', [ + 'fresh_connect' => false, + ]); + + $options = $this->request->curl_options; + + $this->assertArrayHasKey(CURLOPT_FRESH_CONNECT, $options); + $this->assertFalse($options[CURLOPT_FRESH_CONNECT]); + } + public function testDebugOptionTrue(): void { $this->request->request('get', 'http://example.com', [ diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index a91cb468f542..7645847c59e9 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -39,6 +39,7 @@ Libraries ========= - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. +- **CURLRequest:** Added ``fresh_connect`` options to enable/disabled request fresh connection. - **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. diff --git a/user_guide_src/source/libraries/curlrequest.rst b/user_guide_src/source/libraries/curlrequest.rst index 45de3ae5cdd6..8be3b098b287 100644 --- a/user_guide_src/source/libraries/curlrequest.rst +++ b/user_guide_src/source/libraries/curlrequest.rst @@ -278,6 +278,15 @@ if it's not already set: .. _curlrequest-request-options-headers: +fresh_connect +============= + +.. versionadded:: 4.7.0 + +By default, the request is sent using a fresh connection. You can disable this behavior using the ``fresh_connect`` option: + +.. literalinclude:: curlrequest/038.php + headers ======= diff --git a/user_guide_src/source/libraries/curlrequest/038.php b/user_guide_src/source/libraries/curlrequest/038.php new file mode 100644 index 000000000000..83e971d2782a --- /dev/null +++ b/user_guide_src/source/libraries/curlrequest/038.php @@ -0,0 +1,4 @@ +request('GET', 'http://example.com', ['fresh_connect' => true]); +$client->request('GET', 'http://example.com', ['fresh_connect' => false]); From e1317af60e2ceb5127a83fbde0cbc7da06535b1b Mon Sep 17 00:00:00 2001 From: Denny Septian Panggabean <97607754+ddevsr@users.noreply.github.com> Date: Mon, 19 May 2025 17:42:51 +0700 Subject: [PATCH 08/10] refactor: cleanup code in Email (#9570) --- system/Email/Email.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/system/Email/Email.php b/system/Email/Email.php index 4d2cbaf2958a..adb8501d5881 100644 --- a/system/Email/Email.php +++ b/system/Email/Email.php @@ -2036,15 +2036,15 @@ protected function SMTPAuthenticate() $this->SMTPAuthMethod = strtolower($this->SMTPAuthMethod); // Validate supported authentication methods - $validMethods = ['login', 'plain']; - if (! in_array($this->SMTPAuthMethod, $validMethods, true)) { + if (! in_array($this->SMTPAuthMethod, ['login', 'plain'], true)) { $this->setErrorMessage(lang('Email.invalidSMTPAuthMethod', [$this->SMTPAuthMethod])); return false; } + $upperAuthMethod = strtoupper($this->SMTPAuthMethod); // send initial 'AUTH' command - $this->sendData('AUTH ' . strtoupper($this->SMTPAuthMethod)); + $this->sendData('AUTH ' . $upperAuthMethod); $reply = $this->getSMTPData(); if (str_starts_with($reply, '503')) { // Already authenticated @@ -2053,7 +2053,7 @@ protected function SMTPAuthenticate() // if 'AUTH' command is unsuported by the server if (! str_starts_with($reply, '334')) { - $this->setErrorMessage(lang('Email.failureSMTPAuthMethod', [strtoupper($this->SMTPAuthMethod)])); + $this->setErrorMessage(lang('Email.failureSMTPAuthMethod', [$upperAuthMethod])); return false; } From cf6011fcbe57c51763d522c4441d22ab0aef9ef7 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Mon, 19 May 2025 18:43:21 +0800 Subject: [PATCH 09/10] feat: update `CookieInterface::EXPIRES_FORMAT` to use date format per RFC 7231 (#9563) * Update cookie expires to `DATE_RFC7231` * Add to changelog * Fix doc example --- system/Cookie/CookieInterface.php | 2 +- tests/system/Cookie/CookieTest.php | 8 ++++---- user_guide_src/source/changelogs/v4.7.0.rst | 2 ++ user_guide_src/source/libraries/cookies/004.php | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/system/Cookie/CookieInterface.php b/system/Cookie/CookieInterface.php index c848fa8884ec..8317617088cd 100644 --- a/system/Cookie/CookieInterface.php +++ b/system/Cookie/CookieInterface.php @@ -57,7 +57,7 @@ interface CookieInterface * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date * @see https://tools.ietf.org/html/rfc7231#section-7.1.1.2 */ - public const EXPIRES_FORMAT = 'D, d-M-Y H:i:s T'; + public const EXPIRES_FORMAT = DATE_RFC7231; /** * Returns a unique identifier for the cookie consisting diff --git a/tests/system/Cookie/CookieTest.php b/tests/system/Cookie/CookieTest.php index e4421a8bc443..1438cfb9d513 100644 --- a/tests/system/Cookie/CookieTest.php +++ b/tests/system/Cookie/CookieTest.php @@ -155,14 +155,14 @@ public function testExpirationTime(): void // expires => 0 $cookie = new Cookie('test', 'value'); $this->assertSame(0, $cookie->getExpiresTimestamp()); - $this->assertSame('Thu, 01-Jan-1970 00:00:00 GMT', $cookie->getExpiresString()); + $this->assertSame('Thu, 01 Jan 1970 00:00:00 GMT', $cookie->getExpiresString()); $this->assertTrue($cookie->isExpired()); $this->assertSame(0, $cookie->getMaxAge()); $date = new DateTimeImmutable('2021-01-10 00:00:00 GMT', new DateTimeZone('UTC')); $cookie = new Cookie('test', 'value', ['expires' => $date]); $this->assertSame((int) $date->format('U'), $cookie->getExpiresTimestamp()); - $this->assertSame('Sun, 10-Jan-2021 00:00:00 GMT', $cookie->getExpiresString()); + $this->assertSame('Sun, 10 Jan 2021 00:00:00 GMT', $cookie->getExpiresString()); } /** @@ -272,7 +272,7 @@ public function testStringCastingOfCookies(): void $a->toHeaderString(), ); $this->assertSame( - "cookie=monster; Expires=Sun, 14-Feb-2021 00:00:00 GMT; Max-Age={$max}; Path=/web; Domain=localhost; HttpOnly; SameSite=Lax", + "cookie=monster; Expires=Sun, 14 Feb 2021 00:00:00 GMT; Max-Age={$max}; Path=/web; Domain=localhost; HttpOnly; SameSite=Lax", (string) $b, ); $this->assertSame( @@ -280,7 +280,7 @@ public function testStringCastingOfCookies(): void (string) $c, ); $this->assertSame( - 'cookie=deleted; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Path=/; HttpOnly; SameSite=Lax', + 'cookie=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; HttpOnly; SameSite=Lax', (string) $d, ); diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 7645847c59e9..a64f4245de05 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -83,6 +83,8 @@ Message Changes Changes ******* +- **Cookie:** The ``CookieInterface::EXPIRES_FORMAT`` has been changed to ``D, d M Y H:i:s \G\M\T`` to follow the recommended format in RFC 7231. + ************ Deprecations ************ diff --git a/user_guide_src/source/libraries/cookies/004.php b/user_guide_src/source/libraries/cookies/004.php index e39854766db1..d68d97081713 100644 --- a/user_guide_src/source/libraries/cookies/004.php +++ b/user_guide_src/source/libraries/cookies/004.php @@ -23,7 +23,7 @@ $cookie->getPrefix(); // '__Secure-' $cookie->getPrefixedName(); // '__Secure-remember_token' $cookie->getExpiresTimestamp(); // UNIX timestamp -$cookie->getExpiresString(); // 'Fri, 14-Feb-2025 00:00:00 GMT' +$cookie->getExpiresString(); // 'Fri, 14 Feb 2025 00:00:00 GMT' $cookie->isExpired(); // false $cookie->getMaxAge(); // the difference from time() to expires $cookie->isRaw(); // false From 4d912e39c7161ab08cf92c9ebbe4b8ef192e44d8 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Wed, 21 May 2025 02:13:05 +0800 Subject: [PATCH 10/10] fix: ucfirst all cookie samesite values (#9564) * fix: ucfirst all cookie samesite values * Fix doc sample * Add changelog * Fix review * Add case-insensitive validation of SameSite --- system/Cookie/Cookie.php | 4 ++-- system/Cookie/CookieInterface.php | 6 +++--- user_guide_src/source/changelogs/v4.7.0.rst | 2 ++ user_guide_src/source/libraries/cookies/006.php | 6 +++--- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/system/Cookie/Cookie.php b/system/Cookie/Cookie.php index df75c03c7bcb..b677bb944e31 100644 --- a/system/Cookie/Cookie.php +++ b/system/Cookie/Cookie.php @@ -766,11 +766,11 @@ protected function validateSameSite(string $samesite, bool $secure): void $samesite = self::SAMESITE_LAX; } - if (! in_array(strtolower($samesite), self::ALLOWED_SAMESITE_VALUES, true)) { + if (! in_array(ucfirst(strtolower($samesite)), self::ALLOWED_SAMESITE_VALUES, true)) { throw CookieException::forInvalidSameSite($samesite); } - if (strtolower($samesite) === self::SAMESITE_NONE && ! $secure) { + if (ucfirst(strtolower($samesite)) === self::SAMESITE_NONE && ! $secure) { throw CookieException::forInvalidSameSiteNone(); } } diff --git a/system/Cookie/CookieInterface.php b/system/Cookie/CookieInterface.php index 8317617088cd..b63cb4c07833 100644 --- a/system/Cookie/CookieInterface.php +++ b/system/Cookie/CookieInterface.php @@ -25,20 +25,20 @@ interface CookieInterface * first-party and cross-origin requests. If `SameSite=None` is set, * the cookie `Secure` attribute must also be set (or the cookie will be blocked). */ - public const SAMESITE_NONE = 'none'; + public const SAMESITE_NONE = 'None'; /** * Cookies are not sent on normal cross-site subrequests (for example to * load images or frames into a third party site), but are sent when a * user is navigating to the origin site (i.e. when following a link). */ - public const SAMESITE_LAX = 'lax'; + public const SAMESITE_LAX = 'Lax'; /** * Cookies will only be sent in a first-party context and not be sent * along with requests initiated by third party websites. */ - public const SAMESITE_STRICT = 'strict'; + public const SAMESITE_STRICT = 'Strict'; /** * RFC 6265 allowed values for the "SameSite" attribute. diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index a64f4245de05..7182a8dddb53 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -97,6 +97,8 @@ Deprecations Bugs Fixed ********** +- **Cookie:** The ``CookieInterface::SAMESITE_STRICT``, ``CookieInterface::SAMESITE_LAX``, and ``CookieInterface::SAMESITE_NONE`` constants are now written in ucfirst style to be consistent with usage in the rest of the framework. + See the repo's `CHANGELOG.md `_ for a complete list of bugs fixed. diff --git a/user_guide_src/source/libraries/cookies/006.php b/user_guide_src/source/libraries/cookies/006.php index 1bf6c732b71a..512d3e8fefe0 100644 --- a/user_guide_src/source/libraries/cookies/006.php +++ b/user_guide_src/source/libraries/cookies/006.php @@ -2,6 +2,6 @@ use CodeIgniter\Cookie\Cookie; -Cookie::SAMESITE_LAX; // 'lax' -Cookie::SAMESITE_STRICT; // 'strict' -Cookie::SAMESITE_NONE; // 'none' +Cookie::SAMESITE_LAX; // 'Lax' +Cookie::SAMESITE_STRICT; // 'Strict' +Cookie::SAMESITE_NONE; // 'None'