get_param('key') ?? ''))); // Only a value shaped like a license key gets a bucket: random junk must not be // able to write an unbounded number of transients (wp_options rows when there is // no persistent object cache). Filterable for plugins with other key formats. $shape = (string) apply_filters('softminal_cache_license_rate_limit_key_pattern', '/^[A-Z0-9_-]{8,80}$/'); if ($key !== '' && $shape !== '' && !preg_match($shape, $key)) { $key = ''; } if ($limitPerKey > 0 && $key !== '' && self::exceeded('softminal_cache_rl_k_' . md5($key), $limitPerKey)) { return self::tooMany(); } $ip = self::clientIp(); if ($limitPerIp > 0 && $ip !== '' && self::exceeded('softminal_cache_rl_i_' . md5($ip), $limitPerIp)) { return self::tooMany(); } return true; } /** * Increment the bucket's counter and report whether it is now over the limit. */ private static function exceeded(string $bucket, int $limit): bool { $now = time(); $state = get_transient($bucket); // Fixed window: the reset time is set once, when the window opens. // Re-saving with the full TTL on every hit would keep extending it, so // a steady trickle of requests would never get a fresh allowance. if (!is_array($state) || !isset($state['count'], $state['reset']) || (int) $state['reset'] <= $now) { $state = ['count' => 0, 'reset' => $now + self::WINDOW]; } if ((int) $state['count'] >= $limit) { return true; } $state['count'] = (int) $state['count'] + 1; set_transient($bucket, $state, max(1, (int) $state['reset'] - $now)); return false; } private static function clientIp(): string { $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; $ip = (string) apply_filters('softminal_cache_rate_limit_client_ip', $ip); return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : ''; } private static function tooMany(): \WP_Error { return new \WP_Error('rate_limited', 'Too many requests. Please try again later.', ['status' => 429]); } }