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/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/Cookie/Cookie.php b/system/Cookie/Cookie.php index ad461c721112..764c8e5bc3e9 100644 --- a/system/Cookie/Cookie.php +++ b/system/Cookie/Cookie.php @@ -793,11 +793,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 b4a7ea3d7320..1e5ad2d56728 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. @@ -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/system/Email/Email.php b/system/Email/Email.php index 2a0facc0ca08..adb8501d5881 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 * @@ -2020,45 +2025,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 + 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 ' . $upperAuthMethod); $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', [$upperAuthMethod])); 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/HTTP/CURLRequest.php b/system/HTTP/CURLRequest.php index 8a21d77262a0..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; @@ -616,6 +615,16 @@ 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']; + } + + // 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/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/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/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 f1448efa7f8a..2e8b56acd858 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()); + } } + } - if (! is_file($cmd)) { - throw ImageException::forInvalidImageLibraryPath($cmd); + /** + * 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; + + 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); + $this->resource->setImageCompressionQuality($quality); - unlink($this->resource); - - return true; - } - - /** - * 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'; - - $name = basename($this->resource); - $path = pathinfo($this->resource, PATHINFO_DIRNAME); + try { + $result = $this->resource->writeImage($target); - $this->image()->copy($path, $name); - - return $this->resource; - } + chmod($target, $this->filePermissions); - /** - * Make the image resource object if needed - * - * @return void - * - * @throws Exception - */ - protected function ensureResource() - { - $this->getResourcePath(); - - $this->supportedFormatCheck(); - } + $this->resource->clear(); + $this->resource = null; - /** - * 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; - // Text - $cmd .= " -annotate 0 '{$text}'"; + if (isset($options['shadowColor'])) { + $shadowColor = $options['shadowColor']; + + // Shorthand hex, #f00 + if (strlen($shadowColor) === 3) { + $shadowColor = implode('', array_map(str_repeat(...), str_split($shadowColor), [2, 2, 2])); + } + + [$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)')); + } - $source = ! empty($this->resource) ? $this->resource : $this->image()->getPathname(); - $destination = $this->getResourcePath(); + $offset = $options['shadowOffset'] ?? 3; - $cmd = " '{$source}' {$cmd} '{$destination}'"; + $this->resource->annotateImage( + $shadow, + $xAxis + $offset, + $yAxis + $offset, + 0, + $text, + ); + } - $this->process($cmd); + // 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(); } /** @@ -464,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/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/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/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/tests/system/HTTP/CURLRequestTest.php b/tests/system/HTTP/CURLRequestTest.php index e72a1d37d17c..508c4f2f314f 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; /** @@ -582,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', [ @@ -1212,6 +1235,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/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/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 14cb4bc57980..be6bee2cf798 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,14 +434,49 @@ 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); } } + + 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/index.rst b/user_guide_src/source/changelogs/index.rst index bd881c8b59fe..7723f2ee4d62 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.2 v4.6.1 v4.6.0 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..7182a8dddb53 --- /dev/null +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -0,0 +1,104 @@ +############# +Version 4.7.0 +############# + +Release Date: Unreleased + +**4.7.0 release of CodeIgniter4** + +.. contents:: + :local: + :depth: 3 + +********** +Highlights +********** + +- TBD + +******** +BREAKING +******** + +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 +======================== + +************ +Enhancements +************ + +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. +- **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` + +Commands +======== + +Testing +======= + +Database +======== + +Query Builder +------------- + +Forge +----- + +Others +------ + +Model +===== + +Helpers and Functions +===================== + +Others +====== + +*************** +Message Changes +*************** + +- Added ``Email.invalidSMTPAuthMethod`` and ``Email.failureSMTPAuthMethod`` +- Deprecated ``Email.failedSMTPLogin`` and ``Image.libPathInvalid`` + +******* +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 +************ + +- **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 +********** + +- **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/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 0b9f2dd806d0..066dc399d18f 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_462 upgrade_461 upgrade_460 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 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' diff --git a/user_guide_src/source/libraries/curlrequest.rst b/user_guide_src/source/libraries/curlrequest.rst index 49540613e1cc..8be3b098b287 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 =========== @@ -266,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/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 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]); 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 diff --git a/user_guide_src/source/libraries/images.rst b/user_guide_src/source/libraries/images.rst index a439c3321a0b..b2b6b0238942 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. ******************* @@ -264,5 +261,17 @@ The possible options that are recognized are as follows: - ``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. +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/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); diff --git a/utils/phpstan-baseline/argument.type.neon b/utils/phpstan-baseline/argument.type.neon index 34a1a8c9986e..9b54568bce9b 100644 --- a/utils/phpstan-baseline/argument.type.neon +++ b/utils/phpstan-baseline/argument.type.neon @@ -1,4 +1,4 @@ -# total 136 errors +# total 134 errors parameters: ignoreErrors: @@ -202,11 +202,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 e54589fea853..e4506414128e 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 3271 errors +# total 3253 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 8ecc1166e8bd..8e2a59b05642 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1576 errors +# total 1575 errors parameters: ignoreErrors: @@ -4527,11 +4527,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 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