diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-customizations.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-customizations.php index 79ff49ea5a..f965fa9780 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-customizations.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-customizations.php @@ -79,6 +79,7 @@ private function __construct() { add_action( 'save_post', array( __NAMESPACE__ . '\Metabox\Release_Confirmation', 'save_post' ) ); add_action( 'save_post', array( __NAMESPACE__ . '\Metabox\Author_Notice', 'save_post' ) ); add_action( 'save_post', array( __NAMESPACE__ . '\Metabox\Reviewer', 'save_post' ) ); + add_action( 'save_post', array( __NAMESPACE__ . '\Metabox\Controls', 'save_post' ) ); } /** diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php index c80606cdc1..12c6c46c2b 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php @@ -482,7 +482,7 @@ public function clear_reviewer( $post ) { * Flush the caches for the plugin. */ protected function flush_caches( $post ) { - // Update the API endpoints with the new data + // Update the API endpoints with the new data. API_Update_Updater::update_single_plugin( $post->post_name ); Plugins_Info_API::flush_plugin_information_cache( $post->post_name ); } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php index 052f6f5a15..3d85569f28 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php @@ -2,6 +2,8 @@ namespace WordPressdotorg\Plugin_Directory\Admin\Metabox; use WordPressdotorg\Plugin_Directory\Admin\Status_Transitions; +use WordPressdotorg\Plugin_Directory\Jobs\API_Update_Updater; +use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Template; /** @@ -27,6 +29,7 @@ static function display() {
@@ -42,6 +45,116 @@ static function display() { ID, 'version', true ); + if ( ! $version ) { + return; + } + + $release = Plugin_Directory::get_release( $post, $version ); + if ( ! $release ) { + return; + } + + $release_delay = (int) ( $release['release_delay'] ?? 0 ); + if ( ! $release_delay ) { + return; + } + + $cooldown_until = API_Update_Updater::compute_release_time( $post, $release ) + $release_delay; + if ( $cooldown_until <= time() ) { + return; + } + + ?> +
+

+ +

+ +

+ + +

+

+ +

+ +
+ post_type ) { + return; + } + + if ( ! current_user_can( 'plugin_review', $post ) ) { + return; + } + + // Re-verify the post.php form nonce that core already checked, to satisfy phpcs + // and to make the security boundary explicit. + check_admin_referer( 'update-post_' . $post_id ); + + $version = get_post_meta( $post->ID, 'version', true ); + $submitted_version = sanitize_text_field( wp_unslash( $_POST['force_release_version'] ) ); + if ( $submitted_version !== $version ) { + // Submitted version doesn't match current — a newer commit landed since the form was rendered. + return; + } + + $reason = isset( $_POST['force_release_reason'] ) + ? trim( sanitize_textarea_field( wp_unslash( $_POST['force_release_reason'] ) ) ) + : ''; + if ( ! $reason ) { + return; + } + + API_Update_Updater::force_release( $post->post_name, $reason ); + } + /** * Get button label for setting the plugin status. * diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php index f0699662ec..2661bc989f 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php @@ -1729,6 +1729,10 @@ public static function add_release( $plugin, $data ) { 'confirmations_required' => (int) $plugin->release_confirmation, 'committer' => [], 'revision' => [], + // Captures the release cooldown active at creation time so future filter/constant + // changes don't retroactively affect in-flight releases. Reviewers force-release + // by overriding this to 0 — see API_Update_Updater::force_release(). + 'release_delay' => get_release_cooldown_delay( $plugin->post_name ), ]; // Fill the $release with the newish data. This could/should use wp_parse_args()? 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 21a83ecb2d..f24821258a 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 @@ -3,6 +3,7 @@ use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Template; +use WordPressdotorg\Plugin_Directory\Tools; /** * Handles interfacing with the api.WordPress.org/plugin/update-check/ API. @@ -67,20 +68,61 @@ public static function cron_trigger() { /** * Updates a single plugins `update_source` data. + * + * @param string $plugin_slug The plugin slug. + * @return bool */ - public static function update_single_plugin( $plugin_slug, $self_loop = false ) { + public static function update_single_plugin( $plugin_slug ) { global $wpdb; $post = Plugin_Directory::get_plugin_post( $plugin_slug ); if ( ! $post || ! in_array( $post->post_status, array( 'publish', 'disabled', 'closed' ) ) ) { $wpdb->delete( $wpdb->prefix . 'update_source', compact( 'plugin_slug' ) ); + wp_clear_scheduled_hook( "release_to_update_api:{$plugin_slug}" ); return true; } $version = get_post_meta( $post->ID, 'version', true ); $requires_plugins = get_post_meta( $post->ID, 'requires_plugins', true ); - $meta = array( - 'release_time' => strtotime( $post->version_date ?: $post->post_modified ), + $release = Plugin_Directory::get_release( $post, $version ); + $release_time = self::compute_release_time( $post, $release ); + $existing_version = (string) $wpdb->get_var( + $wpdb->prepare( + "SELECT version FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", + $post->post_name + ) + ); + + $release_delay = (int) ( $release['release_delay'] ?? 0 ); + + /* + * Defer the write for new versions still inside the cooldown window. While + * deferred, the existing `update_source` row (carrying the previous version) + * continues to be served by the update API. Reviewers force-release by setting + * `release_delay = 0` on the release meta. + * + * 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. + */ + 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 ); + return true; + } + } + + // When publishing a new version under an active cooldown, anchor `release_time` + // to now — that's the moment the version is actually available to sites. Keeps + // phased_rollout()'s `manual-updates-24hr` window measuring from public availability, + // even if the commit/confirmation was long ago because the cooldown deferred the write. + if ( $release_delay && $existing_version !== (string) $version ) { + $release_time = time(); + } + + $meta = array( + 'release_time' => $release_time, 'last_version' => $post->last_version ?? '', 'last_stable_tag' => $post->last_stable_tag ?? '', ); @@ -96,15 +138,6 @@ public static function update_single_plugin( $plugin_slug, $self_loop = false ) } } - $release = Plugin_Directory::get_release( $post, $version ); - if ( - $release && - $release['confirmations_required'] && - $release['confirmations'] - ) { - $meta['release_time'] = max( $release['confirmations'] ); - } - // Add phased rollout strategy data if needed. if ( $release && ! empty( $release['rollout_strategy'] ) ) { $meta['rollout'] = array( @@ -112,6 +145,10 @@ public static function update_single_plugin( $plugin_slug, $self_loop = false ) ); } + // The deferred event (if any) has either fired or been pre-empted by a force-release + // or status change. Clear any leftover schedule so the cron table doesn't grow. + wp_clear_scheduled_hook( "release_to_update_api:{$post->post_name}" ); + $data = array( 'plugin_id' => $post->ID, 'plugin_slug' => $post->post_name, @@ -171,6 +208,102 @@ public static function update_single_plugin( $plugin_slug, $self_loop = false ) return true; } + /** + * Determine the release timestamp for a plugin version. + * + * Falls back through the commit timestamp on the plugin post, and is replaced by the + * latest committer-confirmation time when release confirmations are required (the + * version isn't really "released" until the last confirmation lands). + * + * @param \WP_Post $post The plugin post. + * @param array|bool $release The release row from Plugin_Directory::get_release(), or false. + * @return int Unix timestamp. + */ + public static function compute_release_time( $post, $release ) { + $release_time = strtotime( $post->version_date ? $post->version_date : $post->post_modified ); + + if ( + $release && + $release['confirmations_required'] && + $release['confirmations'] + ) { + $release_time = max( $release['confirmations'] ); + } + + return $release_time; + } + + /** + * Schedule a deferred release-to-update-api cron event for a plugin, replacing + * any earlier event so a follow-up commit fully resets the cooldown window. + * + * @param string $plugin_slug The plugin slug. + * @param int $cooldown_until Unix timestamp when the deferred event should fire. + */ + public static function queue_release_to_update_api( $plugin_slug, $cooldown_until ) { + wp_clear_scheduled_hook( "release_to_update_api:{$plugin_slug}" ); + wp_schedule_single_event( $cooldown_until, "release_to_update_api:{$plugin_slug}" ); + } + + /** + * Cron handler for `release_to_update_api:{slug}`. Fires when the cooldown + * expires; writes the new version to `update_source` immediately. The slug + * is recovered from the dynamic hook name so no args need flow through cron. + */ + public static function cron_trigger_release() { + list( , $plugin_slug ) = explode( ':', current_filter(), 2 ); + self::update_single_plugin( $plugin_slug ); + } + + /** + * Reviewer force-release: clear the cooldown for a plugin's current version and + * write it to `update_source` immediately. Logs the action with the supplied reason. + * + * Capability checks must be performed by the caller. + * + * @param string $plugin_slug The plugin slug. + * @param string $reason Free-text reason recorded in the audit log. + * @param \WP_User $user The acting user. Defaults to the current user. + * @return bool True on success. + */ + public static function force_release( $plugin_slug, $reason, $user = null ) { + if ( ! $user ) { + $user = wp_get_current_user(); + } + + $post = Plugin_Directory::get_plugin_post( $plugin_slug ); + if ( ! $post ) { + return false; + } + + $version = get_post_meta( $post->ID, 'version', true ); + $release = Plugin_Directory::get_release( $post, $version ); + + if ( ! $release ) { + return false; + } + + Tools::audit_log( + sprintf( + 'Force-released version %s, bypassing the %d-hour release cooldown. Reason: %s', + $version, + (int) ( $release['release_delay'] ?? 0 ) / HOUR_IN_SECONDS, + $reason + ), + $post + ); + + Plugin_Directory::add_release( + $post, + array( + 'tag' => $release['tag'], + 'release_delay' => 0, + ) + ); + + return self::update_single_plugin( $plugin_slug ); + } + static function get_plugin_assets( $post ) { $icons = $banners = $banners_rtl = array(); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-manager.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-manager.php index ebc56fc74e..386552165a 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-manager.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-manager.php @@ -18,11 +18,12 @@ class Manager { * @var array */ public static $wildcard_cron_tasks = array( - 'import_plugin' => array( __NAMESPACE__ . '\Plugin_Import', 'cron_trigger' ), - 'import_plugin_i18n' => array( __NAMESPACE__ . '\Plugin_i18n_Import', 'cron_trigger' ), - 'import_zip' => array( __NAMESPACE__ . '\Plugin_ZIP_Import', 'cron_trigger' ), - 'scan_plugin' => array( __NAMESPACE__ . '\Plugin_Scan', 'cron_trigger' ), - 'create_svn_repo' => array( __NAMESPACE__ . '\SVN_Repo_Creation', 'cron_trigger' ), + 'import_plugin' => array( __NAMESPACE__ . '\Plugin_Import', 'cron_trigger' ), + 'import_plugin_i18n' => array( __NAMESPACE__ . '\Plugin_i18n_Import', 'cron_trigger' ), + 'import_zip' => array( __NAMESPACE__ . '\Plugin_ZIP_Import', 'cron_trigger' ), + 'scan_plugin' => array( __NAMESPACE__ . '\Plugin_Scan', 'cron_trigger' ), + 'create_svn_repo' => array( __NAMESPACE__ . '\SVN_Repo_Creation', 'cron_trigger' ), + 'release_to_update_api' => array( __NAMESPACE__ . '\API_Update_Updater', 'cron_trigger_release' ), ); /** diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/plugin-directory.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/plugin-directory.php index 6d7e745265..45b61e3a2b 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/plugin-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/plugin-directory.php @@ -25,6 +25,53 @@ */ define( __NAMESPACE__ . '\PLUGIN_DIR', __DIR__ ); +/** + * Delay between a plugin release being committed (or its final author confirmation) + * and the new version being written to the `update_source` table — and so served to + * sites by the api.wordpress.org plugin update-check API. The previous version remains + * served until the cooldown elapses. Mitigates supply-chain attacks by giving scanners + * and humans a window to flag bad releases. Plugin reviewers can bypass the cooldown + * via the wp-admin force-release action; see Jobs\API_Update_Updater::update_single_plugin(). + * + * Defers to the shared WPORG_PLUGIN_THEME_RELEASE_DELAY constant when it's defined + * so the plugin and theme directories can be tuned (or disabled) in lockstep from a + * single override point. + * + * Defaults to 0 (cooldown disabled, releases served immediately) for now; this will be + * raised once the surrounding workflow is ready. Can be pre-defined in global config to + * override the default. + */ +if ( ! defined( __NAMESPACE__ . '\RELEASE_COOL_DOWN_DELAY' ) ) { + define( __NAMESPACE__ . '\RELEASE_COOL_DOWN_DELAY', defined( 'WPORG_PLUGIN_THEME_RELEASE_DELAY' ) ? WPORG_PLUGIN_THEME_RELEASE_DELAY : 0 ); +} + +/** + * Returns the release cooldown delay, in seconds, for a plugin. + * + * The RELEASE_COOL_DOWN_DELAY constant provides the default, which is then passed through + * the `wporg_plugins_release_cooldown_delay` filter so the delay can be shortened, + * extended, or removed (return 0 to disable the cooldown) on a per-plugin basis. The + * plugin slug is passed to the filter when it is known. + * + * This is captured onto each release at creation time (see Plugin_Directory::add_release()), + * so changing the filter does not retroactively alter the cooldown of in-flight releases. + * + * @param string $plugin_slug The slug of the plugin being released, if known. + * @return int Delay in seconds. 0 disables the cooldown (the version is served immediately). + */ +function get_release_cooldown_delay( $plugin_slug = '' ) { + /** + * Filters the release cooldown delay for a plugin. + * + * Return 0 to disable the cooldown (the version is served as soon as it's imported), or + * a larger/smaller number of seconds to lengthen or shorten the delay for this plugin. + * + * @param int $delay The default delay in seconds (the RELEASE_COOL_DOWN_DELAY constant). + * @param string $plugin_slug The slug of the plugin being released, or '' when not known. + */ + return (int) apply_filters( 'wporg_plugins_release_cooldown_delay', RELEASE_COOL_DOWN_DELAY, $plugin_slug ); +} + // Register an Autoloader for all files require __DIR__ . '/class-autoloader.php'; Autoloader\register_class_path( __NAMESPACE__, __DIR__ ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php index 977fd2a4f4..862b1630b0 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php @@ -265,6 +265,8 @@ static function get_approval_text( $plugin, $data ) { ); } + self::render_cooldown_status( $data ); + echo ''; $text = ob_get_clean(); @@ -280,6 +282,48 @@ static function get_approval_text( $plugin, $data ) { return apply_filters( 'wporg_plugins_release_approval_text', $text, $plugin, $data ); } + /** + * Render a single line describing the cooldown state of a release: pending serve time. + * Skipped for releases without a cooldown delay (feature off at release creation, or + * force-released), discarded releases, releases that haven't moved past + * confirmation/processing, or where the cooldown window has elapsed. + * + * @param array $data The release row from Plugin_Directory::get_releases(). + */ + protected static function render_cooldown_status( $data ) { + $release_delay = (int) ( $data['release_delay'] ?? 0 ); + if ( ! $release_delay ) { + return; + } + + if ( ! empty( $data['discarded'] ) ) { + return; + } + + // Skip when the release hasn't moved past the confirmation/processing stage yet. + if ( $data['confirmations_required'] && ( ! $data['confirmed'] || ! $data['zips_built'] ) ) { + return; + } + + $release_time = $data['confirmations'] ? max( $data['confirmations'] ) : (int) $data['date']; + $cooldown_until = $release_time + $release_delay; + + if ( $cooldown_until <= time() ) { + return; + } + + $message = sprintf( + /* translators: %s: relative time until cooldown expires */ + __( 'Will be served to sites in %s.', 'wporg-plugins' ), + human_time_diff( time(), $cooldown_until ) + ); + printf( + '%s
', + esc_attr( gmdate( 'Y-m-d H:i:s', $cooldown_until ) ), + esc_html( $message ) + ); + } + static function get_actions( $plugin, $data ) { $buttons = []; @@ -422,6 +466,63 @@ static function template_redirect() { add_filter( 'wporg_noindex_request', '__return_true' ); } + /** + * Surfaces an in-cooldown notice to committers on the plugin's public page. + * + * Bails when the viewer isn't a committer, when there's no current release in + * an active cooldown window, or when the release was force-released + * (release_delay = 0 ⇒ no cooldown). + * + * @param WP_Post $post The currently displayed post. + */ + public static function frontend_cooldown_notice( $post = null ) { + $post = get_post( $post ); + + if ( ! $post || ! current_user_can( 'plugin_admin_edit', $post ) ) { + return; + } + + $version = get_post_meta( $post->ID, 'version', true ); + if ( ! $version ) { + return; + } + + $release = Plugin_Directory::get_release( $post, $version ); + if ( ! $release ) { + return; + } + + $release_delay = (int) ( $release['release_delay'] ?? 0 ); + if ( ! $release_delay ) { + return; + } + + $release_time = $release['confirmations'] ? max( $release['confirmations'] ) : (int) $release['date']; + $cooldown_until = $release_time + $release_delay; + + if ( $cooldown_until <= time() ) { + return; + } + + printf( + '

%s

', + wp_kses( + sprintf( + /* translators: 1: plugin version, 2: relative time until cooldown expires, 3: delay duration in hours, 4: plugins@wordpress.org link */ + __( 'Version %1$s will be released to sites in about %2$s. WordPress.org currently delays plugin updates by %3$d hours so moderators and security scanners can review changes before they reach users. If this update fixes a security issue that needs to ship sooner, contact %4$s.', 'wporg-plugins' ), + '' . esc_html( $version ) . '', + esc_html( human_time_diff( time(), $cooldown_until ) ), + (int) ( $release_delay / HOUR_IN_SECONDS ), + 'plugins@wordpress.org' + ), + array( + 'code' => array(), + 'a' => array( 'href' => true ), + ) + ) + ); + } + /** * Displays the notice on the plugin front-end. * diff --git a/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/inc/template-tags.php b/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/inc/template-tags.php index 10dd3ec7d7..3357c335d3 100644 --- a/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/inc/template-tags.php +++ b/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/inc/template-tags.php @@ -262,6 +262,13 @@ function the_unconfirmed_releases_notice() { return Release_Confirmation::frontend_unconfirmed_releases_notice(); } +/** + * Render the in-cooldown release notice for committers on a plugin's public page. + */ +function the_release_cooldown_notice() { + return Release_Confirmation::frontend_cooldown_notice(); +} + function the_no_self_management_notice() { $post = get_post(); diff --git a/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/template-parts/plugin-single.php b/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/template-parts/plugin-single.php index b28bcffb99..cdf32fc5e7 100644 --- a/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/template-parts/plugin-single.php +++ b/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/template-parts/plugin-single.php @@ -27,6 +27,7 @@ +