licenseManager = $licenseManager; $this->versionManager = $versionManager; $this->registerHooks(); } /** * Register WordPress hooks */ private function registerHooks(): void { add_action('rest_api_init', [$this, 'registerRoutes']); } /** * 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; } /** * 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 = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $transientKey = 'wclp_update_rate_' . md5($ip); $rateLimit = $this->getRateLimit(); $rateWindow = $this->getRateWindow(); $data = get_transient($transientKey); if ($data === false) { set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow); return null; } $count = (int) ($data['count'] ?? 0); $start = (int) ($data['start'] ?? time()); if (time() - $start >= $rateWindow) { set_transient($transientKey, ['count' => 1, 'start' => time()], $rateWindow); return null; } if ($count >= $rateLimit) { $retryAfter = $rateWindow - (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; } set_transient($transientKey, ['count' => $count + 1, 'start' => $start], $rateWindow); return null; } /** * Register REST API routes */ public function registerRoutes(): void { register_rest_route(self::NAMESPACE, '/update-check', [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [$this, 'handleUpdateCheck'], 'permission_callback' => '__return_true', 'args' => [ 'license_key' => [ '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, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function ($value): bool { return !empty($value) && strlen($value) <= 255; }, ], 'plugin_slug' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'current_version' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], ], ]); } /** * Handle update check request */ public function handleUpdateCheck(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'); $currentVersion = $request->get_param('current_version'); // Validate license $validationResult = $this->licenseManager->validateLicense($licenseKey, $domain); if (!$validationResult['valid']) { return new WP_REST_Response([ 'success' => false, 'update_available' => false, 'error' => $validationResult['error'] ?? 'license_invalid', 'message' => $validationResult['message'] ?? __('License validation failed.', 'wc-licensed-product'), ], $validationResult['error'] === 'license_not_found' ? 404 : 403); } // Get license to access product ID $license = $this->licenseManager->getLicenseByKey($licenseKey); if (!$license) { return new WP_REST_Response([ 'success' => false, 'update_available' => false, 'error' => 'license_not_found', 'message' => __('License not found.', 'wc-licensed-product'), ], 404); } $productId = $license->getProductId(); $product = wc_get_product($productId); if (!$product) { return new WP_REST_Response([ 'success' => false, 'update_available' => false, 'error' => 'product_not_found', 'message' => __('Licensed product not found.', 'wc-licensed-product'), ], 404); } // Get latest version based on major version binding $latestVersion = $this->getLatestVersionForLicense($license); if (!$latestVersion) { return new WP_REST_Response([ 'success' => true, 'update_available' => false, 'version' => $currentVersion ?? '0.0.0', 'message' => __('No versions available for this product.', 'wc-licensed-product'), ]); } // Check if update is available $updateAvailable = $currentVersion ? version_compare($latestVersion->getVersion(), $currentVersion, '>') : true; // Build response $response = $this->buildUpdateResponse($product, $latestVersion, $license, $updateAvailable); return new WP_REST_Response($response); } /** * Get latest version for a license, respecting major version binding */ private function getLatestVersionForLicense($license): ?ProductVersion { $productId = $license->getProductId(); // Check if license is bound to a specific version $versionId = $license->getVersionId(); if ($versionId) { $boundVersion = $this->versionManager->getVersionById($versionId); if ($boundVersion) { // Get latest version for this major version return $this->versionManager->getLatestVersionForMajor( $productId, $boundVersion->getMajorVersion() ); } } // No version binding, return latest overall return $this->versionManager->getLatestVersion($productId); } /** * Build WordPress-compatible update response */ private function buildUpdateResponse($product, ProductVersion $version, $license, bool $updateAvailable): array { $productSlug = $product->get_slug(); // Generate secure download URL $downloadUrl = $this->generateUpdateDownloadUrl($license->getId(), $version->getId()); $response = [ 'success' => true, 'update_available' => $updateAvailable, 'version' => $version->getVersion(), 'slug' => $productSlug, 'plugin' => $productSlug . '/' . $productSlug . '.php', 'download_url' => $downloadUrl, 'package' => $downloadUrl, 'last_updated' => $version->getReleasedAt()->format('Y-m-d'), 'tested' => $this->getTestedWpVersion(), 'requires' => $this->getRequiredWpVersion(), 'requires_php' => $this->getRequiredPhpVersion(), ]; // Add changelog if available if ($version->getReleaseNotes()) { $response['changelog'] = $version->getReleaseNotes(); $response['sections'] = [ 'description' => $product->get_short_description() ?: $product->get_description(), 'changelog' => $version->getReleaseNotes(), ]; } // Add package hash for integrity verification if ($version->getFileHash()) { $response['package_hash'] = 'sha256:' . $version->getFileHash(); } // Add product name and homepage $response['name'] = $product->get_name(); $response['homepage'] = get_permalink($product->get_id()); // Add icons if product has featured image $imageId = $product->get_image_id(); if ($imageId) { $iconUrl = wp_get_attachment_image_url($imageId, 'thumbnail'); $iconUrl2x = wp_get_attachment_image_url($imageId, 'medium'); if ($iconUrl) { $response['icons'] = [ '1x' => $iconUrl, '2x' => $iconUrl2x ?: $iconUrl, ]; } } return $response; } /** * Generate secure download URL for updates */ private function generateUpdateDownloadUrl(int $licenseId, int $versionId): string { $data = $licenseId . '-' . $versionId . '-' . wp_salt('auth'); $hash = substr(hash('sha256', $data), 0, 16); $downloadKey = $licenseId . '-' . $versionId . '-' . $hash; return home_url('license-download/' . $downloadKey); } /** * Get tested WordPress version from plugin headers */ private function getTestedWpVersion(): string { return get_option('wc_licensed_product_tested_wp', '6.7'); } /** * Get required WordPress version from plugin headers */ private function getRequiredWpVersion(): string { return get_option('wc_licensed_product_requires_wp', '6.0'); } /** * Get required PHP version */ private function getRequiredPhpVersion(): string { return get_option('wc_licensed_product_requires_php', '8.3'); } }