diff --git a/languages/wc-licensed-product-de_CH.mo b/languages/wc-licensed-product-de_CH.mo index 65e9afc..fd3b30a 100644 Binary files a/languages/wc-licensed-product-de_CH.mo and b/languages/wc-licensed-product-de_CH.mo differ diff --git a/languages/wc-licensed-product-de_CH.po b/languages/wc-licensed-product-de_CH.po index 6169f12..2e11643 100644 --- a/languages/wc-licensed-product-de_CH.po +++ b/languages/wc-licensed-product-de_CH.po @@ -3,10 +3,10 @@ # This file is distributed under the GPL-2.0-or-later. msgid "" msgstr "" -"Project-Id-Version: WC Licensed Product 0.5.0\n" +"Project-Id-Version: WC Licensed Product 0.6.0\n" "Report-Msgid-Bugs-To: magdev3.0@gmail.com\n" -"POT-Creation-Date: 2026-01-27 14:41+0100\n" -"PO-Revision-Date: 2026-01-25T18:30:00+00:00\n" +"POT-Creation-Date: 2026-01-27 18:00+0100\n" +"PO-Revision-Date: 2026-01-27T18:00:00+00:00\n" "Last-Translator: Marco Graetsch \n" "Language-Team: German (Switzerland) \n" "Language: de_CH\n" @@ -1964,3 +1964,39 @@ msgstr "" #~ msgid "Licensed Domain:" #~ msgstr "Lizensierte Domain:" + +#: src/Api/UpdateController.php:195 +msgid "Licensed product not found." +msgstr "Lizenziertes Produkt nicht gefunden." + +#: src/Api/UpdateController.php:207 +msgid "No versions available for this product." +msgstr "Keine Versionen für dieses Produkt verfügbar." + +#: src/Update/PluginUpdateChecker.php:295 +msgid "WooCommerce plugin for selling licensed software products with domain-bound license keys." +msgstr "WooCommerce-Plugin zum Verkauf von lizenzierten Softwareprodukten mit domaingebundenen Lizenzschlüsseln." + +#: src/Admin/SettingsController.php:163 +msgid "Auto-Updates" +msgstr "Auto-Updates" + +#: src/Admin/SettingsController.php:165 +msgid "Configure automatic plugin updates from the license server." +msgstr "Automatische Plugin-Updates vom Lizenzserver konfigurieren." + +#: src/Admin/SettingsController.php:169 +msgid "Enable Auto-Updates" +msgstr "Auto-Updates aktivieren" + +#: src/Admin/SettingsController.php:172 +msgid "Automatically check for and receive plugin updates from the license server." +msgstr "Automatisch auf Plugin-Updates vom Lizenzserver prüfen und diese erhalten." + +#: src/Admin/SettingsController.php:177 +msgid "Check Frequency (Hours)" +msgstr "Prüfhäufigkeit (Stunden)" + +#: src/Admin/SettingsController.php:180 +msgid "How often to check for updates (in hours)." +msgstr "Wie oft auf Updates geprüft werden soll (in Stunden)." diff --git a/languages/wc-licensed-product.pot b/languages/wc-licensed-product.pot index 239b595..77a6f5c 100644 --- a/languages/wc-licensed-product.pot +++ b/languages/wc-licensed-product.pot @@ -6,9 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: WC Licensed Product 0.5.12\n" +"Project-Id-Version: WC Licensed Product 0.6.0\n" "Report-Msgid-Bugs-To: magdev3.0@gmail.com\n" -"POT-Creation-Date: 2026-01-27 14:41+0100\n" +"POT-Creation-Date: 2026-01-27 18:00+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -1869,3 +1869,47 @@ msgstr "" #: wc-licensed-product.php:119 msgid "WC Licensed Product requires WooCommerce to be installed and active." msgstr "" + +#: src/Api/UpdateController.php:175 +msgid "License validation failed." +msgstr "" + +#: src/Api/UpdateController.php:185 +msgid "License not found." +msgstr "" + +#: src/Api/UpdateController.php:195 +msgid "Licensed product not found." +msgstr "" + +#: src/Api/UpdateController.php:207 +msgid "No versions available for this product." +msgstr "" + +#: src/Update/PluginUpdateChecker.php:295 +msgid "WooCommerce plugin for selling licensed software products with domain-bound license keys." +msgstr "" + +#: src/Admin/SettingsController.php:163 +msgid "Auto-Updates" +msgstr "" + +#: src/Admin/SettingsController.php:165 +msgid "Configure automatic plugin updates from the license server." +msgstr "" + +#: src/Admin/SettingsController.php:169 +msgid "Enable Auto-Updates" +msgstr "" + +#: src/Admin/SettingsController.php:172 +msgid "Automatically check for and receive plugin updates from the license server." +msgstr "" + +#: src/Admin/SettingsController.php:177 +msgid "Check Frequency (Hours)" +msgstr "" + +#: src/Admin/SettingsController.php:180 +msgid "How often to check for updates (in hours)." +msgstr "" diff --git a/openapi.json b/openapi.json index 09dd4a6..f22c2f1 100644 --- a/openapi.json +++ b/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "WooCommerce Licensed Product API", "description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.\n\n## Response Signing (Optional)\n\nWhen the server is configured with `WC_LICENSE_SERVER_SECRET`, all API responses include cryptographic signatures for tamper protection:\n\n- `X-License-Signature`: HMAC-SHA256 signature of the response\n- `X-License-Timestamp`: Unix timestamp when the response was generated\n\nSignature verification prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` library's `SecureLicenseClient` class to automatically verify signatures.", - "version": "0.3.2", + "version": "0.6.0", "contact": { "name": "Marco Graetsch", "url": "https://src.bundespruefstelle.ch/magdev", @@ -332,6 +332,148 @@ } } } + }, + "/update-check": { + "post": { + "operationId": "checkForUpdates", + "summary": "Check for plugin updates", + "description": "Checks if a newer version of the licensed product is available. Returns WordPress-compatible update information that can be used to integrate with WordPress's native plugin update system.", + "tags": ["Plugin Updates"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCheckRequest" + }, + "example": { + "license_key": "ABCD-1234-EFGH-5678", + "domain": "example.com", + "plugin_slug": "my-licensed-plugin", + "current_version": "1.0.0" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/UpdateCheckRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Update check completed successfully", + "headers": { + "X-License-Signature": { + "$ref": "#/components/headers/X-License-Signature" + }, + "X-License-Timestamp": { + "$ref": "#/components/headers/X-License-Timestamp" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCheckResponse" + }, + "examples": { + "update_available": { + "summary": "Update is available", + "value": { + "success": true, + "update_available": true, + "version": "1.2.0", + "slug": "my-licensed-plugin", + "plugin": "my-licensed-plugin/my-licensed-plugin.php", + "download_url": "https://example.com/license-download/123-456-abc123", + "package": "https://example.com/license-download/123-456-abc123", + "last_updated": "2026-01-27", + "tested": "6.7", + "requires": "6.0", + "requires_php": "8.3", + "changelog": "## 1.2.0\n- New feature added\n- Bug fixes", + "package_hash": "sha256:abc123def456...", + "name": "My Licensed Plugin", + "homepage": "https://example.com/product/my-plugin" + } + }, + "no_update": { + "summary": "No update available", + "value": { + "success": true, + "update_available": false, + "version": "1.0.0" + } + } + } + } + } + }, + "403": { + "description": "License validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "license_invalid": { + "summary": "License is not valid", + "value": { + "success": false, + "update_available": false, + "error": "license_invalid", + "message": "License validation failed." + } + }, + "domain_mismatch": { + "summary": "Domain mismatch", + "value": { + "success": false, + "update_available": false, + "error": "domain_mismatch", + "message": "This license is not valid for this domain." + } + } + } + } + } + }, + "404": { + "description": "License or product not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "license_not_found": { + "summary": "License not found", + "value": { + "success": false, + "update_available": false, + "error": "license_not_found", + "message": "License not found." + } + }, + "product_not_found": { + "summary": "Product not found", + "value": { + "success": false, + "update_available": false, + "error": "product_not_found", + "message": "Licensed product not found." + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimitExceeded" + } + } + } } }, "components": { @@ -516,6 +658,130 @@ "description": "Seconds until rate limit resets" } } + }, + "UpdateCheckRequest": { + "type": "object", + "required": ["license_key", "domain"], + "properties": { + "license_key": { + "type": "string", + "description": "The license key to validate (format: XXXX-XXXX-XXXX-XXXX)", + "maxLength": 64, + "example": "ABCD-1234-EFGH-5678" + }, + "domain": { + "type": "string", + "description": "The domain the plugin is installed on", + "maxLength": 255, + "example": "example.com" + }, + "plugin_slug": { + "type": "string", + "description": "The plugin slug (optional, for identification)", + "example": "my-licensed-plugin" + }, + "current_version": { + "type": "string", + "description": "Currently installed version for comparison", + "example": "1.0.0" + } + } + }, + "UpdateCheckResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Whether the request was successful" + }, + "update_available": { + "type": "boolean", + "description": "Whether an update is available" + }, + "version": { + "type": "string", + "description": "Latest available version" + }, + "slug": { + "type": "string", + "description": "Plugin slug for WordPress" + }, + "plugin": { + "type": "string", + "description": "Plugin basename (slug/slug.php)" + }, + "download_url": { + "type": "string", + "format": "uri", + "description": "Secure download URL for the update package" + }, + "package": { + "type": "string", + "format": "uri", + "description": "Alias for download_url (WordPress compatibility)" + }, + "last_updated": { + "type": "string", + "format": "date", + "description": "Date of the latest release" + }, + "tested": { + "type": "string", + "description": "Highest WordPress version tested with" + }, + "requires": { + "type": "string", + "description": "Minimum required WordPress version" + }, + "requires_php": { + "type": "string", + "description": "Minimum required PHP version" + }, + "changelog": { + "type": "string", + "description": "Release notes/changelog for the update" + }, + "package_hash": { + "type": "string", + "description": "SHA256 hash of the package for integrity verification", + "example": "sha256:abc123..." + }, + "name": { + "type": "string", + "description": "Product name" + }, + "homepage": { + "type": "string", + "format": "uri", + "description": "Product homepage URL" + }, + "icons": { + "type": "object", + "description": "Plugin icons for WordPress admin", + "properties": { + "1x": { + "type": "string", + "format": "uri" + }, + "2x": { + "type": "string", + "format": "uri" + } + } + }, + "sections": { + "type": "object", + "description": "Content sections for plugin info modal", + "properties": { + "description": { + "type": "string" + }, + "changelog": { + "type": "string" + } + } + } + } } }, "responses": { @@ -577,6 +843,10 @@ { "name": "License Activation", "description": "Activate licenses on domains" + }, + { + "name": "Plugin Updates", + "description": "Check for plugin updates via WordPress-compatible API" } ] } diff --git a/src/Admin/SettingsController.php b/src/Admin/SettingsController.php index 476dc18..ca2b254 100644 --- a/src/Admin/SettingsController.php +++ b/src/Admin/SettingsController.php @@ -62,6 +62,7 @@ final class SettingsController { return [ '' => __('Plugin License', 'wc-licensed-product'), + 'auto-updates' => __('Auto-Updates', 'wc-licensed-product'), 'defaults' => __('Default Settings', 'wc-licensed-product'), 'notifications' => __('Notifications', 'wc-licensed-product'), ]; @@ -112,6 +113,7 @@ final class SettingsController $currentSection = $this->getCurrentSection(); return match ($currentSection) { + 'auto-updates' => $this->getAutoUpdatesSettings(), 'defaults' => $this->getDefaultsSettings(), 'notifications' => $this->getNotificationsSettings(), default => $this->getPluginLicenseSettings(), @@ -160,6 +162,44 @@ final class SettingsController ]; } + /** + * Get auto-updates settings + */ + private function getAutoUpdatesSettings(): array + { + return [ + 'auto_update_section_title' => [ + 'name' => __('Auto-Updates', 'wc-licensed-product'), + 'type' => 'title', + 'desc' => __('Configure automatic plugin updates from the license server.', 'wc-licensed-product'), + 'id' => 'wc_licensed_product_section_auto_update', + ], + 'plugin_auto_update_enabled' => [ + 'name' => __('Enable Auto-Updates', 'wc-licensed-product'), + 'type' => 'checkbox', + 'desc' => __('Automatically check for and receive plugin updates from the license server.', 'wc-licensed-product'), + 'id' => 'wc_licensed_product_plugin_auto_update_enabled', + 'default' => 'yes', + ], + 'update_check_frequency' => [ + 'name' => __('Check Frequency (Hours)', 'wc-licensed-product'), + 'type' => 'number', + 'desc' => __('How often to check for updates (in hours).', 'wc-licensed-product'), + 'id' => 'wc_licensed_product_update_check_frequency', + 'default' => '12', + 'custom_attributes' => [ + 'min' => '1', + 'max' => '168', + 'step' => '1', + ], + ], + 'auto_update_section_end' => [ + 'type' => 'sectionend', + 'id' => 'wc_licensed_product_section_auto_update_end', + ], + ]; + } + /** * Get default license settings */ @@ -460,6 +500,23 @@ final class SettingsController return !empty($secret) ? (string) $secret : null; } + /** + * Check if auto-updates are enabled + */ + public static function isAutoUpdateEnabled(): bool + { + return get_option('wc_licensed_product_plugin_auto_update_enabled', 'yes') === 'yes'; + } + + /** + * Get update check frequency in hours + */ + public static function getUpdateCheckFrequency(): int + { + $value = get_option('wc_licensed_product_update_check_frequency', 12); + return max(1, min(168, (int) $value)); + } + /** * Handle AJAX verify license request */ diff --git a/src/Api/UpdateController.php b/src/Api/UpdateController.php new file mode 100644 index 0000000..0674edd --- /dev/null +++ b/src/Api/UpdateController.php @@ -0,0 +1,352 @@ +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'); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 51ea562..5e53515 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -17,6 +17,7 @@ use Jeremias\WcLicensedProduct\Admin\SettingsController; use Jeremias\WcLicensedProduct\Admin\VersionAdminController; use Jeremias\WcLicensedProduct\Api\ResponseSigner; use Jeremias\WcLicensedProduct\Api\RestApiController; +use Jeremias\WcLicensedProduct\Api\UpdateController; use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration; use Jeremias\WcLicensedProduct\Checkout\CheckoutController; use Jeremias\WcLicensedProduct\Checkout\StoreApiExtension; @@ -27,6 +28,7 @@ use Jeremias\WcLicensedProduct\License\LicenseManager; use Jeremias\WcLicensedProduct\License\PluginLicenseChecker; use Jeremias\WcLicensedProduct\Product\LicensedProductType; use Jeremias\WcLicensedProduct\Product\VersionManager; +use Jeremias\WcLicensedProduct\Update\PluginUpdateChecker; use Twig\Environment; use Twig\Loader\FilesystemLoader; @@ -139,8 +141,9 @@ final class Plugin new AccountController($this->twig, $this->licenseManager, $this->versionManager, $this->downloadController); } - // Always initialize REST API and email controller + // Always initialize REST API, update API, and email controller new RestApiController($this->licenseManager); + new UpdateController($this->licenseManager, $this->versionManager); new LicenseEmailController($this->licenseManager); // Initialize response signing if server secret is configured @@ -162,6 +165,12 @@ final class Plugin add_action('admin_notices', [$this, 'showUnlicensedNotice']); } } + + // Initialize update checker if license server is configured (client-side updates) + $serverUrl = SettingsController::getPluginLicenseServerUrl(); + if (!empty($serverUrl) && !$licenseChecker->isSelfLicensing()) { + PluginUpdateChecker::getInstance()->register(); + } } /** diff --git a/src/Update/PluginUpdateChecker.php b/src/Update/PluginUpdateChecker.php new file mode 100644 index 0000000..fb7974c --- /dev/null +++ b/src/Update/PluginUpdateChecker.php @@ -0,0 +1,417 @@ +pluginSlug = 'wc-licensed-product'; + $this->pluginBasename = WC_LICENSED_PRODUCT_PLUGIN_BASENAME; + } + + /** + * Register WordPress hooks for update checking + */ + public function register(): void + { + // Skip if auto-updates are disabled + if ($this->isAutoUpdateDisabled()) { + return; + } + + // Check for updates + add_filter('pre_set_site_transient_update_plugins', [$this, 'checkForUpdates']); + + // Provide plugin information for the update modal + add_filter('plugins_api', [$this, 'getPluginInfo'], 10, 3); + + // Add authentication headers to download requests + add_filter('http_request_args', [$this, 'addAuthHeaders'], 10, 2); + + // Clear cache on settings save + add_action('update_option_wc_licensed_product_plugin_license_key', [$this, 'clearCache']); + add_action('update_option_wc_licensed_product_plugin_license_server_url', [$this, 'clearCache']); + } + + /** + * Check if auto-updates are disabled + */ + private function isAutoUpdateDisabled(): bool + { + // Check constant + if (defined('WC_LICENSE_DISABLE_AUTO_UPDATE') && WC_LICENSE_DISABLE_AUTO_UPDATE) { + return true; + } + + // Check setting + $enabled = get_option('wc_licensed_product_plugin_auto_update_enabled', 'yes'); + return $enabled !== 'yes'; + } + + /** + * Check for plugin updates + * + * @param object $transient The update_plugins transient + * @return object Modified transient + */ + public function checkForUpdates($transient) + { + if (empty($transient->checked)) { + return $transient; + } + + // Get cached update info or fetch fresh + $updateInfo = $this->getUpdateInfo(); + + if (!$updateInfo || !isset($updateInfo['update_available']) || !$updateInfo['update_available']) { + return $transient; + } + + // Compare versions + $currentVersion = $transient->checked[$this->pluginBasename] ?? WC_LICENSED_PRODUCT_VERSION; + + if (version_compare($updateInfo['version'], $currentVersion, '>')) { + $transient->response[$this->pluginBasename] = $this->buildUpdateObject($updateInfo); + } + + return $transient; + } + + /** + * Get plugin information for the update modal + * + * @param false|object|array $result The result object or array + * @param string $action The API action + * @param object $args Request arguments + * @return false|object + */ + public function getPluginInfo($result, string $action, object $args) + { + if ($action !== 'plugin_information') { + return $result; + } + + if (!isset($args->slug) || $args->slug !== $this->pluginSlug) { + return $result; + } + + // Get update info + $updateInfo = $this->getUpdateInfo(true); + + if (!$updateInfo) { + return $result; + } + + return $this->buildPluginInfoObject($updateInfo); + } + + /** + * Add authentication headers to download requests + * + * @param array $args HTTP request arguments + * @param string $url Request URL + * @return array Modified arguments + */ + public function addAuthHeaders(array $args, string $url): array + { + // Only modify requests to our license server + $serverUrl = $this->getLicenseServerUrl(); + if (empty($serverUrl) || strpos($url, parse_url($serverUrl, PHP_URL_HOST)) === false) { + return $args; + } + + // Only modify download requests + if (strpos($url, 'license-download') === false) { + return $args; + } + + // Add license key to headers for potential server-side verification + $licenseKey = $this->getLicenseKey(); + if (!empty($licenseKey)) { + $args['headers']['X-License-Key'] = $licenseKey; + } + + return $args; + } + + /** + * Get update info from cache or server + * + * @param bool $forceRefresh Force refresh from server + * @return array|null Update info or null if unavailable + */ + public function getUpdateInfo(bool $forceRefresh = false): ?array + { + // Check cache unless force refresh + if (!$forceRefresh) { + $cached = get_transient(self::CACHE_KEY); + if ($cached !== false) { + return $cached; + } + } + + // Fetch from server + $updateInfo = $this->fetchUpdateInfo(); + + if ($updateInfo) { + // Cache the result + $cacheTtl = $this->getCacheTtl(); + set_transient(self::CACHE_KEY, $updateInfo, $cacheTtl); + } + + return $updateInfo; + } + + /** + * Fetch update info from the license server + */ + private function fetchUpdateInfo(): ?array + { + $serverUrl = $this->getLicenseServerUrl(); + $licenseKey = $this->getLicenseKey(); + + if (empty($serverUrl) || empty($licenseKey)) { + return null; + } + + try { + $httpClient = HttpClient::create([ + 'timeout' => 15, + 'verify_peer' => true, + ]); + + $updateCheckUrl = rtrim($serverUrl, '/') . '/wp-json/wc-licensed-product/v1/update-check'; + + $response = $httpClient->request('POST', $updateCheckUrl, [ + 'json' => [ + 'license_key' => $licenseKey, + 'domain' => $this->getCurrentDomain(), + 'plugin_slug' => $this->pluginSlug, + 'current_version' => WC_LICENSED_PRODUCT_VERSION, + ], + ]); + + if ($response->getStatusCode() !== 200) { + return null; + } + + $data = $response->toArray(); + + // Verify response structure + if (!isset($data['success']) || !$data['success']) { + return null; + } + + return $data; + } catch (\Throwable $e) { + // Log error but don't break the site + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log('WC Licensed Product: Update check failed - ' . $e->getMessage()); + } + return null; + } + } + + /** + * Build WordPress update object for transient + */ + private function buildUpdateObject(array $updateInfo): object + { + $update = new \stdClass(); + $update->id = $this->pluginSlug; + $update->slug = $updateInfo['slug'] ?? $this->pluginSlug; + $update->plugin = $this->pluginBasename; + $update->new_version = $updateInfo['version']; + $update->url = $updateInfo['homepage'] ?? ''; + $update->package = $updateInfo['download_url'] ?? $updateInfo['package'] ?? ''; + + if (isset($updateInfo['tested'])) { + $update->tested = $updateInfo['tested']; + } + + if (isset($updateInfo['requires'])) { + $update->requires = $updateInfo['requires']; + } + + if (isset($updateInfo['requires_php'])) { + $update->requires_php = $updateInfo['requires_php']; + } + + if (isset($updateInfo['icons'])) { + $update->icons = $updateInfo['icons']; + } + + return $update; + } + + /** + * Build plugin info object for plugins_api + */ + private function buildPluginInfoObject(array $updateInfo): object + { + $info = new \stdClass(); + $info->name = $updateInfo['name'] ?? 'WC Licensed Product'; + $info->slug = $updateInfo['slug'] ?? $this->pluginSlug; + $info->version = $updateInfo['version']; + $info->author = 'Marco Graetsch'; + $info->homepage = $updateInfo['homepage'] ?? ''; + $info->requires = $updateInfo['requires'] ?? '6.0'; + $info->tested = $updateInfo['tested'] ?? ''; + $info->requires_php = $updateInfo['requires_php'] ?? '8.3'; + $info->downloaded = 0; + $info->last_updated = $updateInfo['last_updated'] ?? ''; + $info->download_link = $updateInfo['download_url'] ?? $updateInfo['package'] ?? ''; + + // Sections for the modal + $info->sections = []; + + if (isset($updateInfo['sections']['description'])) { + $info->sections['description'] = $updateInfo['sections']['description']; + } else { + $info->sections['description'] = __( + 'WooCommerce plugin for selling licensed software products with domain-bound license keys.', + 'wc-licensed-product' + ); + } + + if (isset($updateInfo['sections']['changelog']) || isset($updateInfo['changelog'])) { + $info->sections['changelog'] = $updateInfo['sections']['changelog'] ?? $updateInfo['changelog']; + } + + // Banners and icons + if (isset($updateInfo['banners'])) { + $info->banners = $updateInfo['banners']; + } + + if (isset($updateInfo['icons'])) { + $info->icons = $updateInfo['icons']; + } + + return $info; + } + + /** + * Clear the update cache + */ + public function clearCache(): void + { + delete_transient(self::CACHE_KEY); + } + + /** + * Get cache TTL from settings or default + */ + private function getCacheTtl(): int + { + $hours = (int) get_option('wc_licensed_product_update_check_frequency', 12); + return max(1, $hours) * HOUR_IN_SECONDS; + } + + /** + * Get the license server URL from settings + */ + private function getLicenseServerUrl(): string + { + // Check constant override first + if (defined('WC_LICENSE_UPDATE_CHECK_URL') && WC_LICENSE_UPDATE_CHECK_URL) { + return WC_LICENSE_UPDATE_CHECK_URL; + } + + return (string) get_option('wc_licensed_product_plugin_license_server_url', ''); + } + + /** + * Get the license key from settings + */ + private function getLicenseKey(): string + { + return (string) get_option('wc_licensed_product_plugin_license_key', ''); + } + + /** + * Get the current domain from the site URL + */ + private function getCurrentDomain(): string + { + $siteUrl = get_site_url(); + $parsed = parse_url($siteUrl); + $host = $parsed['host'] ?? 'localhost'; + + if (isset($parsed['port'])) { + $host .= ':' . $parsed['port']; + } + + return strtolower($host); + } + + /** + * Force an immediate update check + * + * Useful for admin interfaces where user clicks "Check for updates" + */ + public function forceUpdateCheck(): ?array + { + $this->clearCache(); + return $this->getUpdateInfo(true); + } +} diff --git a/wc-licensed-product.php b/wc-licensed-product.php index 32b6249..e2b5fc4 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.15 + * Version: 0.6.0 * 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.15'); +define('WC_LICENSED_PRODUCT_VERSION', '0.6.0'); 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__));