From 82ffa4491772f2598d5be40c37d73fe4e81e540b Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Fri, 22 May 2026 11:56:31 +0530 Subject: [PATCH 1/3] Fix fatal in is_oversize_media when usage data is a string When Cloudinary is not yet connected (or the usage API returns an unexpected non-array response), Connect::$usage['media_limits'] can be a string. PHP 8.x then fatals with 'Cannot access offset of type string on string' as soon as any front-end request resolves an attachment URL (e.g. Elementor's get_the_post_thumbnail_url during wp_head). Guard the lookup and treat the asset as not oversize when limits are unavailable, matching the defensive pattern already used in Connect::get_usage_stat(). --- php/class-media.php | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/php/class-media.php b/php/class-media.php index 2bd521279..0d919ce24 100644 --- a/php/class-media.php +++ b/php/class-media.php @@ -446,15 +446,26 @@ public function is_oversize_media( $attachment_id ) { return $is_oversize[ $attachment_id ]; } - $file_size = $this->get_attachment_file_size( $attachment_id ); - $max_size = ( wp_attachment_is_image( $attachment_id ) ? 'image_max_size_bytes' : 'video_max_size_bytes' ); - $limit = $this->plugin->components['connect']->usage['media_limits'][ $max_size ]; + $file_size = $this->get_attachment_file_size( $attachment_id ); + $max_size_key = ( wp_attachment_is_image( $attachment_id ) ? 'image_max_size_bytes' : 'video_max_size_bytes' ); + $usage = $this->plugin->components['connect']->usage; + $media_limits = is_array( $usage ) && isset( $usage['media_limits'] ) ? $usage['media_limits'] : null; + + // If the limit isn't available yet (e.g. plugin not connected, or API + // returned an unexpected response), treat the asset as not oversize + // rather than fatal-erroring on a string offset. + if ( ! is_array( $media_limits ) || ! isset( $media_limits[ $max_size_key ] ) ) { + $is_oversize[ $attachment_id ] = false; + + return $is_oversize[ $attachment_id ]; + } + + $limit = $media_limits[ $max_size_key ]; $is_oversize[ $attachment_id ] = $file_size > $limit; if ( $is_oversize[ $attachment_id ] ) { - $max_size = ( wp_attachment_is_image( $attachment_id ) ? 'image_max_size_bytes' : 'video_max_size_bytes' ); - $max_size_hr = size_format( $this->plugin->components['connect']->usage['media_limits'][ $max_size ] ); + $max_size_hr = size_format( $limit ); // translators: variable is file size. $message = sprintf( __( 'File size exceeds the maximum of %s. This media asset will be served from WordPress.', 'cloudinary' ), $max_size_hr ); update_post_meta( $attachment_id, Sync::META_KEYS['sync_error'], $message ); From c4e24b454135e30c2916615a6118ef452c97c13a Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Sat, 23 May 2026 16:49:52 +0530 Subject: [PATCH 2/3] Guard remaining consumers of usage / last_usage against string values When the Cloudinary usage API has never returned a valid response (fresh install, expired credentials, or a transient API error), both `Connect::$usage` and the persisted `last_usage` setting can be a string rather than the expected array. PHP 8.x then fatals with 'Cannot access offset of type string on string' in any code that dereferences these without checking. This adds defensive checks alongside the existing Media::is_oversize_media fix: - Plan_Details::plan() (php/ui/component/class-plan-details.php): bail to empty plan/requests values when last_usage is not an array. - settings-sidebar.php Account status description: guard the plan name access. - Special_Offer::is_special_offer_available(): return false when last_usage isn't a usable array. - Connect::usage_stats(): only cache derived max image/video sizes when media_limits is actually an array, so a stringy API response doesn't poison the transient and last_usage option for the rest of the hour. --- php/class-connect.php | 11 ++++++++--- php/class-special-offer.php | 6 ++++++ php/ui/component/class-plan-details.php | 14 ++++++++++++-- ui-definitions/settings-sidebar.php | 5 ++++- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/php/class-connect.php b/php/class-connect.php index d35bb2a89..774423ef4 100644 --- a/php/class-connect.php +++ b/php/class-connect.php @@ -720,9 +720,14 @@ public function usage_stats( $refresh = false ) { $last_usage = $this->settings->get_setting( 'last_usage' ); // Get users plan. $stats = $this->api->usage(); - if ( ! is_wp_error( $stats ) && ! empty( $stats['media_limits'] ) ) { - $stats['max_image_size'] = $stats['media_limits']['image_max_size_bytes']; - $stats['max_video_size'] = $stats['media_limits']['video_max_size_bytes']; + if ( + ! is_wp_error( $stats ) + && is_array( $stats ) + && isset( $stats['media_limits'] ) + && is_array( $stats['media_limits'] ) + ) { + $stats['max_image_size'] = isset( $stats['media_limits']['image_max_size_bytes'] ) ? $stats['media_limits']['image_max_size_bytes'] : 0; + $stats['max_video_size'] = isset( $stats['media_limits']['video_max_size_bytes'] ) ? $stats['media_limits']['video_max_size_bytes'] : 0; $last_usage->save_value( $stats );// Save the last successful call to prevgent crashing. } else { // Handle error by logging and fetching the last success. diff --git a/php/class-special-offer.php b/php/class-special-offer.php index 075a99fb5..a703d2418 100644 --- a/php/class-special-offer.php +++ b/php/class-special-offer.php @@ -98,6 +98,12 @@ public function filtered_settings( $settings ) { protected function is_special_offer_available() { $last_usage = get_option( Connect::META_KEYS['last_usage'], array( 'plan' => '' ) ); + // `last_usage` can be a string when the usage API has never returned a + // valid response. Only an array with a `plan` key is meaningful here. + if ( ! is_array( $last_usage ) || ! isset( $last_usage['plan'] ) ) { + return false; + } + return 'free' === strtolower( $last_usage['plan'] ); } diff --git a/php/ui/component/class-plan-details.php b/php/ui/component/class-plan-details.php index f4fea528d..ac3f1d0f7 100644 --- a/php/ui/component/class-plan-details.php +++ b/php/ui/component/class-plan-details.php @@ -68,10 +68,20 @@ protected function plan( $struct ) { return $struct; } + // `last_usage` can be a string when the usage API has never returned a + // valid response (e.g. fresh install or API error). Without this + // guard, PHP 8.x fatals on string-offset access below. + if ( ! is_array( $data ) ) { + $data = array(); + } + + $plan_name = isset( $data['plan'] ) ? $data['plan'] : ''; + $requests = isset( $data['requests'] ) ? $data['requests'] : 0; + $struct['element'] = 'div'; $struct['attributes']['class'][] = 'cld-plan'; - $struct['children']['plan'] = $this->make_item( __( 'Plan', 'cloudinary' ), $data['plan'], $this->dir_url . 'css/images/star.svg' ); + $struct['children']['plan'] = $this->make_item( __( 'Plan', 'cloudinary' ), $plan_name, $this->dir_url . 'css/images/star.svg' ); if ( $connection->get_usage_stat( 'credits', 'limit' ) ) { $struct = $this->plan_credit( $struct, $connection ); @@ -79,7 +89,7 @@ protected function plan( $struct ) { $struct = $this->plan_classic( $struct, $connection ); } - $struct['children']['requests'] = $this->make_item( __( 'Total Requests', 'cloudinary' ), number_format_i18n( $data['requests'] ), $this->dir_url . 'css/images/requests.svg' ); + $struct['children']['requests'] = $this->make_item( __( 'Total Requests', 'cloudinary' ), number_format_i18n( $requests ), $this->dir_url . 'css/images/requests.svg' ); $struct['children']['assets'] = $this->make_item( __( 'Optimized assets', 'cloudinary' ), number_format_i18n( Sync_Queue::get_optimized_assets() ), $this->dir_url . 'css/images/image.svg' ); return $struct; diff --git a/ui-definitions/settings-sidebar.php b/ui-definitions/settings-sidebar.php index 5fd2c8ad5..a21b3a4cb 100644 --- a/ui-definitions/settings-sidebar.php +++ b/ui-definitions/settings-sidebar.php @@ -23,10 +23,13 @@ $plugin = get_plugin_instance(); $data = $plugin->settings->get_value( 'last_usage' ); $cloud_name = $plugin->components['connect']->get_cloud_name(); + // `last_usage` may be a string when no successful usage API + // response has been recorded yet; fall back to an empty plan name. + $plan_name = is_array( $data ) && isset( $data['plan'] ) ? $data['plan'] : ''; ob_start(); ?> - +
@ Date: Sat, 23 May 2026 16:58:47 +0530 Subject: [PATCH 3/3] Harden additional stored-state consumers against non-array values Following the usage/last_usage fix, audit found the same PHP 8.x 'Cannot access offset of type string on string' / 'foreach on string' hazard in other code paths that read from get_option / get_transient and immediately dereference or iterate the result: - Connect::history(): $history option may be a string; reset to array before doing $history[$plan][$date] lookups. Also defend the $plan resolution when $this->usage or $this->credentials is unexpected. - Sync_Queue::get_thread_queue(): thread option may be a string; reset to defaults before array_merge. - Cron::load_schedule(): cron schedule option may be a string; reset to array before foreach and guard inner item type. - WPML::wp_generate_attachment_metadata(): transient may be a scalar; cast to array before the $data[$id] check. These are defensive guards only - normal happy-path behaviour is unchanged. --- php/class-connect.php | 8 +++++++- php/class-cron.php | 9 ++++++++- php/integrations/class-wpml.php | 5 +++++ php/sync/class-sync-queue.php | 6 ++++-- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/php/class-connect.php b/php/class-connect.php index 774423ef4..216e0164c 100644 --- a/php/class-connect.php +++ b/php/class-connect.php @@ -447,7 +447,13 @@ function ( $a ) { public function history( $days = 1 ) { $return = array(); $history = get_option( self::META_KEYS['history'], array() ); - $plan = ! empty( $this->usage['plan'] ) ? $this->usage['plan'] : $this->credentials['cloud_name']; + // Stored option can be a non-array if it was corrupted by a previous + // failed write. Reset to empty array to avoid string-offset fatals. + if ( ! is_array( $history ) ) { + $history = array(); + } + $plan_source = is_array( $this->usage ) && ! empty( $this->usage['plan'] ) ? $this->usage['plan'] : ''; + $plan = '' !== $plan_source ? $plan_source : ( isset( $this->credentials['cloud_name'] ) ? $this->credentials['cloud_name'] : '' ); for ( $i = 1; $i <= $days; $i++ ) { $date = date_i18n( 'd-m-Y', strtotime( '- ' . $i . ' days' ) ); if ( ! isset( $history[ $plan ][ $date ] ) || is_wp_error( $history[ $plan ][ $date ] ) ) { diff --git a/php/class-cron.php b/php/class-cron.php index ec0203646..9877c0b0b 100644 --- a/php/class-cron.php +++ b/php/class-cron.php @@ -203,8 +203,15 @@ public function rest_endpoints( $endpoints ) { */ protected function load_schedule() { $this->schedule = get_option( self::CRON_META_KEY, array() ); + // Guard against a corrupted option (e.g. a string was previously + // stored) so the foreach below cannot fatal on PHP 8.x. + if ( ! is_array( $this->schedule ) ) { + $this->schedule = array(); + } foreach ( $this->schedule as &$item ) { - $item['active'] = false; + if ( is_array( $item ) ) { + $item['active'] = false; + } } } diff --git a/php/integrations/class-wpml.php b/php/integrations/class-wpml.php index 56199c037..1f576f174 100644 --- a/php/integrations/class-wpml.php +++ b/php/integrations/class-wpml.php @@ -102,6 +102,11 @@ public function wp_generate_attachment_metadata( $metadata, $attachment_id, $con $original_attachment_id = $attachment_id; $data = get_transient( self::TRANSIENT_KEY ); + // Cast to array to avoid string-offset fatals on PHP 8.x when the + // transient holds an unexpected scalar. + if ( ! is_array( $data ) ) { + $data = array(); + } // This is a duplicated attachment. Let's restore the metadata via WPML. if ( ! empty( $data[ $original_attachment_id ] ) ) { diff --git a/php/sync/class-sync-queue.php b/php/sync/class-sync-queue.php index 220b9b7c5..883ff1567 100644 --- a/php/sync/class-sync-queue.php +++ b/php/sync/class-sync-queue.php @@ -699,8 +699,10 @@ public function get_thread_queue( $thread ) { ); wp_cache_delete( $thread_option, 'options' ); $return = get_option( $thread_option ); - if ( empty( $return ) ) { - // Set option to remove notoption and default fro cache. + if ( empty( $return ) || ! is_array( $return ) ) { + // Reset to default when missing or corrupted (e.g. a string was + // previously stored). Without the is_array check, array_merge + // below would fatal on PHP 8.x. $this->set_thread_queue( $thread, $default ); $return = $default; }