PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.22
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.22
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-rate-limiter.php

class-metasync-rate-limiter.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.22, at includes/class-metasync-rate-limiter.php

262 lines 8.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MetaSync Rate Limiter
4 *
5 * A robust rate limiting implementation that uses per-key WordPress
6 * transients so each IP/token hash is stored as its own row with a
7 * TTL matching the rate-limit window. This avoids the contention,
8 * race conditions, and unbounded growth of a single serialized blob.
9 *
10 * @package Metasync
11 * @subpackage Metasync/includes
12 * @since 2.5.17
13 */
14
15 # Prevent direct access
16 if (!defined('ABSPATH')) {
17 exit;
18 }
19
20 class Metasync_Rate_Limiter
21 {
22 /**
23 * Prefix for per-key rate limit transients.
24 */
25 const TRANSIENT_PREFIX = 'metasync_rl_';
26
27 /**
28 * Singleton instance
29 *
30 * @var Metasync_Rate_Limiter|null
31 */
32 private static $instance = null;
33
34 /**
35 * Get singleton instance
36 *
37 * @return Metasync_Rate_Limiter
38 */
39 public static function get_instance()
40 {
41 if (self::$instance === null) {
42 self::$instance = new self();
43 }
44 return self::$instance;
45 }
46
47 /**
48 * Private constructor for singleton pattern
49 */
50 private function __construct()
51 {
52 # Cancel any previously scheduled hourly cleanup job — transient
53 # expiry handles cleanup automatically now.
54 wp_clear_scheduled_hook('metasync_rate_limit_cleanup');
55 }
56
57 /**
58 * Build the transient key for a given full rate-limit key.
59 *
60 * Hashing keeps the resulting transient name comfortably under
61 * WordPress's 172-char limit regardless of the input length.
62 *
63 * @param string $full_key Full rate-limit key (prefix + key).
64 * @return string Transient name.
65 */
66 private function get_transient_key($full_key)
67 {
68 return self::TRANSIENT_PREFIX . substr(hash('sha256', $full_key), 0, 40);
69 }
70
71 /**
72 * Check and increment rate limit for a given key.
73 *
74 * Each key is tracked in its own transient, so concurrent
75 * requests for different keys never share a write lock.
76 *
77 * @param string $key Unique identifier for rate limiting (e.g., hashed token or IP)
78 * @param int $max_attempts Maximum number of attempts allowed
79 * @param int $window_seconds Time window in seconds
80 * @param string $prefix Optional prefix for the rate limit key
81 * @return bool|WP_Error True if under limit, WP_Error if rate limit exceeded
82 */
83 public function check_rate_limit($key, $max_attempts, $window_seconds, $prefix = '')
84 {
85 $full_key = $prefix . $key;
86 $transient_key = $this->get_transient_key($full_key);
87 $now = time();
88
89 $data = get_transient($transient_key);
90
91 # Initialize entry if not present or expired
92 if ($data === false || !is_array($data) || !isset($data['expires_at']) || $data['expires_at'] < $now) {
93 $data = array(
94 'attempts' => 1,
95 'first_attempt_at' => $now,
96 'expires_at' => $now + $window_seconds,
97 'window_seconds' => $window_seconds,
98 );
99 set_transient($transient_key, $data, $window_seconds);
100
101 return true;
102 }
103
104 # Check if rate limit exceeded
105 if ($data['attempts'] >= $max_attempts) {
106 $remaining_seconds = $data['expires_at'] - $now;
107 $remaining_minutes = ceil($remaining_seconds / 60);
108
109 return new WP_Error(
110 'rate_limit_exceeded',
111 sprintf(
112 'Too many attempts. Please try again in %d minute%s.',
113 $remaining_minutes,
114 $remaining_minutes > 1 ? 's' : ''
115 ),
116 array(
117 'retry_after' => $remaining_seconds,
118 'attempts' => $data['attempts'],
119 'max_attempts' => $max_attempts,
120 )
121 );
122 }
123
124 # Increment attempt count, preserving the original window end
125 $data['attempts']++;
126 $data['last_attempt_at'] = $now;
127 set_transient($transient_key, $data, max(1, $data['expires_at'] - $now));
128
129 return true;
130 }
131
132 /**
133 * Check IP-based rate limit
134 *
135 * @param int $max_attempts Maximum attempts per IP
136 * @param int $window_seconds Time window in seconds
137 * @return bool|WP_Error True if under limit, WP_Error if exceeded
138 */
139 public function check_ip_rate_limit($max_attempts, $window_seconds)
140 {
141 $ip_address = $this->get_client_ip();
142
143 if (empty($ip_address)) {
144 return true;
145 }
146
147 # Hash IP for privacy
148 $ip_hash = hash('sha256', $ip_address);
149
150 $result = $this->check_rate_limit($ip_hash, $max_attempts, $window_seconds, 'ip_');
151
152 if (is_wp_error($result)) {
153 # Replace error code for IP-specific error
154 return new WP_Error(
155 'ip_rate_limit_exceeded',
156 sprintf(
157 'Too many attempts from your IP address. Please try again in %d minute%s.',
158 ceil($result->get_error_data()['retry_after'] / 60),
159 ceil($result->get_error_data()['retry_after'] / 60) > 1 ? 's' : ''
160 ),
161 $result->get_error_data()
162 );
163 }
164
165 return $result;
166 }
167
168 /**
169 * Check token-based rate limit
170 *
171 * @param string $token The token to rate limit
172 * @param int $max_attempts Maximum attempts per token
173 * @param int $window_seconds Time window in seconds
174 * @return bool|WP_Error True if under limit, WP_Error if exceeded
175 */
176 public function check_token_rate_limit($token, $max_attempts, $window_seconds)
177 {
178 # Hash token for storage efficiency and privacy
179 $token_hash = hash('sha256', $token);
180
181 return $this->check_rate_limit($token_hash, $max_attempts, $window_seconds, 'token_');
182 }
183
184 /**
185 * Get client IP address
186 *
187 * Handles various proxy configurations and load balancers.
188 *
189 * @return string Client IP address
190 */
191 private function get_client_ip()
192 {
193 $ip_keys = array(
194 'HTTP_CF_CONNECTING_IP', # Cloudflare
195 'HTTP_X_REAL_IP', # Nginx proxy
196 'HTTP_X_FORWARDED_FOR', # Standard proxy header
197 'REMOTE_ADDR', # Direct connection
198 );
199
200 foreach ($ip_keys as $key) {
201 if (!empty($_SERVER[$key])) {
202 $ip = $_SERVER[$key];
203
204 # X-Forwarded-For may contain multiple IPs, get the first one
205 if ($key === 'HTTP_X_FORWARDED_FOR') {
206 $ips = explode(',', $ip);
207 $ip = trim($ips[0]);
208 }
209
210 # Validate IP format
211 if (filter_var($ip, FILTER_VALIDATE_IP)) {
212 return $ip;
213 }
214 }
215 }
216
217 return '';
218 }
219
220 /**
221 * Reset rate limit for a specific key
222 *
223 * Useful for testing or admin override.
224 *
225 * @param string $key Rate limit key
226 * @param string $prefix Optional prefix
227 * @return bool Success
228 */
229 public function reset_rate_limit($key, $prefix = '')
230 {
231 delete_transient($this->get_transient_key($prefix . $key));
232 return true;
233 }
234
235 /**
236 * Get rate limit status for a key (for debugging/admin UI)
237 *
238 * @param string $key Rate limit key
239 * @param string $prefix Optional prefix
240 * @return array|null Rate limit status or null if not found
241 */
242 public function get_rate_limit_status($key, $prefix = '')
243 {
244 $data = get_transient($this->get_transient_key($prefix . $key));
245
246 if ($data === false || !is_array($data)) {
247 return null;
248 }
249
250 $now = time();
251
252 return array(
253 'attempts' => $data['attempts'],
254 'expires_at' => $data['expires_at'],
255 'remaining_seconds' => max(0, $data['expires_at'] - $now),
256 'is_expired' => $data['expires_at'] < $now,
257 'first_attempt_at' => $data['first_attempt_at'],
258 'last_attempt_at' => isset($data['last_attempt_at']) ? $data['last_attempt_at'] : null,
259 );
260 }
261 }
262