Files
wp-agentic-writer/includes/class-rate-limiter.php
Dwindi Ramadhana 690991c526 refactor: Cleanup git state - commit all staged changes
Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
2026-06-17 05:27:58 +07:00

217 lines
7.1 KiB
PHP

<?php
/**
* Rate Limiter for WP Agentic Writer REST Endpoints
*
* @package WP_Agentic_Writer
*/
/**
* Class WPAW_Rate_Limiter
*
* Simple WordPress transient-based rate limiter for REST endpoints.
*
* @since 0.3.0
*/
class WPAW_Rate_Limiter {
/**
* Default rate limits per endpoint type.
*
* @var array
*/
private static $default_limits = [
'chat' => [ 'limit' => 60, 'window' => 60 ], // 60/min
'chat_stream' => [ 'limit' => 60, 'window' => 60 ], // 60/min
'generate_plan' => [ 'limit' => 10, 'window' => 60 ], // 10/min
'stream_plan' => [ 'limit' => 10, 'window' => 60 ], // 10/min
'execute_article' => [ 'limit' => 10, 'window' => 60 ], // 10/min
'stream_article' => [ 'limit' => 10, 'window' => 60 ], // 10/min
'generate_image' => [ 'limit' => 10, 'window' => 60 ], // 10/min
'seo_audit' => [ 'limit' => 20, 'window' => 60 ], // 20/min
'refine' => [ 'limit' => 30, 'window' => 60 ], // 30/min
'default' => [ 'limit' => 30, 'window' => 60 ], // 30/min fallback
];
/**
* Generate cache key for rate limit tracking.
*
* @since 0.3.0
* @param int $user_id User ID.
* @param string $endpoint Endpoint name.
* @return string Cache key.
*/
private static function get_key( $user_id, $endpoint ) {
return "wpaw_rl_{$endpoint}_{$user_id}";
}
/**
* Check if request is within rate limit.
*
* @since 0.3.0
* @param string $endpoint Endpoint name.
* @param int $limit Max requests per window.
* @param int $window Time window in seconds.
* @return true|WP_Error True if allowed, WP_Error if rate limited.
*/
public static function check( $endpoint, $limit = null, $window = null ) {
// Use default limits if not specified.
if ( null === $limit || null === $window ) {
$defaults = self::$default_limits[ $endpoint ] ?? self::$default_limits['default'];
$limit = $defaults['limit'];
$window = $defaults['window'];
}
$user_id = get_current_user_id();
// For non-authenticated users, use IP address as fallback.
if ( 0 === $user_id ) {
$user_id = self::get_client_ip();
}
$key = self::get_key( $user_id, $endpoint );
$count = (int) get_transient( $key );
if ( $count >= $limit ) {
$retry_after = (int) get_transient( $key . '_expires' );
if ( false === $retry_after ) {
$retry_after = $window;
}
return new WP_Error(
'rate_limited',
sprintf(
// translators: %d is the number of seconds to wait.
__( 'Too many requests. Please wait %d seconds.', 'wp-agentic-writer' ),
$retry_after
),
[
'status' => 429,
'retry_after' => $retry_after,
]
);
}
// Increment counter.
if ( false === get_transient( $key ) ) {
// First request in window - set expiration tracking.
set_transient( $key, 1, $window );
set_transient( $key . '_expires', $window, $window );
} else {
// Increment existing counter.
set_transient( $key, $count + 1, $window );
}
return true;
}
/**
* Get current request count for an endpoint.
*
* @since 0.3.0
* @param string $endpoint Endpoint name.
* @return int Current request count.
*/
public static function get_current_count( $endpoint ) {
$user_id = get_current_user_id();
if ( 0 === $user_id ) {
$user_id = self::get_client_ip();
}
$key = self::get_key( $user_id, $endpoint );
$count = (int) get_transient( $key );
return false === $count ? 0 : $count;
}
/**
* Get remaining requests for an endpoint.
*
* @since 0.3.0
* @param string $endpoint Endpoint name.
* @param int $limit Max requests per window.
* @return int Remaining requests.
*/
public static function get_remaining( $endpoint, $limit = null ) {
if ( null === $limit ) {
$defaults = self::$default_limits[ $endpoint ] ?? self::$default_limits['default'];
$limit = $defaults['limit'];
}
$current = self::get_current_count( $endpoint );
return max( 0, $limit - $current );
}
/**
* Get client IP address for unauthenticated rate limiting.
*
* @since 0.3.0
* @return string Client IP address.
*/
private static function get_client_ip() {
$ip = '';
if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
// Cloudflare.
$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
} elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
// X-Forwarded-For header (may contain multiple IPs).
$ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
$ip = trim( $ips[0] );
} elseif ( ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) {
// X-Real-IP header.
$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) );
} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
// Direct IP.
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
}
// Validate IP format.
if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
$ip = '0.0.0.0';
}
return $ip;
}
/**
* Reset rate limit for a user/endpoint (for testing).
*
* @since 0.3.0
* @param int $user_id User ID.
* @param string $endpoint Endpoint name.
* @return bool Success.
*/
public static function reset( $user_id, $endpoint ) {
$key = self::get_key( $user_id, $endpoint );
delete_transient( $key );
delete_transient( $key . '_expires' );
return true;
}
/**
* Add rate limit headers to response.
*
* @since 0.3.0
* @param WP_REST_Response $response Response object.
* @param string $endpoint Endpoint name.
* @param int $limit Max requests per window.
* @return WP_REST_Response Modified response with headers.
*/
public static function add_headers( $response, $endpoint, $limit = null ) {
if ( null === $limit ) {
$defaults = self::$default_limits[ $endpoint ] ?? self::$default_limits['default'];
$limit = $defaults['limit'];
}
$remaining = self::get_remaining( $endpoint, $limit );
$current = self::get_current_count( $endpoint );
$response->header( 'X-RateLimit-Limit', $limit );
$response->header( 'X-RateLimit-Remaining', $remaining );
$response->header( 'X-RateLimit-Used', $current );
return $response;
}
}