true, 'attempts_per_hour' => 5, 'attempts_per_day' => 20, 'lockout_duration' => 3600, // 1 hour in seconds. ) ); if ( empty( $limits['enabled'] ) ) { return true; // Rate limiting disabled. } // Create unique key for this user/IP + form combination. $identifier = self::get_identifier( $user_id ); $transient_key_hour = 'affcf_rate_limit_hour_' . md5( $identifier . '_' . $form_id ); $transient_key_day = 'affcf_rate_limit_day_' . md5( $identifier . '_' . $form_id ); $lockout_key = 'affcf_lockout_' . md5( $identifier . '_' . $form_id ); // Check if locked out. $lockout = get_transient( $lockout_key ); if ( $lockout ) { $remaining_time = $lockout - time(); $minutes = ceil( $remaining_time / 60 ); return new WP_Error( 'rate_limit_lockout', sprintf( /* translators: %d: minutes until next attempt */ __( 'Too many submission attempts. Please try again in %d minutes.', 'airy-frontend-forms' ), $minutes ) ); } // Get current attempt counts. $attempts_hour = (int) get_transient( $transient_key_hour ); $attempts_day = (int) get_transient( $transient_key_day ); // Check hourly limit. $hour_limit = (int) $limits['attempts_per_hour']; if ( $hour_limit > 0 && $attempts_hour >= $hour_limit ) { // Apply lockout. $lockout_duration = (int) $limits['lockout_duration']; set_transient( $lockout_key, time() + $lockout_duration, $lockout_duration ); return new WP_Error( 'rate_limit_exceeded', sprintf( /* translators: %d: attempts allowed */ __( 'You have exceeded the maximum of %d submissions per hour. Please try again later.', 'airy-frontend-forms' ), $hour_limit ) ); } // Check daily limit. $day_limit = (int) $limits['attempts_per_day']; if ( $day_limit > 0 && $attempts_day >= $day_limit ) { return new WP_Error( 'rate_limit_exceeded', sprintf( /* translators: %d: maximum submissions allowed per day */ __( 'You have exceeded the maximum of %d submissions per day. Please try again tomorrow.', 'airy-frontend-forms' ), $day_limit ) ); } return true; } /** * Record submission attempt * * @param int $user_id User ID. * @param int $form_id Form ID. */ public static function record_attempt( $user_id, $form_id ) { $limits = get_option( 'affcf_rate_limit_settings', array( 'enabled' => true ) ); if ( empty( $limits['enabled'] ) ) { return; // Rate limiting disabled. } $identifier = self::get_identifier( $user_id ); $transient_key_hour = 'affcf_rate_limit_hour_' . md5( $identifier . '_' . $form_id ); $transient_key_day = 'affcf_rate_limit_day_' . md5( $identifier . '_' . $form_id ); // Increment hourly counter. $attempts_hour = (int) get_transient( $transient_key_hour ); set_transient( $transient_key_hour, $attempts_hour + 1, 3600 ); // 1 hour. // Increment daily counter. $attempts_day = (int) get_transient( $transient_key_day ); set_transient( $transient_key_day, $attempts_day + 1, 86400 ); // 24 hours. // Log attempt (removed to comply with PHPCS). } /** * Get unique identifier for user/IP * * @param int $user_id User ID. * @return string Unique identifier. */ private static function get_identifier( $user_id ) { // For logged-in users, use user ID. if ( $user_id > 0 ) { return 'user_' . $user_id; } // For guests, use IP address. $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; // Add user agent for additional uniqueness. $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; return 'ip_' . md5( $ip . $user_agent ); } /** * Clear rate limits for user * * @param int $user_id User ID. * @param int $form_id Optional form ID to clear specific form limits. */ public static function clear_limits( $user_id, $form_id = null ) { global $wpdb; $identifier = self::get_identifier( $user_id ); if ( $form_id ) { // Clear specific form. $pattern_hour = '_transient_affcf_rate_limit_hour_' . md5( $identifier . '_' . $form_id ); $pattern_day = '_transient_affcf_rate_limit_day_' . md5( $identifier . '_' . $form_id ); $pattern_lockout = '_transient_affcf_lockout_' . md5( $identifier . '_' . $form_id ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup of specific rate limit transients $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name IN (%s, %s, %s)", $pattern_hour, $pattern_day, $pattern_lockout ) ); } else { // Clear all rate limits for this user. $pattern = $wpdb->esc_like( '_transient_affcf_rate_limit_' ) . '%' . $wpdb->esc_like( $identifier ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup of rate limit transients by pattern $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $pattern ) ); } } /** * Get remaining attempts for user * * @param int $user_id User ID. * @param int $form_id Form ID. * @return array Remaining attempts info. */ public static function get_remaining( $user_id, $form_id ) { $limits = get_option( 'affcf_rate_limit_settings', array( 'enabled' => true, 'attempts_per_hour' => 5, 'attempts_per_day' => 20, ) ); if ( empty( $limits['enabled'] ) ) { return array( 'enabled' => false, ); } $identifier = self::get_identifier( $user_id ); $transient_key_hour = 'affcf_rate_limit_hour_' . md5( $identifier . '_' . $form_id ); $transient_key_day = 'affcf_rate_limit_day_' . md5( $identifier . '_' . $form_id ); $lockout_key = 'affcf_lockout_' . md5( $identifier . '_' . $form_id ); $attempts_hour = (int) get_transient( $transient_key_hour ); $attempts_day = (int) get_transient( $transient_key_day ); $lockout = get_transient( $lockout_key ); return array( 'enabled' => true, 'hour_limit' => (int) $limits['attempts_per_hour'], 'hour_used' => $attempts_hour, 'hour_remaining' => max( 0, $limits['attempts_per_hour'] - $attempts_hour ), 'day_limit' => (int) $limits['attempts_per_day'], 'day_used' => $attempts_day, 'day_remaining' => max( 0, $limits['attempts_per_day'] - $attempts_day ), 'locked_out' => (bool) $lockout, 'lockout_expires' => $lockout ? ( $lockout - time() ) : 0, ); } }