diff --git a/CHANGELOG.md b/CHANGELOG.md index b732429..a94bb66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.4] - 2026-01-26 + +### Fixed + +- REST API `/validate` endpoint now returns HTTP 404 for `license_not_found` error (was 403) +- License key validation now enforces minimum 8 characters per API documentation + +### Added + +- Configurable rate limiting via `WC_LICENSE_RATE_LIMIT` and `WC_LICENSE_RATE_WINDOW` constants +- Rate limit now defaults to 30 requests per 60 second window (configurable) + +### Changed + +- Improved HTTP status code mapping: 404 for not found, 500 for server errors, 403 for all other errors +- Rate limiting implementation now uses configurable constants instead of hardcoded values + ## [0.5.3] - 2026-01-26 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0c7c7b1..8ed7ffe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1378,3 +1378,31 @@ Major feature release adding support for WooCommerce variable products. Customer - Order meta `_licensed_product_domains` now includes optional `variation_id` field - License generation uses variation settings when `variation_id` is present in order item - Backward compatible: existing simple licensed products continue to work unchanged + +### 2026-01-26 - Version 0.5.4 - API Compliance + +**Overview:** + +Bug fix release aligning server implementation with client documentation at `magdev/wc-licensed-product-client`. + +**Fixed:** + +- `/validate` endpoint now returns HTTP 404 for `license_not_found` error (was returning 403) +- License key validation now enforces minimum 8 characters across all API endpoints + +**Implemented:** + +- Configurable rate limiting via `WC_LICENSE_RATE_LIMIT` constant (default: 30 requests) +- Configurable rate window via `WC_LICENSE_RATE_WINDOW` constant (default: 60 seconds) +- HTTP status code mapping: 404 for not found, 500 for server errors, 403 for all other errors + +**Modified files:** + +- `src/Api/RestApiController.php` - Added configurable rate limiting, fixed HTTP status codes, added license_key validation + +**Technical notes:** + +- Rate limiting now uses `getRateLimit()` and `getRateWindow()` methods instead of constants +- New `getStatusCodeForResult()` method maps error codes to HTTP status codes +- License key validation callback added to all three endpoints (validate, status, activate) +- Uses PHP 8 match expression for status code mapping diff --git a/src/Api/RestApiController.php b/src/Api/RestApiController.php index e914446..0ea92cf 100644 --- a/src/Api/RestApiController.php +++ b/src/Api/RestApiController.php @@ -22,14 +22,34 @@ final class RestApiController private const NAMESPACE = 'wc-licensed-product/v1'; /** - * Rate limit: requests per minute per IP + * Default rate limit: requests per window per IP */ - private const RATE_LIMIT_REQUESTS = 30; + private const DEFAULT_RATE_LIMIT = 30; /** - * Rate limit window in seconds + * Default rate limit window in seconds */ - private const RATE_LIMIT_WINDOW = 60; + private const DEFAULT_RATE_WINDOW = 60; + + /** + * Get the configured rate limit (requests per window) + */ + private function getRateLimit(): int + { + return defined('WC_LICENSE_RATE_LIMIT') + ? (int) WC_LICENSE_RATE_LIMIT + : self::DEFAULT_RATE_LIMIT; + } + + /** + * Get the configured rate limit window in seconds + */ + private function getRateWindow(): int + { + return defined('WC_LICENSE_RATE_WINDOW') + ? (int) WC_LICENSE_RATE_WINDOW + : self::DEFAULT_RATE_WINDOW; + } private LicenseManager $licenseManager; @@ -56,12 +76,14 @@ final class RestApiController { $ip = $this->getClientIp(); $transientKey = 'wclp_rate_' . md5($ip); + $rateLimit = $this->getRateLimit(); + $rateWindow = $this->getRateWindow(); $data = get_transient($transientKey); if ($data === false) { // First request, start counting - set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW); + set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow); return null; } @@ -69,15 +91,15 @@ final class RestApiController $start = (int) ($data['start'] ?? time()); // Check if window has expired - if (time() - $start >= self::RATE_LIMIT_WINDOW) { + if (time() - $start >= $rateWindow) { // Reset counter - set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW); + set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow); return null; } // Check if limit exceeded - if ($count >= self::RATE_LIMIT_REQUESTS) { - $retryAfter = self::RATE_LIMIT_WINDOW - (time() - $start); + if ($count >= $rateLimit) { + $retryAfter = $rateWindow - (time() - $start); $response = new WP_REST_Response([ 'success' => false, 'error' => 'rate_limit_exceeded', @@ -89,7 +111,7 @@ final class RestApiController } // Increment counter - set_transient($transientKey, ['count' => $count + 1, 'start' => $start], self::RATE_LIMIT_WINDOW); + set_transient($transientKey, ['count' => $count + 1, 'start' => $start], $rateWindow); return null; } @@ -257,7 +279,8 @@ final class RestApiController 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function ($value): bool { - return !empty($value) && strlen($value) <= 64; + $len = strlen($value); + return !empty($value) && $len >= 8 && $len <= 64; }, ], 'domain' => [ @@ -281,6 +304,10 @@ final class RestApiController 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ($value): bool { + $len = strlen($value); + return !empty($value) && $len >= 8 && $len <= 64; + }, ], ], ]); @@ -295,6 +322,10 @@ final class RestApiController 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => function ($value): bool { + $len = strlen($value); + return !empty($value) && $len >= 8 && $len <= 64; + }, ], 'domain' => [ 'required' => true, @@ -320,11 +351,32 @@ final class RestApiController $result = $this->licenseManager->validateLicense($licenseKey, $domain); - $statusCode = $result['valid'] ? 200 : 403; + $statusCode = $this->getStatusCodeForResult($result); return new WP_REST_Response($result, $statusCode); } + /** + * Get HTTP status code based on validation result + * + * @param array $result The validation result + * @return int HTTP status code + */ + private function getStatusCodeForResult(array $result): int + { + if ($result['valid']) { + return 200; + } + + $error = $result['error'] ?? ''; + + return match ($error) { + 'license_not_found' => 404, + 'activation_failed' => 500, + default => 403, + }; + } + /** * Check license status endpoint */ diff --git a/wc-licensed-product.php b/wc-licensed-product.php index 7f9e3ae..fdab926 100644 --- a/wc-licensed-product.php +++ b/wc-licensed-product.php @@ -3,7 +3,7 @@ * Plugin Name: WooCommerce Licensed Product * Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product * Description: WooCommerce plugin to sell software products using license keys with domain-based validation. - * Version: 0.5.3 + * Version: 0.5.4 * Author: Marco Graetsch * Author URI: https://src.bundespruefstelle.ch/magdev * License: GPL-2.0-or-later @@ -28,7 +28,7 @@ if (!defined('ABSPATH')) { } // Plugin constants -define('WC_LICENSED_PRODUCT_VERSION', '0.5.3'); +define('WC_LICENSED_PRODUCT_VERSION', '0.5.4'); define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__); define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));