Implement version 0.0.2 features

Add product version management:
- ProductVersion model and VersionManager class
- VersionAdminController with meta box on product edit page
- AJAX-based version CRUD (add, delete, toggle status)
- JavaScript for version management UI

Add email notifications:
- LicenseEmailController for order emails
- License keys included in order completed emails
- Support for both HTML and plain text emails

Add REST API rate limiting:
- 30 requests per minute per IP
- Cloudflare and proxy-aware IP detection
- HTTP 429 response with Retry-After header

Other changes:
- Bump version to 0.0.2
- Update CHANGELOG.md
- Add version status styles to admin.css

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 19:15:19 +01:00
parent 82c18633a1
commit dec4bd609b
10 changed files with 1269 additions and 4 deletions

View File

@@ -21,6 +21,16 @@ final class RestApiController
{
private const NAMESPACE = 'wc-licensed-product/v1';
/**
* Rate limit: requests per minute per IP
*/
private const RATE_LIMIT_REQUESTS = 30;
/**
* Rate limit window in seconds
*/
private const RATE_LIMIT_WINDOW = 60;
private LicenseManager $licenseManager;
public function __construct(LicenseManager $licenseManager)
@@ -37,6 +47,77 @@ final class RestApiController
add_action('rest_api_init', [$this, 'registerRoutes']);
}
/**
* Check rate limit for current IP
*
* @return WP_REST_Response|null Returns error response if rate limited, null if OK
*/
private function checkRateLimit(): ?WP_REST_Response
{
$ip = $this->getClientIp();
$transientKey = 'wclp_rate_' . md5($ip);
$data = get_transient($transientKey);
if ($data === false) {
// First request, start counting
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
return null;
}
$count = (int) ($data['count'] ?? 0);
$start = (int) ($data['start'] ?? time());
// Check if window has expired
if (time() - $start >= self::RATE_LIMIT_WINDOW) {
// Reset counter
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
return null;
}
// Check if limit exceeded
if ($count >= self::RATE_LIMIT_REQUESTS) {
$retryAfter = self::RATE_LIMIT_WINDOW - (time() - $start);
$response = new WP_REST_Response([
'success' => false,
'error' => 'rate_limit_exceeded',
'message' => __('Too many requests. Please try again later.', 'wc-licensed-product'),
'retry_after' => $retryAfter,
], 429);
$response->header('Retry-After', (string) $retryAfter);
return $response;
}
// Increment counter
set_transient($transientKey, ['count' => $count + 1, 'start' => $start], self::RATE_LIMIT_WINDOW);
return null;
}
/**
* Get client IP address
*/
private function getClientIp(): string
{
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
'REMOTE_ADDR',
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
$ips = explode(',', $_SERVER[$header]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
return '0.0.0.0';
}
/**
* Register REST API routes
*/
@@ -125,6 +206,11 @@ final class RestApiController
*/
public function validateLicense(WP_REST_Request $request): WP_REST_Response
{
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
@@ -140,6 +226,11 @@ final class RestApiController
*/
public function checkStatus(WP_REST_Request $request): WP_REST_Response
{
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key');
$license = $this->licenseManager->getLicenseByKey($licenseKey);
@@ -166,6 +257,11 @@ final class RestApiController
*/
public function activateLicense(WP_REST_Request $request): WP_REST_Response
{
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
@@ -228,6 +324,11 @@ final class RestApiController
*/
public function deactivateLicense(WP_REST_Request $request): WP_REST_Response
{
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');