diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php index d6f07121b77..da64bb85fdd 100644 --- a/plugins/woocommerce/includes/class-wc-install.php +++ b/plugins/woocommerce/includes/class-wc-install.php @@ -165,6 +165,10 @@ class WC_Install { 'wc_update_560_create_refund_returns_page', 'wc_update_560_db_version', ), + '6.0.0' => array( + 'wc_update_600_migrate_rate_limit_options', + 'wc_update_600_db_version', + ), ); /** @@ -524,6 +528,7 @@ class WC_Install { wp_clear_scheduled_hook( 'woocommerce_cleanup_logs' ); wp_clear_scheduled_hook( 'woocommerce_geoip_updater' ); wp_clear_scheduled_hook( 'woocommerce_tracker_send_event' ); + wp_clear_scheduled_hook( 'woocommerce_cleanup_rate_limits' ); $ve = get_option( 'gmt_offset' ) > 0 ? '-' : '+'; @@ -545,6 +550,7 @@ class WC_Install { wp_schedule_event( time() + ( 6 * HOUR_IN_SECONDS ), 'twicedaily', 'woocommerce_cleanup_sessions' ); wp_schedule_event( time() + MINUTE_IN_SECONDS, 'fifteendays', 'woocommerce_geoip_updater' ); wp_schedule_event( time() + 10, apply_filters( 'woocommerce_tracker_event_recurrence', 'daily' ), 'woocommerce_tracker_send_event' ); + wp_schedule_event( time() + ( 3 * HOUR_IN_SECONDS ), 'daily', 'woocommerce_cleanup_rate_limits' ); } /** @@ -1039,6 +1045,13 @@ CREATE TABLE {$wpdb->prefix}wc_reserved_stock ( `timestamp` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', `expires` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (`order_id`, `product_id`) +) $collate; +CREATE TABLE {$wpdb->prefix}woocommerce_rate_limits ( + rate_limit_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + rate_limit_key varchar(200) NOT NULL, + rate_limit_expiry BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (rate_limit_id), + UNIQUE KEY rate_limit_key (rate_limit_key($max_index_length)) ) $collate; "; @@ -1074,6 +1087,7 @@ CREATE TABLE {$wpdb->prefix}wc_reserved_stock ( "{$wpdb->prefix}woocommerce_tax_rate_locations", "{$wpdb->prefix}woocommerce_tax_rates", "{$wpdb->prefix}wc_reserved_stock", + "{$wpdb->prefix}woocommerce_rate_limits", ); /** diff --git a/plugins/woocommerce/includes/class-wc-rate-limiter.php b/plugins/woocommerce/includes/class-wc-rate-limiter.php index aba4fb01489..a47f2f21fe9 100644 --- a/plugins/woocommerce/includes/class-wc-rate-limiter.php +++ b/plugins/woocommerce/includes/class-wc-rate-limiter.php @@ -32,13 +32,57 @@ defined( 'ABSPATH' ) || exit; class WC_Rate_Limiter { /** - * Constructs Option name from action identifier. + * Cache group. + */ + const CACHE_GROUP = 'wc_rate_limit'; + + /** + * Hook in methods. + */ + public static function init() { + add_action( 'woocommerce_cleanup_rate_limits', array( __CLASS__, 'cleanup' ) ); + } + + /** + * Constructs key name from action identifier. + * Left in for backwards compatibility. * * @param string $action_id Identifier of the action. * @return string */ public static function storage_id( $action_id ) { - return 'woocommerce_rate_limit_' . $action_id; + return $action_id; + } + + /** + * Gets a cache prefix. + * + * @param string $action_id Identifier of the action. + * @return string + */ + protected static function get_cache_key( $action_id ) { + return WC_Cache_Helper::get_cache_prefix( 'rate_limit' . $action_id ); + } + + /** + * Retrieve a cached rate limit. + * + * @param string $action_id Identifier of the action. + * @return bool|int + */ + protected static function get_cached( $action_id ) { + return wp_cache_get( self::get_cache_key( $action_id ), self::CACHE_GROUP ); + } + + /** + * Cache a rate limit. + * + * @param string $action_id Identifier of the action. + * @param int $expiry Timestamp when the limit expires. + * @return bool + */ + protected static function set_cache( $action_id, $expiry ) { + return wp_cache_set( self::get_cache_key( $action_id ), $expiry, self::CACHE_GROUP ); } /** @@ -48,10 +92,27 @@ class WC_Rate_Limiter { * @return bool */ public static function retried_too_soon( $action_id ) { - $next_try_allowed_at = get_option( self::storage_id( $action_id ) ); + global $wpdb; + + $next_try_allowed_at = self::get_cached( $action_id ); + + if ( false === $next_try_allowed_at ) { + $next_try_allowed_at = $wpdb->get_var( + $wpdb->prepare( + " + SELECT rate_limit_expiry + FROM {$wpdb->prefix}woocommerce_rate_limits + WHERE rate_limit_key = %s + ", + $action_id + ) + ); + + self::set_cache( $action_id, $next_try_allowed_at ); + } // No record of action running, so action is allowed to run. - if ( false === $next_try_allowed_at ) { + if ( null === $next_try_allowed_at ) { return false; } @@ -72,8 +133,41 @@ class WC_Rate_Limiter { * @return bool True if the option setting was successful, false otherwise. */ public static function set_rate_limit( $action_id, $delay ) { - $option_name = self::storage_id( $action_id ); + global $wpdb; + $next_try_allowed_at = time() + $delay; - return update_option( $option_name, $next_try_allowed_at ); + + $result = $wpdb->replace( + $wpdb->prefix . 'woocommerce_rate_limits', + array( + 'rate_limit_key' => $action_id, + 'rate_limit_expiry' => $next_try_allowed_at, + ), + array( '%s', '%d' ) + ); + + self::set_cache( $action_id, $next_try_allowed_at ); + + return false !== $result; + } + + /** + * Cleanup expired rate limits from the database and clear caches. + */ + public static function cleanup() { + global $wpdb; + + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->prefix}woocommerce_rate_limits WHERE rate_limit_expiry < %d", + time() + ) + ); + + if ( class_exists( 'WC_Cache_Helper' ) ) { + WC_Cache_Helper::invalidate_cache_group( self::CACHE_GROUP ); + } } } + +WC_Rate_Limiter::init(); diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php index 653b5a49010..a845efd3a23 100644 --- a/plugins/woocommerce/includes/wc-update-functions.php +++ b/plugins/woocommerce/includes/wc-update-functions.php @@ -2297,3 +2297,41 @@ function wc_update_560_create_refund_returns_page() { function wc_update_560_db_version() { WC_Install::update_db_version( '5.6.0' ); } + +/** + * Migrate rate limit options to the new table. + * + * See @link https://github.com/woocommerce/woocommerce/issues/27103. + */ +function wc_update_600_migrate_rate_limit_options() { + global $wpdb; + + $rate_limits = $wpdb->get_results( + " + SELECT option_name, option_value + FROM $wpdb->options + WHERE option_name LIKE 'woocommerce_rate_limit_add_payment_method_%' + ", + ARRAY_A + ); + $prefix_length = strlen( 'woocommerce_rate_limit_' ); + + foreach ( $rate_limits as $rate_limit ) { + $new_delay = (int) $rate_limit['option_value'] - time(); + + // Migrate the limit if it hasn't expired yet. + if ( 0 < $new_delay ) { + $action_id = substr( $rate_limit['option_name'], $prefix_length ); + WC_Rate_Limiter::set_rate_limit( $action_id, $new_delay ); + } + + delete_option( $rate_limit['option_name'] ); + } +} + +/** + * Update DB version to 6.0.0. + */ +function wc_update_600_db_version() { + WC_Install::update_db_version( '6.0.0' ); +} diff --git a/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-rate-limiter.php b/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-rate-limiter.php index 7ec2562d042..2ac073511f0 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-rate-limiter.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/util/class-wc-rate-limiter.php @@ -37,16 +37,69 @@ class WC_Tests_Rate_Limiter extends WC_Unit_Test_Case { $rate_limit_id_1 = $action_identifier . $user_1_id; $rate_limit_id_2 = $action_identifier . $user_2_id; - WC_Rate_Limiter::set_rate_limit( $rate_limit_id_1, 1 ); + WC_Rate_Limiter::set_rate_limit( $rate_limit_id_1, 0 ); $this->assertEquals( true, WC_Rate_Limiter::retried_too_soon( $rate_limit_id_1 ), 'retried_too_soon allowed action to run too soon before the delay.' ); $this->assertEquals( false, WC_Rate_Limiter::retried_too_soon( $rate_limit_id_2 ), 'retried_too_soon did not allow action to run for another user before the delay.' ); // As retired_too_soon bails if current time <= limit, the actual time needs to be at least 1 second after the limit. - sleep( 2 ); + sleep( 1 ); $this->assertEquals( false, WC_Rate_Limiter::retried_too_soon( $rate_limit_id_1 ), 'retried_too_soon did not allow action to run after the designated delay.' ); $this->assertEquals( false, WC_Rate_Limiter::retried_too_soon( $rate_limit_id_2 ), 'retried_too_soon did not allow action to run for another user after the designated delay.' ); } + /** + * Test setting the limit and running rate limited actions when missing cache. + */ + public function test_rate_limit_limits_without_cache() { + $action_identifier = 'action_1'; + $user_1_id = 10; + $user_2_id = 15; + + $rate_limit_id_1 = $action_identifier . $user_1_id; + $rate_limit_id_2 = $action_identifier . $user_2_id; + + WC_Rate_Limiter::set_rate_limit( $rate_limit_id_1, 0 ); + // Clear cached value for user 1. + wp_cache_delete( WC_Cache_Helper::get_cache_prefix( 'rate_limit' . $rate_limit_id_1 ), WC_Rate_Limiter::CACHE_GROUP ); + + $this->assertEquals( true, WC_Rate_Limiter::retried_too_soon( $rate_limit_id_1 ), 'retried_too_soon allowed action to run too soon before the delay.' ); + $this->assertEquals( false, WC_Rate_Limiter::retried_too_soon( $rate_limit_id_2 ), 'retried_too_soon did not allow action to run for another user before the delay.' ); + + // Clear cached values for both users. + wp_cache_delete( WC_Cache_Helper::get_cache_prefix( 'rate_limit' . $rate_limit_id_1 ), WC_Rate_Limiter::CACHE_GROUP ); + wp_cache_delete( WC_Cache_Helper::get_cache_prefix( 'rate_limit' . $rate_limit_id_2 ), WC_Rate_Limiter::CACHE_GROUP ); + + // As retired_too_soon bails if current time <= limit, the actual time needs to be at least 1 second after the limit. + sleep( 1 ); + + $this->assertEquals( false, WC_Rate_Limiter::retried_too_soon( $rate_limit_id_1 ), 'retried_too_soon did not allow action to run after the designated delay.' ); + $this->assertEquals( false, WC_Rate_Limiter::retried_too_soon( $rate_limit_id_2 ), 'retried_too_soon did not allow action to run for another user after the designated delay.' ); + } + + /** + * Test that rate limit option migration only processes unexpired limits. + */ + public function test_rate_limit_option_migration() { + global $wpdb; + + // Add some options to be migrated. + add_option( 'woocommerce_rate_limit_add_payment_method_123', time() + 1000 ); + add_option( 'woocommerce_rate_limit_add_payment_method_234', time() - 1 ); + + // Run the migration function. + include_once WC_Unit_Tests_Bootstrap::instance()->plugin_dir . '/includes/wc-update-functions.php'; + wc_update_600_migrate_rate_limit_options(); + + // Ensure that only the _123 limit was migrated. + $migrated = $wpdb->get_col( "SELECT rate_limit_key FROM {$wpdb->prefix}woocommerce_rate_limits" ); + + $this->assertCount( 1, $migrated ); + $this->assertEquals( 'add_payment_method_123', $migrated[0] ); + + // Verify that all rate limit options were deleted. + $this->assertFalse( get_option( 'woocommerce_rate_limit_add_payment_method_123' ) ); + $this->assertFalse( get_option( 'woocommerce_rate_limit_add_payment_method_234' ) ); + } } diff --git a/plugins/woocommerce/uninstall.php b/plugins/woocommerce/uninstall.php index 8a764b801a5..613dc9fd924 100644 --- a/plugins/woocommerce/uninstall.php +++ b/plugins/woocommerce/uninstall.php @@ -19,6 +19,7 @@ wp_clear_scheduled_hook( 'woocommerce_cleanup_personal_data' ); wp_clear_scheduled_hook( 'woocommerce_cleanup_logs' ); wp_clear_scheduled_hook( 'woocommerce_geoip_updater' ); wp_clear_scheduled_hook( 'woocommerce_tracker_send_event' ); +wp_clear_scheduled_hook( 'woocommerce_cleanup_rate_limits' ); /* * Only remove ALL product and page data if WC_REMOVE_ALL_DATA constant is set to true in user's