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.
217 lines
7.1 KiB
PHP
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;
|
|
}
|
|
}
|