*/ private const DEFAULTS = [ 'session_create' => [ 'max' => 30, 'window' => 3600, ], 'availability' => [ 'max' => 120, 'window' => 3600, ], 'coupon_validate' => [ 'max' => 10, 'window' => 60, ], 'manage_request_link' => [ 'max' => 3, 'window' => 3600, ], 'session_submit' => [ 'max' => 20, 'window' => 3600, ], 'payment_intent' => [ 'max' => 30, 'window' => 3600, ], 'unauthenticated_post' => [ 'max' => 60, 'window' => 3600, ], ]; /** * Seconds remaining until the current window resets (set after a failed attempt). * * @var int */ private int $last_retry_after = 0; /** * Attempt to consume one unit of quota. * * @param string $route_key Rate-limit profile key (filterable). * @param string $ip Client IP (hashed before storage). * @param string|null $extra Optional session token or email address. * * @since 0.1.0 */ public function attempt( string $route_key, string $ip, ?string $extra = null ): bool { $limits = $this->limits_for( $route_key ); $max = $limits['max']; $window = $limits['window']; $key = $this->storage_key( $route_key, $ip, $extra ); $state = get_transient( $key ); if ( ! is_array( $state ) || ! isset( $state['count'], $state['reset'] ) ) { $state = [ 'count' => 0, 'reset' => time() + $window, ]; } $now = time(); if ( (int) $state['reset'] <= $now ) { $state = [ 'count' => 0, 'reset' => $now + $window, ]; } if ( (int) $state['count'] >= $max ) { $this->last_retry_after = max( 1, (int) $state['reset'] - $now ); return false; } $state['count'] = (int) $state['count'] + 1; $ttl = max( 1, (int) $state['reset'] - $now ); set_transient( $key, $state, $ttl ); $this->last_retry_after = 0; return true; } /** * Retry-After seconds from the last failed attempt. * * @since 0.1.0 */ public function retry_after(): int { return $this->last_retry_after; } /** * Resolve max/window, honouring the bookline/rate_limit/{route} filter. * * @param string $route_key Profile key. * @return array{max: int, window: int} */ private function limits_for( string $route_key ): array { $defaults = self::DEFAULTS[ $route_key ] ?? self::DEFAULTS['unauthenticated_post']; /** * Filter a rate-limit profile so hosts behind shared proxies can adjust. * * @param array{max: int, window: int} $defaults Default max and window seconds. * @param string $route_key Profile key. * @since 0.1.0 */ $filtered = apply_filters( 'bookline/rate_limit/' . $route_key, $defaults, $route_key ); if ( ! is_array( $filtered ) || ! isset( $filtered['max'], $filtered['window'] ) ) { return $defaults; } return [ 'max' => max( 1, (int) $filtered['max'] ), 'window' => max( 1, (int) $filtered['window'] ), ]; } /** * Build a transient key from hashed inputs. * * @param string $route_key Profile key. * @param string $ip IP. * @param string|null $extra Extra bucket. */ private function storage_key( string $route_key, string $ip, ?string $extra ): string { $parts = [ 'bookline_rl', $route_key, hash( 'sha256', $ip ), ]; if ( null !== $extra && '' !== $extra ) { $parts[] = hash( 'sha256', strtolower( $extra ) ); } return implode( '_', $parts ); } }