You've already forked wc-licensed-product
- Fix admin license test popup showing empty product field - Display product name in bold in test license modal - Split auto-update settings into notification and auto-install options - Add filter functionality to customer account licenses page - Update translations (402 strings) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
440 lines
13 KiB
PHP
440 lines
13 KiB
PHP
<?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\Admin\SettingsController;
|
|
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 update notifications are disabled
|
|
if ($this->isUpdateNotificationDisabled()) {
|
|
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);
|
|
|
|
// Handle auto-install setting
|
|
add_filter('auto_update_plugin', [$this, 'handleAutoInstall'], 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']);
|
|
add_action('update_option_wc_licensed_product_update_notification_enabled', [$this, 'clearCache']);
|
|
}
|
|
|
|
/**
|
|
* Check if update notifications are disabled
|
|
*/
|
|
private function isUpdateNotificationDisabled(): bool
|
|
{
|
|
// Check constant
|
|
if (defined('WC_LICENSE_DISABLE_AUTO_UPDATE') && WC_LICENSE_DISABLE_AUTO_UPDATE) {
|
|
return true;
|
|
}
|
|
|
|
// Check setting
|
|
return !SettingsController::isUpdateNotificationEnabled();
|
|
}
|
|
|
|
/**
|
|
* Handle auto-install setting for WordPress automatic updates
|
|
*
|
|
* @param bool|null $update The update decision
|
|
* @param object $item The plugin update object
|
|
* @return bool|null Whether to auto-update this plugin
|
|
*/
|
|
public function handleAutoInstall($update, $item): ?bool
|
|
{
|
|
// Only handle our plugin
|
|
if (!isset($item->plugin) || $item->plugin !== $this->pluginBasename) {
|
|
return $update;
|
|
}
|
|
|
|
// Return true to enable auto-install, false to disable, or null to use default
|
|
return SettingsController::isAutoInstallEnabled() ? true : $update;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|