diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php index f24821258a..ec5f7e2854 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php @@ -2,6 +2,7 @@ namespace WordPressdotorg\Plugin_Directory\Jobs; use WordPressdotorg\Plugin_Directory\Plugin_Directory; +use WordPressdotorg\Plugin_Directory\Standalone\Plugins_Info_API; use WordPressdotorg\Plugin_Directory\Template; use WordPressdotorg\Plugin_Directory\Tools; @@ -86,12 +87,13 @@ public static function update_single_plugin( $plugin_slug ) { $requires_plugins = get_post_meta( $post->ID, 'requires_plugins', true ); $release = Plugin_Directory::get_release( $post, $version ); $release_time = self::compute_release_time( $post, $release ); - $existing_version = (string) $wpdb->get_var( + $existing_row = $wpdb->get_row( $wpdb->prepare( - "SELECT version FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", + "SELECT version, meta FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", $post->post_name ) ); + $existing_version = (string) ( $existing_row->version ?? '' ); $release_delay = (int) ( $release['release_delay'] ?? 0 ); @@ -104,11 +106,22 @@ public static function update_single_plugin( $plugin_slug ) { * The deferred cron fires at exactly $cooldown_until, so by definition this * gate is false when called from cron_trigger_release() and no explicit bypass * is needed. + * + * Only the version bump waits for the cooldown: a status change made + * mid-cooldown (a closure, a reopen) reaches the existing row right away, + * while it keeps serving the previous release's data. Until the cooldown + * expires, cron_trigger() keeps re-selecting the plugin and this write + * repeats as a no-op. */ if ( $release_delay && $existing_version !== (string) $version ) { $cooldown_until = $release_time + $release_delay; if ( $cooldown_until > time() ) { self::queue_release_to_update_api( $post->post_name, $cooldown_until ); + + if ( $existing_row ) { + self::update_row_availability( $post, $existing_row->meta ); + } + return true; } } @@ -127,16 +140,7 @@ public static function update_single_plugin( $plugin_slug ) { 'last_stable_tag' => $post->last_stable_tag ?? '', ); - if ( in_array( $post->post_status, array( 'disabled', 'closed' ) ) ) { - $closed_data = Template::get_close_data( $post ); - if ( $closed_data ) { - // Close date is sometimes unknown, only include the Day of closure. - $meta['closed_at'] = $closed_data['date'] ? gmdate( 'Y-m-d', strtotime( $closed_data['date'] ) ) : false; - if ( $closed_data['public'] ) { - $meta['closed_reason'] = $closed_data['reason'] ?: 'unknown'; - } - } - } + $meta = array_merge( $meta, self::get_close_meta( $post ) ); // Add phased rollout strategy data if needed. if ( $release && ! empty( $release['rollout_strategy'] ) ) { @@ -152,7 +156,7 @@ public static function update_single_plugin( $plugin_slug ) { $data = array( 'plugin_id' => $post->ID, 'plugin_slug' => $post->post_name, - 'available' => (int) in_array( $post->post_status, array( 'publish', 'disabled' ) ), + 'available' => (int) self::is_available( $post ), 'version' => $version, 'stable_tag' => get_post_meta( $post->ID, 'stable_tag', true ), 'plugin_name' => strip_tags( get_post_meta( $post->ID, 'header_name', true ) ), @@ -177,30 +181,14 @@ public static function update_single_plugin( $plugin_slug ) { } } - // ~34char prefix, Memcache limit of 255char per key. - $plugin_details_cache_key = 'plugin_details:' . ( strlen( $plugin_slug ) > 200 ? 'md5:' . md5( $plugin_slug ) : $plugin_slug ); - wp_cache_delete( $plugin_details_cache_key, 'update-check-3' ); - - // Clear plugin info caches also - if ( defined( 'GLOTPRESS_LOCALES_PATH' ) && GLOTPRESS_LOCALES_PATH ) { - require_once GLOTPRESS_LOCALES_PATH; - - $locales = array_filter( array_values( wp_list_pluck( \GP_Locales::locales(), 'wp_locale' ) ) ); - - foreach ( $locales as $locale ) { - $cache_key = 'plugin_information:' - . ( strlen( $plugin_slug ) > 200 ? 'md5:' . md5( $plugin_slug ) : $plugin_slug ) - . ":{$locale}"; - wp_cache_delete( $cache_key, 'plugin_api_info' ); - } - } + self::clear_plugin_caches( $plugin_slug ); // Sync the latest version to Stats. if ( function_exists( '\WordPressdotorg\Stats\sync_latest_version' ) ) { \WordPressdotorg\Stats\sync_latest_version( - 'plugin', + 'plugin', array( - $plugin_slug => $version + $plugin_slug => $version, ) ); } @@ -208,6 +196,93 @@ public static function update_single_plugin( $plugin_slug ) { return true; } + /** + * Sync the status-dependent `update_source` fields for a plugin whose + * version bump is deferred by a release cooldown. + * + * The row keeps serving the previous release's data; only its availability + * and closure meta follow the plugin's current status. `version` and + * `last_updated` are deliberately left untouched: the stale freshness + * marker keeps the plugin matching cron_trigger()'s out-of-date query, so + * the backup recovery path survives even when the version clauses are + * blinded by their 128-character truncation allowance. + * + * @param \WP_Post $post The plugin post. + * @param string|null $row_meta The row's current `meta` column value. + * @return bool Whether the row changed. + */ + protected static function update_row_availability( $post, $row_meta ) { + global $wpdb; + + $meta = maybe_unserialize( $row_meta ); + $meta = is_array( $meta ) ? $meta : array(); + unset( $meta['closed_at'], $meta['closed_reason'] ); + $meta = array_merge( $meta, self::get_close_meta( $post ) ); + + $updated = $wpdb->update( + $wpdb->prefix . 'update_source', + array( + 'available' => (int) self::is_available( $post ), + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Matches the update_source meta format. + 'meta' => $meta ? serialize( $meta ) : '', + ), + array( 'plugin_slug' => $post->post_name ) + ); + + if ( $updated ) { + self::clear_plugin_caches( $post->post_name ); + } + + return (bool) $updated; + } + + /** + * Whether a plugin's `update_source` row should be marked available. + * + * @param \WP_Post $post The plugin post. + * @return bool + */ + protected static function is_available( $post ) { + return in_array( $post->post_status, array( 'publish', 'disabled' ), true ); + } + + /** + * Return the closure fields for a plugin's `update_source` meta. + * + * @param \WP_Post $post The plugin post. + * @return array Empty for plugins that are not disabled or closed. + */ + protected static function get_close_meta( $post ) { + $meta = array(); + + if ( in_array( $post->post_status, array( 'disabled', 'closed' ), true ) ) { + $closed_data = Template::get_close_data( $post ); + if ( $closed_data ) { + // Close date is sometimes unknown, only include the Day of closure. + $meta['closed_at'] = $closed_data['date'] ? gmdate( 'Y-m-d', strtotime( $closed_data['date'] ) ) : false; + if ( $closed_data['public'] ) { + $meta['closed_reason'] = $closed_data['reason'] ? $closed_data['reason'] : 'unknown'; + } + } + } + + return $meta; + } + + /** + * Clear the update-check and plugin information caches for a plugin. + * + * @param string $plugin_slug The plugin slug. + */ + protected static function clear_plugin_caches( $plugin_slug ) { + // ~34char prefix, Memcache limit of 255char per key. + $plugin_details_cache_key = 'plugin_details:' . ( strlen( $plugin_slug ) > 200 ? 'md5:' . md5( $plugin_slug ) : $plugin_slug ); + wp_cache_delete( $plugin_details_cache_key, 'update-check-3' ); + + // Clear plugin info caches also. + Plugins_Info_API::flush_plugin_information_cache( $plugin_slug ); + } + /** * Determine the release timestamp for a plugin version. * diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Cooldown_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Cooldown_Test.php new file mode 100644 index 0000000000..9cdef8e8e9 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Cooldown_Test.php @@ -0,0 +1,231 @@ + 'cooldown-test-' . ( ++self::$plugin_count ), + 'post_title' => 'Cooldown Sync Test Plugin', + 'post_status' => 'publish', + ) + ); + + $this->assertInstanceOf( \WP_Post::class, $plugin ); + $this->plugin = $plugin; + + /* + * The stub update_source table survives across runs — the WP test + * installer only drops core tables — so clear leftovers that would + * collide with this run's plugin ID or read as a served version. + */ + global $wpdb; + $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_id' => $this->plugin->ID ) ); + $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_slug' => $this->plugin->post_name ) ); + + update_post_meta( $this->plugin->ID, 'version', self::STAGED_VERSION ); + update_post_meta( $this->plugin->ID, 'stable_tag', self::STAGED_VERSION ); + update_post_meta( + $this->plugin->ID, + 'releases', + array( + array( + 'date' => time(), + 'tag' => self::STAGED_VERSION, + 'version' => self::STAGED_VERSION, + 'zips_built' => true, + 'zips_built_from_revision' => 0, + 'confirmations' => array(), + 'confirmed' => true, + 'confirmations_required' => 0, + 'committer' => array(), + 'revision' => array(), + 'release_delay' => DAY_IN_SECONDS, + ), + ) + ); + } + + /** + * Insert an update_source row serving the previous version. + */ + private function insert_served_row(): void { + global $wpdb; + + $wpdb->insert( + $wpdb->prefix . 'update_source', + array( + 'plugin_id' => $this->plugin->ID, + 'plugin_slug' => $this->plugin->post_name, + 'available' => 1, + 'version' => self::SERVED_VERSION, + 'stable_tag' => self::SERVED_VERSION, + 'plugin_name' => $this->plugin->post_title, + 'requires_plugins' => '', + 'last_updated' => $this->plugin->post_modified, + ) + ); + } + + /** + * Fetch the plugin's update_source row. + * + * @return object|null The row, or null when none exists. + */ + private function get_row(): ?object { + global $wpdb; + + return $wpdb->get_row( + $wpdb->prepare( + "SELECT available, version, meta FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", + $this->plugin->post_name + ) + ); + } + + /** + * Set the plugin's status, mirroring the closure meta the admin UI writes. + * + * @param string $status The new post status. + */ + private function set_status( string $status ): void { + wp_update_post( + array( + 'ID' => $this->plugin->ID, + 'post_status' => $status, + ) + ); + + if ( in_array( $status, array( 'closed', 'disabled' ), true ) ) { + update_post_meta( $this->plugin->ID, '_close_reason', 'security-issue' ); + update_post_meta( $this->plugin->ID, 'plugin_closed_date', current_time( 'mysql' ) ); + } else { + delete_post_meta( $this->plugin->ID, '_close_reason' ); + delete_post_meta( $this->plugin->ID, 'plugin_closed_date' ); + } + } + + /** + * A new version inside its cooldown stays deferred; the row keeps serving + * the previous version. + */ + public function test_version_bump_is_deferred_during_cooldown(): void { + $this->insert_served_row(); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $row = $this->get_row(); + $this->assertSame( '1', $row->available ); + $this->assertSame( self::SERVED_VERSION, $row->version ); + $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); + } + + /** + * Closing a plugin mid-cooldown withdraws its row immediately, still on + * the served version. + */ + public function test_closure_during_cooldown_reaches_row_immediately(): void { + $this->insert_served_row(); + $this->set_status( 'closed' ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $row = $this->get_row(); + $this->assertSame( '0', $row->available ); + $this->assertStringContainsString( 'closed_at', (string) $row->meta ); + $this->assertSame( self::SERVED_VERSION, $row->version ); + } + + /** + * Disabling a plugin mid-cooldown records its closure meta while the row + * stays available. + */ + public function test_disable_during_cooldown_records_closure_meta(): void { + $this->insert_served_row(); + $this->set_status( 'disabled' ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $row = $this->get_row(); + $this->assertSame( '1', $row->available ); + $this->assertStringContainsString( 'closed_at', (string) $row->meta ); + $this->assertSame( self::SERVED_VERSION, $row->version ); + } + + /** + * Reopening a closed plugin mid-cooldown restores its row immediately; + * only the version bump keeps waiting for the cooldown. + */ + public function test_reopen_during_cooldown_restores_row(): void { + $this->insert_served_row(); + + $this->set_status( 'closed' ); + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $this->set_status( 'publish' ); + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $row = $this->get_row(); + $this->assertSame( '1', $row->available ); + $this->assertStringNotContainsString( 'closed_at', (string) $row->meta ); + $this->assertSame( self::SERVED_VERSION, $row->version ); + } + + /** + * A first-ever release in cooldown has no row to sync; none is created + * until the cooldown expires. + */ + public function test_first_release_in_cooldown_creates_no_row(): void { + $this->set_status( 'closed' ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $this->assertNull( $this->get_row() ); + } +}