You've already forked wc-licensed-product
Add WordPress auto-update functionality (v0.6.0)
- Add UpdateController REST API endpoint for serving update info to licensed plugins - Add PluginUpdateChecker singleton for client-side update checking - Hook into WordPress native plugin update system (pre_set_site_transient_update_plugins, plugins_api) - Add Auto-Updates settings subtab with enable/disable and check frequency options - Add authentication headers for secure download requests - Support configurable cache TTL for update checks (default 12 hours) - Document /update-check endpoint in OpenAPI specification - Update German translations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
352
src/Api/UpdateController.php
Normal file
352
src/Api/UpdateController.php
Normal file
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
/**
|
||||
* Update Controller
|
||||
*
|
||||
* REST API endpoint for plugin update checks
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Api
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Api;
|
||||
|
||||
use Jeremias\WcLicensedProduct\License\LicenseManager;
|
||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||
use Jeremias\WcLicensedProduct\Product\ProductVersion;
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_REST_Server;
|
||||
|
||||
/**
|
||||
* Handles REST API endpoint for plugin update checks
|
||||
*
|
||||
* This endpoint allows licensed plugins to check for updates from this WooCommerce store.
|
||||
* It validates the license and returns WordPress-compatible update information.
|
||||
*/
|
||||
final class UpdateController
|
||||
{
|
||||
private const NAMESPACE = 'wc-licensed-product/v1';
|
||||
|
||||
/**
|
||||
* Default rate limit: requests per window per IP
|
||||
*/
|
||||
private const DEFAULT_RATE_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Default rate limit window in seconds
|
||||
*/
|
||||
private const DEFAULT_RATE_WINDOW = 60;
|
||||
|
||||
private LicenseManager $licenseManager;
|
||||
private VersionManager $versionManager;
|
||||
|
||||
public function __construct(LicenseManager $licenseManager, VersionManager $versionManager)
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user