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:
Binary file not shown.
@@ -3,10 +3,10 @@
|
|||||||
# This file is distributed under the GPL-2.0-or-later.
|
# This file is distributed under the GPL-2.0-or-later.
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
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"
|
"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: 2026-01-25T18:30:00+00:00\n"
|
"PO-Revision-Date: 2026-01-27T18:00:00+00:00\n"
|
||||||
"Last-Translator: Marco Graetsch <magdev3.0@gmail.com>\n"
|
"Last-Translator: Marco Graetsch <magdev3.0@gmail.com>\n"
|
||||||
"Language-Team: German (Switzerland) <de_CH@li.org>\n"
|
"Language-Team: German (Switzerland) <de_CH@li.org>\n"
|
||||||
"Language: de_CH\n"
|
"Language: de_CH\n"
|
||||||
@@ -1964,3 +1964,39 @@ msgstr ""
|
|||||||
|
|
||||||
#~ msgid "Licensed Domain:"
|
#~ msgid "Licensed Domain:"
|
||||||
#~ msgstr "Lizensierte 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)."
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
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"
|
"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"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -1869,3 +1869,47 @@ msgstr ""
|
|||||||
#: wc-licensed-product.php:119
|
#: wc-licensed-product.php:119
|
||||||
msgid "WC Licensed Product requires WooCommerce to be installed and active."
|
msgid "WC Licensed Product requires WooCommerce to be installed and active."
|
||||||
msgstr ""
|
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 ""
|
||||||
|
|||||||
272
openapi.json
272
openapi.json
@@ -3,7 +3,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "WooCommerce Licensed Product API",
|
"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.",
|
"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": {
|
"contact": {
|
||||||
"name": "Marco Graetsch",
|
"name": "Marco Graetsch",
|
||||||
"url": "https://src.bundespruefstelle.ch/magdev",
|
"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": {
|
"components": {
|
||||||
@@ -516,6 +658,130 @@
|
|||||||
"description": "Seconds until rate limit resets"
|
"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": {
|
"responses": {
|
||||||
@@ -577,6 +843,10 @@
|
|||||||
{
|
{
|
||||||
"name": "License Activation",
|
"name": "License Activation",
|
||||||
"description": "Activate licenses on domains"
|
"description": "Activate licenses on domains"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Plugin Updates",
|
||||||
|
"description": "Check for plugin updates via WordPress-compatible API"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ final class SettingsController
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'' => __('Plugin License', 'wc-licensed-product'),
|
'' => __('Plugin License', 'wc-licensed-product'),
|
||||||
|
'auto-updates' => __('Auto-Updates', 'wc-licensed-product'),
|
||||||
'defaults' => __('Default Settings', 'wc-licensed-product'),
|
'defaults' => __('Default Settings', 'wc-licensed-product'),
|
||||||
'notifications' => __('Notifications', 'wc-licensed-product'),
|
'notifications' => __('Notifications', 'wc-licensed-product'),
|
||||||
];
|
];
|
||||||
@@ -112,6 +113,7 @@ final class SettingsController
|
|||||||
$currentSection = $this->getCurrentSection();
|
$currentSection = $this->getCurrentSection();
|
||||||
|
|
||||||
return match ($currentSection) {
|
return match ($currentSection) {
|
||||||
|
'auto-updates' => $this->getAutoUpdatesSettings(),
|
||||||
'defaults' => $this->getDefaultsSettings(),
|
'defaults' => $this->getDefaultsSettings(),
|
||||||
'notifications' => $this->getNotificationsSettings(),
|
'notifications' => $this->getNotificationsSettings(),
|
||||||
default => $this->getPluginLicenseSettings(),
|
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
|
* Get default license settings
|
||||||
*/
|
*/
|
||||||
@@ -460,6 +500,23 @@ final class SettingsController
|
|||||||
return !empty($secret) ? (string) $secret : null;
|
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
|
* Handle AJAX verify license request
|
||||||
*/
|
*/
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
|||||||
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
|
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
|
||||||
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
|
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
|
||||||
use Jeremias\WcLicensedProduct\Api\RestApiController;
|
use Jeremias\WcLicensedProduct\Api\RestApiController;
|
||||||
|
use Jeremias\WcLicensedProduct\Api\UpdateController;
|
||||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration;
|
use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration;
|
||||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
|
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
|
||||||
use Jeremias\WcLicensedProduct\Checkout\StoreApiExtension;
|
use Jeremias\WcLicensedProduct\Checkout\StoreApiExtension;
|
||||||
@@ -27,6 +28,7 @@ use Jeremias\WcLicensedProduct\License\LicenseManager;
|
|||||||
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
|
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
|
||||||
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
|
use Jeremias\WcLicensedProduct\Product\LicensedProductType;
|
||||||
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
use Jeremias\WcLicensedProduct\Product\VersionManager;
|
||||||
|
use Jeremias\WcLicensedProduct\Update\PluginUpdateChecker;
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
use Twig\Loader\FilesystemLoader;
|
use Twig\Loader\FilesystemLoader;
|
||||||
|
|
||||||
@@ -139,8 +141,9 @@ final class Plugin
|
|||||||
new AccountController($this->twig, $this->licenseManager, $this->versionManager, $this->downloadController);
|
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 RestApiController($this->licenseManager);
|
||||||
|
new UpdateController($this->licenseManager, $this->versionManager);
|
||||||
new LicenseEmailController($this->licenseManager);
|
new LicenseEmailController($this->licenseManager);
|
||||||
|
|
||||||
// Initialize response signing if server secret is configured
|
// Initialize response signing if server secret is configured
|
||||||
@@ -162,6 +165,12 @@ final class Plugin
|
|||||||
add_action('admin_notices', [$this, 'showUnlicensedNotice']);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
417
src/Update/PluginUpdateChecker.php
Normal file
417
src/Update/PluginUpdateChecker.php
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Update Checker
|
||||||
|
*
|
||||||
|
* Checks for plugin updates from the configured license server.
|
||||||
|
*
|
||||||
|
* @package Jeremias\WcLicensedProduct\Update
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Jeremias\WcLicensedProduct\Update;
|
||||||
|
|
||||||
|
use Jeremias\WcLicensedProduct\License\PluginLicenseChecker;
|
||||||
|
use Symfony\Component\HttpClient\HttpClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles checking for plugin updates from the license server
|
||||||
|
*
|
||||||
|
* This class hooks into WordPress's native plugin update system to check for
|
||||||
|
* updates from the configured license server. It validates the license and
|
||||||
|
* provides download authentication.
|
||||||
|
*/
|
||||||
|
final class PluginUpdateChecker
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Cache key for update info
|
||||||
|
*/
|
||||||
|
private const CACHE_KEY = 'wclp_update_info';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default cache TTL (12 hours)
|
||||||
|
*/
|
||||||
|
private const DEFAULT_CACHE_TTL = 43200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance
|
||||||
|
*/
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin slug
|
||||||
|
*/
|
||||||
|
private string $pluginSlug;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin basename (slug/slug.php)
|
||||||
|
*/
|
||||||
|
private string $pluginBasename;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*/
|
||||||
|
public static function getInstance(): self
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private constructor for singleton
|
||||||
|
*/
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
$this->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 = '<a href="https://src.bundespruefstelle.ch/magdev">Marco Graetsch</a>';
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: WooCommerce Licensed Product
|
* Plugin Name: WooCommerce Licensed Product
|
||||||
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-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.
|
* 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: Marco Graetsch
|
||||||
* Author URI: https://src.bundespruefstelle.ch/magdev
|
* Author URI: https://src.bundespruefstelle.ch/magdev
|
||||||
* License: GPL-2.0-or-later
|
* License: GPL-2.0-or-later
|
||||||
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plugin constants
|
// 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_FILE', __FILE__);
|
||||||
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));
|
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
|||||||
Reference in New Issue
Block a user