| 1 |
<?php |
| 2 |
|
| 3 |
namespace gVectors\License\Services; |
| 4 |
|
| 5 |
// Exit if accessed directly |
| 6 |
use FilesystemIterator; |
| 7 |
use gVectors\License\Config; |
| 8 |
use gVectors\License\LicenseModule; |
| 9 |
use Plugin_Upgrader; |
| 10 |
use RecursiveDirectoryIterator; |
| 11 |
use RecursiveIteratorIterator; |
| 12 |
use SodiumException; |
| 13 |
use stdClass; |
| 14 |
use WP_Ajax_Upgrader_Skin; |
| 15 |
use WP_Error; |
| 16 |
|
| 17 |
if( ! defined( 'ABSPATH' ) ) exit; |
| 18 |
|
| 19 |
/** |
| 20 |
* Handles addon download, installation, activation, and update checks. |
| 21 |
* Downloads are always through signed URLs from the proxy server. |
| 22 |
*/ |
| 23 |
class AddonsService { |
| 24 |
public $licenseService; |
| 25 |
private $config; |
| 26 |
/** |
| 27 |
* Check for addon updates via the proxy server. |
| 28 |
* Fetches latest version info directly from the proxy (read from addon file headers on server), |
| 29 |
* only offers updates for licenses that are active/trial AND activated for this domain. |
| 30 |
* Expired licenses are NOT offered updates (addon keeps working but no new versions). |
| 31 |
*/ |
| 32 |
private $update_check_done = false; |
| 33 |
private $pruned = false; |
| 34 |
private $all_addons_transient_name; |
| 35 |
private $signature_check_hook; |
| 36 |
private $license_check_hook; |
| 37 |
private $tampered_option; |
| 38 |
private $expired_notice_option; |
| 39 |
private $tamper_dismissed_option; |
| 40 |
private $legacy_licenses_option; |
| 41 |
private $legacy_notice_option; |
| 42 |
/** Last successfully fetched store addon list (slug => parent host slugs) — used while the store server is unreachable */ |
| 43 |
private $store_addons_option; |
| 44 |
private $store_addons = null; |
| 45 |
/** Shared transient (not slug-prefixed) so one dismissing covers all plugin instances */ |
| 46 |
private static $shared_dev_env_transient = 'gvectors_dev_env_notice_dismissed'; |
| 47 |
private static $shared_dev_licenses_transient = 'gvectors_dev_licenses_notice_dismissed'; |
| 48 |
|
| 49 |
/** Static collectors for cross-instance notice deduplication */ |
| 50 |
private static $dev_env_notice_shown = false; |
| 51 |
private static $dev_licenses_collected = []; |
| 52 |
private static $dev_licenses_registered = false; |
| 53 |
/** Per-request "already rendered" markers so several host plugins never duplicate addon notices/rows */ |
| 54 |
private static $notices_shown = []; |
| 55 |
/** Shared (not slug-prefixed) queue + single-event hook for "updates can't be installed" notices — one email for all hosts */ |
| 56 |
private const BLOCKED_UPDATES_OPTION = 'gvectors_blocked_updates_queue'; |
| 57 |
private const BLOCKED_UPDATES_HOOK = 'gvectors_blocked_updates_notify'; |
| 58 |
|
| 59 |
public function __construct( Config $config, LicenseService $licenseService ) { |
| 60 |
$this->config = $config; |
| 61 |
$this->licenseService = $licenseService; |
| 62 |
$this->all_addons_transient_name = $this->config->get_core_plugin_slug() . '_gvectors_all_addons'; |
| 63 |
$this->signature_check_hook = $this->config->get_core_plugin_slug() . '_gvectors_addon_signature_check'; |
| 64 |
$this->license_check_hook = $this->config->get_core_plugin_slug() . '_gvectors_addon_license_check'; |
| 65 |
$this->tampered_option = $this->licenseService->tampered_option; |
| 66 |
$this->expired_notice_option = $this->licenseService->expired_notice_option; |
| 67 |
$this->tamper_dismissed_option = $this->config->get_core_plugin_slug() . '_gvectors_tamper_notice_seen'; |
| 68 |
$this->legacy_licenses_option = $this->config->get_core_plugin_slug() . '_gvectors_legacy_addon_licenses'; |
| 69 |
$this->legacy_notice_option = $this->config->get_core_plugin_slug() . '_gvectors_legacy_license_notices'; |
| 70 |
$this->store_addons_option = $this->config->get_core_plugin_slug() . '_gvectors_store_addons'; |
| 71 |
$this->init_hooks(); |
| 72 |
} |
| 73 |
|
| 74 |
private function init_hooks() { |
| 75 |
add_filter( 'pre_set_site_transient_update_plugins', [ $this, 'check_for_updates' ] ); |
| 76 |
add_filter( 'plugins_api', [ $this, 'plugin_info' ], 20, 3 ); |
| 77 |
|
| 78 |
// Force-refresh the update transient if unmigrated legacy addons exist |
| 79 |
add_action( 'admin_init', [ $this, 'maybe_refresh_update_transient' ] ); |
| 80 |
|
| 81 |
// Show license-required notice on plugin page for addons with updates but no active license |
| 82 |
add_action( 'admin_init', [ $this, 'register_unlicensed_update_row_hooks' ] ); |
| 83 |
|
| 84 |
// Signature integrity check cron (twice daily) |
| 85 |
add_action( $this->signature_check_hook, [ $this, 'verify_all_addon_signatures' ] ); |
| 86 |
if( ! wp_next_scheduled( $this->signature_check_hook ) ) { |
| 87 |
wp_schedule_event( time(), 'twicedaily', $this->signature_check_hook ); |
| 88 |
} |
| 89 |
|
| 90 |
// License validity check cron (daily) |
| 91 |
add_action( $this->license_check_hook, [ $this, 'check_all_license_validity' ] ); |
| 92 |
if( ! wp_next_scheduled( $this->license_check_hook ) ) { |
| 93 |
wp_schedule_event( time(), 'daily', $this->license_check_hook ); |
| 94 |
} |
| 95 |
|
| 96 |
// Admin notices |
| 97 |
add_action( 'admin_notices', [ $this, 'tampered_addon_notice' ] ); |
| 98 |
add_action( 'admin_notices', [ $this, 'expired_license_notice' ] ); |
| 99 |
add_action( 'admin_notices', [ $this, 'legacy_license_notice' ] ); |
| 100 |
add_action( 'admin_notices', [ $this, 'dev_environment_notice' ] ); |
| 101 |
add_action( 'admin_notices', [ $this, 'dev_licenses_notice' ] ); |
| 102 |
|
| 103 |
// Handle dismissal of dev environment notices |
| 104 |
add_action( 'admin_init', [ $this, 'handle_dev_notice_dismiss' ] ); |
| 105 |
|
| 106 |
// Track when admin has seen tamper notices |
| 107 |
add_action( 'admin_init', [ $this, 'track_tamper_notice_view' ] ); |
| 108 |
|
| 109 |
// Intercept plugin activation to validate addon before allowing it |
| 110 |
add_action( 'activate_plugin', [ $this, 'validate_on_activation' ] ); |
| 111 |
|
| 112 |
// Intercept WordPress updater downloads to block tampered addons with a visible error |
| 113 |
add_filter( 'upgrader_pre_download', [ $this, 'block_tampered_update_download' ], 10, 2 ); |
| 114 |
|
| 115 |
// Clear tamper flag when a plugin is deleted |
| 116 |
add_action( 'deleted_plugin', [ $this, 'on_plugin_deleted' ], 10, 2 ); |
| 117 |
|
| 118 |
// Entitled updates the site can't install → email the admins (news module), from cron |
| 119 |
add_action( self::BLOCKED_UPDATES_HOOK, [ self::class, 'notify_blocked_updates' ] ); |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* Install and activate an addon in one step. |
| 124 |
* An addon already on disk (installed but inactive, or uploaded manually via FTP) is only activated — |
| 125 |
* re-running the installer would attempt an update, which fails when none is pending or WordPress can't write plugins. |
| 126 |
*/ |
| 127 |
public function install_and_activate( string $product_id ): array { |
| 128 |
$license = $this->licenseService->get( $product_id ); |
| 129 |
$plugin_slug = self::sanitize_slug( $license['plugin_slug'] ?? '' ); |
| 130 |
$plugin_file = ! empty( $license['license_key'] ) ? $this->get_installed_plugin_file( $plugin_slug ) : ''; |
| 131 |
|
| 132 |
if( $plugin_file ) { |
| 133 |
// Verify here so a bad manual upload gets a clear JSON error instead of the activation gate's wp_die() |
| 134 |
$sig_result = $this->verify_addon_signatures( $plugin_slug ); |
| 135 |
if( ! in_array( $sig_result, [ 'valid', 'legacy_valid' ], true ) ) { |
| 136 |
return [ |
| 137 |
'success' => false, |
| 138 |
'error' => self::signature_failure_reason( $sig_result ) . ' ' . sprintf( |
| 139 |
/* translators: %s: addon folder name */ |
| 140 |
__( 'Please delete the "%s" folder from wp-content/plugins and upload the folder again from the ZIP you downloaded (including the hidden .addon-signatures.json file, using binary transfer mode).', 'gvectors' ), |
| 141 |
$plugin_slug |
| 142 |
), |
| 143 |
'manual_install' => true, |
| 144 |
]; |
| 145 |
} |
| 146 |
|
| 147 |
return $this->activate( $plugin_file ); |
| 148 |
} |
| 149 |
|
| 150 |
$install_result = $this->install( $product_id ); |
| 151 |
if( empty( $install_result['success'] ) ) return $install_result; |
| 152 |
|
| 153 |
$plugin_file = $install_result['plugin_file']; |
| 154 |
if( empty( $plugin_file ) ) { |
| 155 |
return [ 'success' => false, 'error' => __( 'Could not determine plugin file after installation', 'gvectors' ) ]; |
| 156 |
} |
| 157 |
|
| 158 |
$activate_result = $this->activate( $plugin_file ); |
| 159 |
if( empty( $activate_result['success'] ) ) return $activate_result; |
| 160 |
|
| 161 |
// Clear cached plugin list so subsequent get_plugins() calls see the new addon |
| 162 |
wp_cache_delete( 'plugins', 'plugins' ); |
| 163 |
|
| 164 |
return [ |
| 165 |
'success' => true, |
| 166 |
'message' => __( 'Addon installed and activated successfully', 'gvectors' ), |
| 167 |
]; |
| 168 |
} |
| 169 |
|
| 170 |
/** |
| 171 |
* Whether WordPress can install addons on this site from an AJAX request. |
| 172 |
* False when file modifications are disabled (DISALLOW_FILE_MODS strips install_plugins), or when the |
| 173 |
* plugins folder isn't directly writable and no FTP/SSH credentials are predefined (AJAX can't prompt for them). |
| 174 |
* Such sites get the addon ZIP for a manual upload instead. |
| 175 |
*/ |
| 176 |
public function can_install_addons(): bool { |
| 177 |
return current_user_can( 'install_plugins' ) && self::site_can_install_plugins(); |
| 178 |
} |
| 179 |
|
| 180 |
/** |
| 181 |
* Site-level half of can_install_addons(), without any user context (safe in cron): file |
| 182 |
* modifications allowed (DISALLOW_FILE_MODS / the file_mod_allowed filter) and a filesystem |
| 183 |
* WordPress can write plugins to — direct access for the context the upgrader's fs_connect() |
| 184 |
* uses plus a writable plugins folder, or predefined FTP/SSH credentials. Static per request. |
| 185 |
*/ |
| 186 |
public static function site_can_install_plugins(): bool { |
| 187 |
static $can_install = null; |
| 188 |
if( $can_install !== null ) return $can_install; |
| 189 |
|
| 190 |
if( ! wp_is_file_mod_allowed( 'gvectors_addon_install' ) ) return $can_install = false; |
| 191 |
|
| 192 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 193 |
if( get_filesystem_method( [], WP_CONTENT_DIR ) === 'direct' ) return $can_install = wp_is_writable( WP_PLUGIN_DIR ); |
| 194 |
|
| 195 |
return $can_install = defined( 'FTP_HOST' ) && defined( 'FTP_USER' ) && ( defined( 'FTP_PASS' ) || defined( 'FTP_PRIKEY' ) ); |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* Signed, one-time addon ZIP URL for the admin's browser — the manual install path (FTP upload) |
| 200 |
* for sites where WordPress can't write plugins. Needs an active license only, not install_plugins, |
| 201 |
* and is allowed for tampered addons too: a clean copy is how those get fixed. |
| 202 |
*/ |
| 203 |
public function get_download_link( string $product_id ): array { |
| 204 |
if( ! $this->licenseService->is_active( $product_id ) ) { |
| 205 |
return [ 'success' => false, 'error' => __( 'No active license for this product', 'gvectors' ) ]; |
| 206 |
} |
| 207 |
|
| 208 |
$download = $this->request_download( $product_id ); |
| 209 |
if( empty( $download['success'] ) ) return $download; |
| 210 |
|
| 211 |
$download['file_name'] = ( $download['plugin_slug'] ?: 'addon' ) . '.zip'; |
| 212 |
|
| 213 |
return $download; |
| 214 |
} |
| 215 |
|
| 216 |
/** |
| 217 |
* Request a signed, one-time download URL for a licensed addon from the proxy server |
| 218 |
*/ |
| 219 |
private function request_download( string $product_id ): array { |
| 220 |
$license = $this->licenseService->get( $product_id ); |
| 221 |
if( empty( $license ) || empty( $license['license_key'] ) ) { |
| 222 |
return [ 'success' => false, 'error' => __( 'No active license for this product', 'gvectors' ) ]; |
| 223 |
} |
| 224 |
|
| 225 |
$response = $this->licenseService->apiService->get_addon_download_url( $product_id, $license['license_key'] ); |
| 226 |
error_log( '[gVectors Addon] download-url response: ' . print_r( $response, true ) ); |
| 227 |
if( empty( $response['success'] ) || empty( $response['data']['download_url'] ) ) { |
| 228 |
$error = $response['error'] ?? __( 'Failed to get download URL', 'gvectors' ); |
| 229 |
if( isset( $response['data']['error'] ) ) $error = $response['data']['error']; |
| 230 |
error_log( '[gVectors Addon] Failed to get download URL: ' . $error ); |
| 231 |
|
| 232 |
return [ 'success' => false, 'error' => $error ]; |
| 233 |
} |
| 234 |
|
| 235 |
$download_url = add_query_arg( 'site_domain', rawurlencode( LicenseModule::get_site_domain() ), $response['data']['download_url'] ); |
| 236 |
$plugin_slug = self::sanitize_slug( $response['data']['plugin_slug'] ?? '' ); |
| 237 |
error_log( '[gVectors Addon] download_url: ' . $download_url . ' | plugin_slug: ' . $plugin_slug ); |
| 238 |
|
| 239 |
return [ 'success' => true, 'download_url' => $download_url, 'plugin_slug' => $plugin_slug ]; |
| 240 |
} |
| 241 |
|
| 242 |
/** |
| 243 |
* Download and install an addon from the proxy server |
| 244 |
*/ |
| 245 |
public function install( string $product_id ): array { |
| 246 |
if( ! $this->can_install_addons() ) { |
| 247 |
return [ |
| 248 |
'success' => false, |
| 249 |
'error' => __( 'WordPress is not allowed to install plugins on this site (file modifications are disabled or the plugins folder is not writable). Download the addon ZIP and upload it manually.', 'gvectors' ), |
| 250 |
'manual_install' => true, |
| 251 |
]; |
| 252 |
} |
| 253 |
|
| 254 |
$license = $this->licenseService->get( $product_id ); |
| 255 |
if( empty( $license ) || empty( $license['license_key'] ) ) { |
| 256 |
return [ 'success' => false, 'error' => __( 'No active license for this product', 'gvectors' ) ]; |
| 257 |
} |
| 258 |
|
| 259 |
// Block install/update for tampered/unauthorized addons |
| 260 |
$plugin_slug = $license['plugin_slug'] ?? ''; |
| 261 |
if( $plugin_slug && $this->is_addon_tampered( $plugin_slug ) ) { |
| 262 |
return [ |
| 263 |
'success' => false, |
| 264 |
'error' => __( |
| 265 |
'This addon cannot be updated because its files have been modified or are not original. To resolve this, please: 1) Go to Plugins and deactivate, then delete this addon. 2) Visit the gVectors Store Addons page and make sure your license is active. 3) Re-install the addon from the gVectors Store Addons page. Once re-installed, everything will work normally again.', |
| 266 |
'gvectors' |
| 267 |
), |
| 268 |
]; |
| 269 |
} |
| 270 |
|
| 271 |
// Get signed download URL from proxy |
| 272 |
$download = $this->request_download( $product_id ); |
| 273 |
if( empty( $download['success'] ) ) return $download; |
| 274 |
|
| 275 |
$download_url = $download['download_url']; |
| 276 |
$plugin_slug = $download['plugin_slug']; |
| 277 |
|
| 278 |
// Use WordPress built-in plugin installer |
| 279 |
require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; |
| 280 |
require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; |
| 281 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 282 |
require_once ABSPATH . 'wp-admin/includes/misc.php'; |
| 283 |
|
| 284 |
$skin = new WP_Ajax_Upgrader_Skin(); |
| 285 |
$upgrader = new Plugin_Upgrader( $skin ); |
| 286 |
|
| 287 |
// Check if plugin already installed - if so, upgrade |
| 288 |
$installed_plugin = $this->get_installed_plugin_file( $plugin_slug ); |
| 289 |
if( $installed_plugin ) { |
| 290 |
$result = $upgrader->upgrade( $installed_plugin, [ 'clear_update_cache' => true ] ); |
| 291 |
} else { |
| 292 |
$result = $upgrader->install( $download_url ); |
| 293 |
} |
| 294 |
|
| 295 |
// Upgrader failures are mostly filesystem problems — offer the manual (ZIP + FTP) install path |
| 296 |
if( is_wp_error( $result ) ) { |
| 297 |
error_log( '[gVectors Addon] WP_Error from upgrader: ' . $result->get_error_message() ); |
| 298 |
|
| 299 |
return [ 'success' => false, 'error' => $result->get_error_message(), 'manual_install' => true ]; |
| 300 |
} |
| 301 |
|
| 302 |
if( $result === false ) { |
| 303 |
$errors = $skin->get_errors(); |
| 304 |
$error = is_wp_error( $errors ) ? $errors->get_error_message() : __( 'Installation failed', 'gvectors' ); |
| 305 |
$skin_feedback = method_exists( $skin, 'get_upgrade_messages' ) ? $skin->get_upgrade_messages() : []; |
| 306 |
error_log( '[gVectors Addon] Install result=false. Error: ' . $error . ' | Feedback: ' . print_r( $skin_feedback, true ) ); |
| 307 |
|
| 308 |
return [ 'success' => false, 'error' => $error, 'manual_install' => true ]; |
| 309 |
} |
| 310 |
|
| 311 |
error_log( '[gVectors Addon] Install result: ' . print_r( $result, true ) ); |
| 312 |
error_log( '[gVectors Addon] Skin messages: ' . print_r( $skin->get_upgrade_messages(), true ) ); |
| 313 |
|
| 314 |
$plugin_file = $installed_plugin ?: $upgrader->plugin_info(); |
| 315 |
|
| 316 |
// Fallback: if plugin_info() returned empty, re-scan installed plugins by slug |
| 317 |
if( empty( $plugin_file ) && ! empty( $plugin_slug ) ) { |
| 318 |
// Clear cached plugin list so get_plugins() picks up the newly installed addon |
| 319 |
wp_cache_delete( 'plugins', 'plugins' ); |
| 320 |
$plugin_file = $this->get_installed_plugin_file( $plugin_slug ); |
| 321 |
} |
| 322 |
|
| 323 |
// Last resort: scan the plugin directory for a file with a Plugin Name header |
| 324 |
if( empty( $plugin_file ) && ! empty( $plugin_slug ) ) { |
| 325 |
$plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug; |
| 326 |
if( is_dir( $plugin_dir ) ) { |
| 327 |
foreach( glob( $plugin_dir . '/*.php' ) as $php_file ) { |
| 328 |
$headers = get_plugin_data( $php_file, false, false ); |
| 329 |
if( ! empty( $headers['Name'] ) ) { |
| 330 |
$plugin_file = $plugin_slug . '/' . basename( $php_file ); |
| 331 |
break; |
| 332 |
} |
| 333 |
} |
| 334 |
} |
| 335 |
} |
| 336 |
|
| 337 |
// Verify file signatures after installation — if verification fails, block activation |
| 338 |
if( $plugin_slug ) { |
| 339 |
$sig_result = $this->verify_addon_signatures( $plugin_slug ); |
| 340 |
if( ! in_array( $sig_result, [ 'valid', 'legacy_valid' ], true ) ) { |
| 341 |
// Signatures invalid after fresh installation — possible MITM or corrupted download |
| 342 |
if( $plugin_file && is_plugin_active( $plugin_file ) ) { |
| 343 |
deactivate_plugins( $plugin_file ); |
| 344 |
} |
| 345 |
|
| 346 |
return [ |
| 347 |
'success' => false, |
| 348 |
'error' => __( 'Addon installed but signature verification failed. The download may have been corrupted. Please try again.', 'gvectors' ), |
| 349 |
]; |
| 350 |
} |
| 351 |
} |
| 352 |
|
| 353 |
return [ |
| 354 |
'success' => true, |
| 355 |
'plugin_file' => $plugin_file, |
| 356 |
'message' => __( 'Addon installed successfully', 'gvectors' ), |
| 357 |
]; |
| 358 |
} |
| 359 |
|
| 360 |
/** |
| 361 |
* Check if an addon is flagged as tampered/unauthorized |
| 362 |
*/ |
| 363 |
public function is_addon_tampered( string $plugin_slug ): bool { |
| 364 |
$tampered = get_option( $this->tampered_option, [] ); |
| 365 |
|
| 366 |
return isset( $tampered[ $plugin_slug ] ); |
| 367 |
} |
| 368 |
|
| 369 |
/** |
| 370 |
* Find the installed plugin file by slug |
| 371 |
*/ |
| 372 |
private function get_installed_plugin_file( string $plugin_slug ): string { |
| 373 |
if( empty( $plugin_slug ) ) return ''; |
| 374 |
|
| 375 |
if( ! function_exists( 'get_plugins' ) ) { |
| 376 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 377 |
} |
| 378 |
|
| 379 |
$all_plugins = get_plugins(); |
| 380 |
foreach( $all_plugins as $file => $data ) { |
| 381 |
if( strpos( $file, $plugin_slug . '/' ) === 0 ) { |
| 382 |
return $file; |
| 383 |
} |
| 384 |
} |
| 385 |
|
| 386 |
return ''; |
| 387 |
} |
| 388 |
|
| 389 |
/** |
| 390 |
* Provide plugin info for the WordPress updater popup ("View version X details"). |
| 391 |
* Uses proxy server data for version/compatibility info (from addon file headers). |
| 392 |
* Does NOT fetch download URL — that is handled by check_for_updates() in the update transient. |
| 393 |
*/ |
| 394 |
public function plugin_info( $result, $action, $args ) { |
| 395 |
if( $action !== 'plugin_information' ) return $result; |
| 396 |
|
| 397 |
// Fetch addon metadata from proxy server (cached via transient) |
| 398 |
$proxy_addons = $this->get_proxy_addons_map(); |
| 399 |
if( ! isset( $proxy_addons[ $args->slug ] ) ) return $result; |
| 400 |
|
| 401 |
$proxy_info = $proxy_addons[ $args->slug ]; |
| 402 |
|
| 403 |
$info = new stdClass(); |
| 404 |
$info->name = ! empty( $proxy_info['name'] ) ? $proxy_info['name'] : $args->slug; |
| 405 |
$info->slug = $args->slug; |
| 406 |
$info->version = ! empty( $proxy_info['version'] ) ? $proxy_info['version'] : ''; |
| 407 |
$info->author = ! empty( $proxy_info['author'] ) ? $proxy_info['author'] : 'gVectors Team'; |
| 408 |
$info->author_profile = ! empty( $proxy_info['author_uri'] ) ? $proxy_info['author_uri'] : 'https://gvectors.com'; |
| 409 |
$info->homepage = ! empty( $proxy_info['plugin_uri'] ) ? $proxy_info['plugin_uri'] : 'https://gvectors.com'; |
| 410 |
$info->requires = ! empty( $proxy_info['requires'] ) ? $proxy_info['requires'] : '5.0'; |
| 411 |
$info->tested = ! empty( $proxy_info['tested'] ) ? $proxy_info['tested'] : get_bloginfo( 'version' ); |
| 412 |
$info->requires_php = ! empty( $proxy_info['requires_php'] ) ? $proxy_info['requires_php'] : '7.4'; |
| 413 |
$info->download_link = ''; // No download URL here — WordPress uses $update->package from the transient |
| 414 |
|
| 415 |
$info->sections = [ |
| 416 |
'description' => ! empty( $proxy_info['description'] ) ? $proxy_info['description'] : '', |
| 417 |
'changelog' => ! empty( $proxy_info['changelog'] ) ? $proxy_info['changelog'] : '', |
| 418 |
]; |
| 419 |
|
| 420 |
// Use the addon's featured image from Paddle as the update popup banner |
| 421 |
if( ! empty( $proxy_info['image_url'] ) ) { |
| 422 |
$info->banners = [ |
| 423 |
'high' => $proxy_info['image_url'], |
| 424 |
'low' => $proxy_info['image_url'], |
| 425 |
]; |
| 426 |
} |
| 427 |
|
| 428 |
// Use the logo as the plugin icon |
| 429 |
if( ! empty( $proxy_info['logo'] ) ) { |
| 430 |
$info->icons = [ |
| 431 |
'1x' => $proxy_info['logo'], |
| 432 |
'2x' => $proxy_info['logo'], |
| 433 |
]; |
| 434 |
} |
| 435 |
|
| 436 |
return $info; |
| 437 |
} |
| 438 |
|
| 439 |
/** |
| 440 |
* Fetch all addon info from the proxy server, keyed by slug. |
| 441 |
* Returns associative array: slug => [ name, version, description, author, requires, tested, requires_php, plugin_uri, ... ] |
| 442 |
* Host plugins (wpForo, wpDiscuz, ...) are never part of the map — they update from wordpress.org. |
| 443 |
*/ |
| 444 |
private function get_proxy_addons_map(): array { |
| 445 |
$response = $this->licenseService->apiService->get_all_addons(); |
| 446 |
if( empty( $response['success'] ) || empty( $response['data']['addons'] ) || ! is_array( $response['data']['addons'] ) ) { |
| 447 |
return []; |
| 448 |
} |
| 449 |
$map = []; |
| 450 |
foreach( $response['data']['addons'] as $addon ) { |
| 451 |
if( ! empty( $addon['slug'] ) && is_string( $addon['slug'] ) && ! $this->is_host_plugin( $addon['slug'] ) ) { |
| 452 |
$map[ $addon['slug'] ] = $addon; |
| 453 |
} |
| 454 |
} |
| 455 |
$this->remember_store_addons( $map ); |
| 456 |
|
| 457 |
return $map; |
| 458 |
} |
| 459 |
|
| 460 |
/** |
| 461 |
* All addons sold in the gVectors store: slug => host plugin slugs the addon belongs to |
| 462 |
* (from the products' Paddle `parent_slug`; [] = belongs to every host, e.g. wpForo AND wpDiscuz). |
| 463 |
* This list is the ONLY way an installed plugin is recognized as one of our addons — plugin/folder |
| 464 |
* names are never used. Falls back to the last successfully fetched list while the store is unreachable. |
| 465 |
* |
| 466 |
* @param bool $allow_remote false = never make an HTTP request (for page-load paths like admin_init) |
| 467 |
*/ |
| 468 |
private function get_store_addons( bool $allow_remote = true ): array { |
| 469 |
if( $this->store_addons !== null ) return $this->store_addons; |
| 470 |
|
| 471 |
if( $allow_remote ) { |
| 472 |
$map = $this->get_proxy_addons_map(); |
| 473 |
if( ! empty( $map ) ) return $this->store_addons = self::extract_parent_slugs( $map ); |
| 474 |
} |
| 475 |
|
| 476 |
$known = get_option( $this->store_addons_option, [] ); |
| 477 |
if( ! is_array( $known ) ) return []; |
| 478 |
|
| 479 |
$addons = []; |
| 480 |
foreach( $known as $slug => $parents ) { |
| 481 |
if( is_string( $slug ) && $slug !== '' && ! $this->is_host_plugin( $slug ) ) { |
| 482 |
$addons[ $slug ] = is_array( $parents ) ? $parents : []; |
| 483 |
} |
| 484 |
} |
| 485 |
|
| 486 |
return $addons; |
| 487 |
} |
| 488 |
|
| 489 |
/** |
| 490 |
* slug => sanitized host plugin slugs from the store's `parent_slugs` ([] or missing = all hosts). |
| 491 |
*/ |
| 492 |
private static function extract_parent_slugs( array $proxy_addons ): array { |
| 493 |
$addons = []; |
| 494 |
foreach( $proxy_addons as $slug => $addon ) { |
| 495 |
$parents = isset( $addon['parent_slugs'] ) && is_array( $addon['parent_slugs'] ) ? $addon['parent_slugs'] : []; |
| 496 |
$parents = array_values( array_unique( array_filter( $parents, function( $parent ) { |
| 497 |
return is_string( $parent ) && $parent !== ''; |
| 498 |
} ) ) ); |
| 499 |
sort( $parents ); |
| 500 |
$addons[ (string) $slug ] = $parents; |
| 501 |
} |
| 502 |
ksort( $addons ); |
| 503 |
|
| 504 |
return $addons; |
| 505 |
} |
| 506 |
|
| 507 |
/** |
| 508 |
* Persist the store addon list (not autoloaded) so addon checks keep working during store outages. |
| 509 |
*/ |
| 510 |
private function remember_store_addons( array $proxy_addons ): void { |
| 511 |
if( empty( $proxy_addons ) ) return; |
| 512 |
$addons = self::extract_parent_slugs( $proxy_addons ); |
| 513 |
if( get_option( $this->store_addons_option ) !== $addons ) { |
| 514 |
update_option( $this->store_addons_option, $addons, false ); |
| 515 |
} |
| 516 |
} |
| 517 |
|
| 518 |
/** |
| 519 |
* Does the addon belong to this host plugin? Products with an empty/missing Paddle `parent_slug` |
| 520 |
* belong to every host; otherwise only to the listed host(s). |
| 521 |
*/ |
| 522 |
private function addon_belongs_to_host( string $plugin_slug, bool $allow_remote = true ): bool { |
| 523 |
$parents = $this->get_store_addons( $allow_remote )[ $plugin_slug ] ?? []; |
| 524 |
|
| 525 |
return empty( $parents ) || in_array( $this->config->get_core_plugin_slug(), $parents, true ); |
| 526 |
} |
| 527 |
|
| 528 |
/** |
| 529 |
* Should this host instance handle the addon (updates without own license, activation gate, |
| 530 |
* integrity scan, notices)? Yes when this host holds a license for it; otherwise only when no |
| 531 |
* other host holds a license and the addon belongs to this host (or to all hosts). |
| 532 |
*/ |
| 533 |
private function manages_addon( string $plugin_slug, bool $allow_remote = true ): bool { |
| 534 |
if( $this->addon_has_license( $plugin_slug ) ) return true; |
| 535 |
if( $this->is_licensed_by_other_host( $plugin_slug ) ) return false; |
| 536 |
|
| 537 |
return $this->addon_belongs_to_host( $plugin_slug, $allow_remote ); |
| 538 |
} |
| 539 |
|
| 540 |
/** |
| 541 |
* Host plugins running this module (wpForo, wpDiscuz, ...) are distributed via wordpress.org. |
| 542 |
* They must never be treated as store addons, so their core updates are never touched. |
| 543 |
*/ |
| 544 |
private function is_host_plugin( string $plugin_slug ): bool { |
| 545 |
return $plugin_slug === $this->config->get_core_plugin_slug() || in_array( $plugin_slug, LicenseModule::get_host_slugs(), true ); |
| 546 |
} |
| 547 |
|
| 548 |
/** |
| 549 |
* Does another host plugin on this site (e.g. wpDiscuz when this instance is wpForo) hold a license for the addon? |
| 550 |
* That host's instance then owns the addon's updates, activation gate and integrity checks. |
| 551 |
*/ |
| 552 |
private function is_licensed_by_other_host( string $plugin_slug ): bool { |
| 553 |
foreach( LicenseModule::get_host_slugs() as $host ) { |
| 554 |
if( $host === $this->config->get_core_plugin_slug() ) continue; |
| 555 |
$actions = LicenseModule::getActionsService( $host ); |
| 556 |
if( $actions && $actions->addonsService->addon_has_license( $plugin_slug ) ) return true; |
| 557 |
} |
| 558 |
|
| 559 |
return false; |
| 560 |
} |
| 561 |
|
| 562 |
/** |
| 563 |
* Claim the right to render a per-addon notice/row once per request across all host plugin instances. |
| 564 |
*/ |
| 565 |
private static function claim_notice( string $type, string $plugin_slug ): bool { |
| 566 |
$key = $type . ':' . $plugin_slug; |
| 567 |
if( isset( self::$notices_shown[ $key ] ) ) return false; |
| 568 |
self::$notices_shown[ $key ] = true; |
| 569 |
|
| 570 |
return true; |
| 571 |
} |
| 572 |
|
| 573 |
/** |
| 574 |
* Verify signatures of a single addon by its slug. |
| 575 |
* Checks: manifest existence, file hashes, domain signature, PHP header signatures. |
| 576 |
* For addons without a manifest, checks legacy license before flagging as tampered. |
| 577 |
* Returns 'valid', 'legacy_valid', 'no_manifest', 'tampered', 'domain_mismatch', 'no_signatures', or 'patched'. |
| 578 |
*/ |
| 579 |
public function verify_addon_signatures( string $plugin_slug ): string { |
| 580 |
// Development/local/staging environments are always valid |
| 581 |
if( LicenseModule::is_development_site() ) return 'valid'; |
| 582 |
|
| 583 |
$plugin_slug = self::sanitize_slug( $plugin_slug ); |
| 584 |
$plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug; |
| 585 |
$manifest_file = $plugin_dir . '/.addon-signatures.json'; |
| 586 |
|
| 587 |
// No manifest at all — could be a pirated copy OR a legacy-licensed installation |
| 588 |
// from before the new signature system. Check legacy license before flagging. |
| 589 |
if( ! file_exists( $manifest_file ) ) { |
| 590 |
// Always check for nulled/patched patterns first (catches case 4 regardless) |
| 591 |
$patch_check = $this->detect_nulled_patterns( $plugin_slug ); |
| 592 |
if( $patch_check !== 'valid' ) { |
| 593 |
return $patch_check; |
| 594 |
} |
| 595 |
|
| 596 |
// Check if this addon has a legacy license from the old gVectors system |
| 597 |
$legacy = $this->check_legacy_license( $plugin_slug ); |
| 598 |
if( ! empty( $legacy['has_license'] ) ) { |
| 599 |
// Legacy licensed addon — clear any previous tamper flags |
| 600 |
$this->clear_tamper_flag( $plugin_slug ); |
| 601 |
// Track expired legacy licenses for admin notice |
| 602 |
$this->update_legacy_notice( $plugin_slug, $legacy ); |
| 603 |
|
| 604 |
return 'legacy_valid'; |
| 605 |
} |
| 606 |
|
| 607 |
// No legacy license either — this is an unauthorized copy |
| 608 |
$this->mark_addon_tampered( $plugin_slug, [ 'Missing signature manifest' ], 'no_manifest' ); |
| 609 |
|
| 610 |
return 'no_manifest'; |
| 611 |
} |
| 612 |
|
| 613 |
$raw_manifest = json_decode( file_get_contents( $manifest_file ), true ); |
| 614 |
if( ! is_array( $raw_manifest ) || empty( $raw_manifest ) ) { |
| 615 |
$this->mark_addon_tampered( $plugin_slug, [ 'Empty or corrupted signature manifest' ] ); |
| 616 |
|
| 617 |
return 'tampered'; |
| 618 |
} |
| 619 |
|
| 620 |
// Support both new format { "files": {...}, "manifest_signature": "..." } |
| 621 |
// and legacy format { "file.php": {...} } for backwards compatibility |
| 622 |
if( isset( $raw_manifest['files'] ) && is_array( $raw_manifest['files'] ) ) { |
| 623 |
$manifest = $raw_manifest['files']; |
| 624 |
$manifest_signature = $raw_manifest['manifest_signature'] ?? null; |
| 625 |
} else { |
| 626 |
$manifest = $raw_manifest; |
| 627 |
$manifest_signature = null; |
| 628 |
} |
| 629 |
|
| 630 |
if( empty( $manifest ) ) { |
| 631 |
$this->mark_addon_tampered( $plugin_slug, [ 'Empty signature manifest (no files)' ] ); |
| 632 |
|
| 633 |
return 'tampered'; |
| 634 |
} |
| 635 |
|
| 636 |
// 0) Verify manifest cryptographic signature (Ed25519) |
| 637 |
// Prevents manifest forgery — attacker cannot modify signed_for/file_hash/signatures |
| 638 |
// without invalidating the signature, and cannot re-sign without the server's private key. |
| 639 |
if( $manifest_signature !== null |
| 640 |
&& $this->config->get_manifest_public_key() !== 'REPLACE_WITH_YOUR_ED25519_PUBLIC_KEY_HEX' |
| 641 |
&& function_exists( 'sodium_crypto_sign_verify_detached' ) |
| 642 |
) { |
| 643 |
try { |
| 644 |
$canonical_json = json_encode( $manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); |
| 645 |
$public_key = sodium_hex2bin( $this->config->get_manifest_public_key() ); |
| 646 |
$sig = sodium_hex2bin( $manifest_signature ); |
| 647 |
if( ! sodium_crypto_sign_verify_detached( $sig, $canonical_json, $public_key ) ) { |
| 648 |
$this->mark_addon_tampered( $plugin_slug, [ 'Manifest cryptographic signature is invalid — possible forgery' ] ); |
| 649 |
|
| 650 |
return 'tampered'; |
| 651 |
} |
| 652 |
} catch ( SodiumException $e ) { |
| 653 |
$this->mark_addon_tampered( $plugin_slug, [ 'Manifest signature corrupted: ' . $e->getMessage() ] ); |
| 654 |
|
| 655 |
return 'tampered'; |
| 656 |
} |
| 657 |
} |
| 658 |
|
| 659 |
// 1) Verify file hashes |
| 660 |
$tampered_files = []; |
| 661 |
$real_plugin_dir = realpath( $plugin_dir ); |
| 662 |
foreach( $manifest as $relative_path => $info ) { |
| 663 |
// Prevent path traversal via crafted manifest keys |
| 664 |
if( strpos( $relative_path, '..' ) !== false || strpos( $relative_path, '/' ) === 0 ) { |
| 665 |
$this->mark_addon_tampered( $plugin_slug, [ 'Manifest contains invalid path: ' . $relative_path ] ); |
| 666 |
|
| 667 |
return 'tampered'; |
| 668 |
} |
| 669 |
$file_path = $plugin_dir . '/' . $relative_path; |
| 670 |
if( ! file_exists( $file_path ) ) { |
| 671 |
$tampered_files[] = $relative_path . ' (missing)'; |
| 672 |
continue; |
| 673 |
} |
| 674 |
|
| 675 |
// Verify resolved path is within the plugin directory (prevents symlink escapes) |
| 676 |
if( $real_plugin_dir ) { |
| 677 |
$real_file = realpath( $file_path ); |
| 678 |
if( $real_file === false || strpos( $real_file, $real_plugin_dir . DIRECTORY_SEPARATOR ) !== 0 ) { |
| 679 |
$this->mark_addon_tampered( $plugin_slug, [ 'File escapes plugin directory: ' . $relative_path ] ); |
| 680 |
|
| 681 |
return 'tampered'; |
| 682 |
} |
| 683 |
} |
| 684 |
|
| 685 |
$current_hash = hash( 'sha256', file_get_contents( $file_path ) ); |
| 686 |
if( isset( $info['file_hash'] ) && $current_hash !== $info['file_hash'] ) { |
| 687 |
$tampered_files[] = $relative_path; |
| 688 |
} |
| 689 |
} |
| 690 |
|
| 691 |
if( ! empty( $tampered_files ) ) { |
| 692 |
$this->mark_addon_tampered( $plugin_slug, $tampered_files ); |
| 693 |
|
| 694 |
return 'tampered'; |
| 695 |
} |
| 696 |
|
| 697 |
// 2) Verify domain signature matches this site |
| 698 |
$site_domain = LicenseModule::get_site_domain(); |
| 699 |
$site_normalized = LicenseModule::normalize_domain( $site_domain ); |
| 700 |
foreach( $manifest as $info ) { |
| 701 |
if( empty( $info['signed_for'] ) ) continue; |
| 702 |
$signed_normalized = LicenseModule::normalize_domain( $info['signed_for'] ); |
| 703 |
if( $signed_normalized !== $site_normalized ) { |
| 704 |
$this->mark_addon_tampered( $plugin_slug, [ |
| 705 |
'Domain mismatch: addon signed for ' . $info['signed_for'] . ', running on ' . $site_domain, |
| 706 |
], 'domain_mismatch' ); |
| 707 |
|
| 708 |
return 'domain_mismatch'; |
| 709 |
} |
| 710 |
} |
| 711 |
|
| 712 |
// 3) Detect extra PHP files not listed in the manifest. |
| 713 |
// An attacker could add malicious PHP files that bypass all signature checks |
| 714 |
// if we only iterate over manifest keys. Scan the actual directory instead. |
| 715 |
$all_php_files = $this->get_php_files_recursive( $plugin_dir ); |
| 716 |
foreach( $all_php_files as $php_file ) { |
| 717 |
$relative = str_replace( $plugin_dir . '/', '', $php_file ); |
| 718 |
if( ! isset( $manifest[ $relative ] ) ) { |
| 719 |
$this->mark_addon_tampered( $plugin_slug, [ |
| 720 |
'Unauthorized PHP file not in manifest: ' . $relative, |
| 721 |
] ); |
| 722 |
|
| 723 |
return 'tampered'; |
| 724 |
} |
| 725 |
} |
| 726 |
|
| 727 |
// 4) Verify PHP file headers contain our signature comment |
| 728 |
$header_check = $this->verify_php_header_signatures( $plugin_slug, $manifest ); |
| 729 |
if( $header_check !== 'valid' ) { |
| 730 |
return $header_check; |
| 731 |
} |
| 732 |
|
| 733 |
// 5) Check for known nulled/patched patterns in PHP files |
| 734 |
$patch_check = $this->detect_nulled_patterns( $plugin_slug ); |
| 735 |
if( $patch_check !== 'valid' ) { |
| 736 |
return $patch_check; |
| 737 |
} |
| 738 |
|
| 739 |
// All checks passed - clear any previous tamper flags |
| 740 |
$this->clear_tamper_flag( $plugin_slug ); |
| 741 |
|
| 742 |
return 'valid'; |
| 743 |
} |
| 744 |
|
| 745 |
/** |
| 746 |
* Sanitize a plugin slug to prevent directory traversal. |
| 747 |
* Only allows alphanumeric characters, hyphens, and underscores. |
| 748 |
*/ |
| 749 |
public static function sanitize_slug( string $slug ): string { |
| 750 |
return preg_replace( '/[^a-zA-Z0-9_-]/', '', $slug ); |
| 751 |
} |
| 752 |
|
| 753 |
/** |
| 754 |
* Detect common nulled/patched plugin patterns: |
| 755 |
* - License check bypasses |
| 756 |
* - Known nulling tool signatures |
| 757 |
* - Suspicious eval/base64 injections |
| 758 |
* - Removed or stubbed license verification functions |
| 759 |
*/ |
| 760 |
private function detect_nulled_patterns( string $plugin_slug ): string { |
| 761 |
$plugin_slug = self::sanitize_slug( $plugin_slug ); |
| 762 |
$plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug; |
| 763 |
if( ! is_dir( $plugin_dir ) ) return 'valid'; |
| 764 |
|
| 765 |
$suspicious_patterns = [ |
| 766 |
'/\b(nulled|cracked|patched|warez|gpl\s*club|gpldl)\b/i', |
| 767 |
'/eval\s*\(\s*base64_decode\s*\(/i', |
| 768 |
'/eval\s*\(\s*gzinflate\s*\(/i', |
| 769 |
'/eval\s*\(\s*str_rot13\s*\(/i', |
| 770 |
'/\$GLOBALS\s*\[\s*[\'"][a-z0-9_]{30,}[\'"]\s*\]/i', |
| 771 |
'/preg_replace\s*\(\s*[\'"]\/[^\/]*\/e[\'"]/i', |
| 772 |
]; |
| 773 |
|
| 774 |
$flagged_files = []; |
| 775 |
$php_files = $this->get_php_files_recursive( $plugin_dir ); |
| 776 |
|
| 777 |
foreach( $php_files as $file ) { |
| 778 |
$content = file_get_contents( $file ); |
| 779 |
if( $content === false ) continue; |
| 780 |
|
| 781 |
foreach( $suspicious_patterns as $pattern ) { |
| 782 |
if( preg_match( $pattern, $content, $matches ) ) { |
| 783 |
$relative = str_replace( $plugin_dir . '/', '', $file ); |
| 784 |
$flagged_files[] = $relative . ' (suspicious: ' . trim( $matches[0] ) . ')'; |
| 785 |
break; // One match per file is enough |
| 786 |
} |
| 787 |
} |
| 788 |
} |
| 789 |
|
| 790 |
if( ! empty( $flagged_files ) ) { |
| 791 |
$this->mark_addon_tampered( $plugin_slug, $flagged_files, 'patched' ); |
| 792 |
|
| 793 |
return 'patched'; |
| 794 |
} |
| 795 |
|
| 796 |
return 'valid'; |
| 797 |
} |
| 798 |
|
| 799 |
/** |
| 800 |
* Get all PHP files recursively in a directory |
| 801 |
*/ |
| 802 |
private function get_php_files_recursive( string $dir, int $max_depth = 10 ): array { |
| 803 |
$files = []; |
| 804 |
$real_dir = realpath( $dir ); |
| 805 |
if( $real_dir === false ) return $files; |
| 806 |
|
| 807 |
$iterator = new RecursiveIteratorIterator( |
| 808 |
new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS ), |
| 809 |
RecursiveIteratorIterator::SELF_FIRST |
| 810 |
); |
| 811 |
$iterator->setMaxDepth( $max_depth ); |
| 812 |
|
| 813 |
foreach( $iterator as $file ) { |
| 814 |
if( ! $file->isFile() || $file->getExtension() !== 'php' ) continue; |
| 815 |
|
| 816 |
// Ensure file is actually within the plugin directory (prevent symlink escapes) |
| 817 |
$real_path = realpath( $file->getPathname() ); |
| 818 |
if( $real_path === false || strpos( $real_path, $real_dir ) !== 0 ) continue; |
| 819 |
|
| 820 |
$files[] = $file->getPathname(); |
| 821 |
} |
| 822 |
|
| 823 |
return $files; |
| 824 |
} |
| 825 |
|
| 826 |
/** |
| 827 |
* Mark an addon as tampered in the options |
| 828 |
*/ |
| 829 |
private function mark_addon_tampered( string $plugin_slug, array $files, string $reason = 'tampered' ): void { |
| 830 |
$tampered = get_option( $this->tampered_option, [] ); |
| 831 |
// Don't overwrite detected_at if already flagged (preserve grace period start) |
| 832 |
if( isset( $tampered[ $plugin_slug ] ) ) { |
| 833 |
$tampered[ $plugin_slug ]['files'] = $files; |
| 834 |
$tampered[ $plugin_slug ]['reason'] = $reason; |
| 835 |
} else { |
| 836 |
$tampered[ $plugin_slug ] = [ |
| 837 |
'files' => $files, |
| 838 |
'reason' => $reason, |
| 839 |
'detected_at' => current_time( 'mysql' ), |
| 840 |
]; |
| 841 |
} |
| 842 |
update_option( $this->tampered_option, $tampered ); |
| 843 |
} |
| 844 |
|
| 845 |
/** |
| 846 |
* Check if an addon has a legacy license from the old gVectors license system. |
| 847 |
* Results are cached locally and revalidated daily to avoid repeated API calls. |
| 848 |
* |
| 849 |
* Returns cached legacy license data or false if no legacy license. |
| 850 |
*/ |
| 851 |
private function check_legacy_license( string $plugin_slug ): array { |
| 852 |
$cached = $this->get_cached_legacy_license( $plugin_slug ); |
| 853 |
if( $cached !== false ) return $cached; |
| 854 |
|
| 855 |
$response = $this->licenseService->apiService->check_legacy_license( $plugin_slug ); |
| 856 |
|
| 857 |
if( ! empty( $response['success'] ) && ! empty( $response['data'] ) ) { |
| 858 |
$data = $response['data']; |
| 859 |
$legacy_data = [ |
| 860 |
'has_license' => ! empty( $data['has_legacy_license'] ), |
| 861 |
'status' => $data['status'] ?? '', |
| 862 |
'expired' => ! empty( $data['expired'] ), |
| 863 |
'expired_time' => isset( $data['expired_time'] ) ? (int) $data['expired_time'] : 0, |
| 864 |
'last_checked' => time(), |
| 865 |
]; |
| 866 |
$this->save_cached_legacy_license( $plugin_slug, $legacy_data ); |
| 867 |
|
| 868 |
return $legacy_data; |
| 869 |
} |
| 870 |
|
| 871 |
// API call failed — cache a negative result with a shorter TTL (1 hour) |
| 872 |
// so we retry sooner, but don't hammer the server on every cron run |
| 873 |
$negative = [ |
| 874 |
'has_license' => false, |
| 875 |
'status' => '', |
| 876 |
'expired' => false, |
| 877 |
'expired_time' => 0, |
| 878 |
'last_checked' => time() - $this->config->get_legacy_check_period() + HOUR_IN_SECONDS, |
| 879 |
]; |
| 880 |
$this->save_cached_legacy_license( $plugin_slug, $negative ); |
| 881 |
|
| 882 |
return $negative; |
| 883 |
} |
| 884 |
|
| 885 |
/** |
| 886 |
* Get cached legacy license data for a slug. |
| 887 |
* Returns the cached array or false if not cached or stale. |
| 888 |
*/ |
| 889 |
private function get_cached_legacy_license( string $plugin_slug ) { |
| 890 |
$all_legacy = get_option( $this->legacy_licenses_option, [] ); |
| 891 |
if( ! isset( $all_legacy[ $plugin_slug ] ) ) return false; |
| 892 |
|
| 893 |
$cached = $all_legacy[ $plugin_slug ]; |
| 894 |
$last = isset( $cached['last_checked'] ) ? (int) $cached['last_checked'] : 0; |
| 895 |
|
| 896 |
// Stale if older than LEGACY_CHECK_PERIOD |
| 897 |
if( ( time() - $last ) > $this->config->get_legacy_check_period() ) return false; |
| 898 |
|
| 899 |
return $cached; |
| 900 |
} |
| 901 |
|
| 902 |
// ========================================== |
| 903 |
// Activation Gate |
| 904 |
// ========================================== |
| 905 |
|
| 906 |
/** |
| 907 |
* Save legacy license check result to the persistent cache. |
| 908 |
*/ |
| 909 |
private function save_cached_legacy_license( string $plugin_slug, array $data ): void { |
| 910 |
$all_legacy = get_option( $this->legacy_licenses_option, [] ); |
| 911 |
$all_legacy[ $plugin_slug ] = $data; |
| 912 |
update_option( $this->legacy_licenses_option, $all_legacy ); |
| 913 |
} |
| 914 |
|
| 915 |
// ========================================== |
| 916 |
// Signature & Piracy Verification |
| 917 |
// ========================================== |
| 918 |
|
| 919 |
/** |
| 920 |
* Clear tamper flag for an addon |
| 921 |
*/ |
| 922 |
private function clear_tamper_flag( string $plugin_slug ): void { |
| 923 |
$tampered = get_option( $this->tampered_option, [] ); |
| 924 |
if( isset( $tampered[ $plugin_slug ] ) ) { |
| 925 |
unset( $tampered[ $plugin_slug ] ); |
| 926 |
update_option( $this->tampered_option, $tampered ); |
| 927 |
} |
| 928 |
|
| 929 |
// Also clear the seen flag |
| 930 |
$seen = get_option( $this->tamper_dismissed_option, [] ); |
| 931 |
if( isset( $seen[ $plugin_slug ] ) ) { |
| 932 |
unset( $seen[ $plugin_slug ] ); |
| 933 |
update_option( $this->tamper_dismissed_option, $seen ); |
| 934 |
} |
| 935 |
} |
| 936 |
|
| 937 |
/** |
| 938 |
* Track legacy-licensed addons that have expired licenses for admin notice. |
| 939 |
*/ |
| 940 |
private function update_legacy_notice( string $plugin_slug, array $legacy_data ): void { |
| 941 |
$notices = get_option( $this->legacy_notice_option, [] ); |
| 942 |
|
| 943 |
if( ! empty( $legacy_data['expired'] ) ) { |
| 944 |
$plugin_file = $this->get_installed_plugin_file( $plugin_slug ); |
| 945 |
$plugin_name = $plugin_slug; |
| 946 |
if( $plugin_file ) { |
| 947 |
$plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, false, false ); |
| 948 |
$plugin_name = $plugin_data['Name'] ?? $plugin_slug; |
| 949 |
} |
| 950 |
$notices[ $plugin_slug ] = [ |
| 951 |
'plugin_name' => $plugin_name, |
| 952 |
'status' => 'expired', |
| 953 |
'expired_time' => $legacy_data['expired_time'] ?? 0, |
| 954 |
]; |
| 955 |
} else { |
| 956 |
// Active legacy license — remove any notice |
| 957 |
unset( $notices[ $plugin_slug ] ); |
| 958 |
} |
| 959 |
|
| 960 |
update_option( $this->legacy_notice_option, $notices ); |
| 961 |
} |
| 962 |
|
| 963 |
/** |
| 964 |
* Verify that PHP files contain valid embedded signature headers. |
| 965 |
* Checks both @addon-signature (HMAC hash) and @addon-domain (base64 site URL). |
| 966 |
* Validates the domain hash matches this site and the signature hash matches the manifest. |
| 967 |
*/ |
| 968 |
private function verify_php_header_signatures( string $plugin_slug, array $manifest ): string { |
| 969 |
$plugin_dir = WP_PLUGIN_DIR . '/' . $plugin_slug; |
| 970 |
$site_domain = LicenseModule::get_site_domain(); |
| 971 |
$missing_sigs = []; |
| 972 |
$invalid_domain = []; |
| 973 |
$invalid_hash = []; |
| 974 |
|
| 975 |
foreach( $manifest as $relative_path => $info ) { |
| 976 |
if( strpos( $relative_path, '..' ) !== false || strpos( $relative_path, '/' ) === 0 ) continue; |
| 977 |
$file_path = $plugin_dir . '/' . $relative_path; |
| 978 |
if( ! file_exists( $file_path ) ) continue; |
| 979 |
if( pathinfo( $file_path, PATHINFO_EXTENSION ) !== 'php' ) continue; |
| 980 |
|
| 981 |
$header = file_get_contents( $file_path, false, null, 0, 4096 ); |
| 982 |
if( $header === false ) continue; |
| 983 |
|
| 984 |
// Extract @addon-signature hash |
| 985 |
if( ! preg_match( '/\/\*\s*@addon-signature\s+([a-f0-9]{64})\s*\*\//', $header, $sig_match ) ) { |
| 986 |
$missing_sigs[] = $relative_path . ' (missing @addon-signature header)'; |
| 987 |
continue; |
| 988 |
} |
| 989 |
|
| 990 |
// Extract @addon-domain base64-encoded site URL |
| 991 |
if( ! preg_match( '/\/\*\s*@addon-domain\s+([A-Za-z0-9+\/=]+)\s*\*\//', $header, $domain_match ) ) { |
| 992 |
$missing_sigs[] = $relative_path . ' (missing @addon-domain header)'; |
| 993 |
continue; |
| 994 |
} |
| 995 |
|
| 996 |
// Validate domain matched this site |
| 997 |
$signed_domain = base64_decode( $domain_match[1] ); |
| 998 |
if( $signed_domain === false ) { |
| 999 |
$invalid_domain[] = $relative_path . ' (corrupted domain encoding)'; |
| 1000 |
continue; |
| 1001 |
} |
| 1002 |
if( LicenseModule::normalize_domain( $signed_domain ) !== LicenseModule::normalize_domain( $site_domain ) ) { |
| 1003 |
$invalid_domain[] = $relative_path . ' (domain: ' . $signed_domain . ' vs ' . $site_domain . ')'; |
| 1004 |
continue; |
| 1005 |
} |
| 1006 |
|
| 1007 |
// Validate signature hash matches the one stored in manifest |
| 1008 |
if( ! empty( $info['signature'] ) && $sig_match[1] !== $info['signature'] ) { |
| 1009 |
$invalid_hash[] = $relative_path . ' (signature hash mismatch)'; |
| 1010 |
} |
| 1011 |
} |
| 1012 |
|
| 1013 |
if( ! empty( $missing_sigs ) ) { |
| 1014 |
$this->mark_addon_tampered( $plugin_slug, $missing_sigs, 'no_signatures' ); |
| 1015 |
|
| 1016 |
return 'no_signatures'; |
| 1017 |
} |
| 1018 |
|
| 1019 |
if( ! empty( $invalid_domain ) ) { |
| 1020 |
$this->mark_addon_tampered( $plugin_slug, $invalid_domain, 'domain_mismatch' ); |
| 1021 |
|
| 1022 |
return 'domain_mismatch'; |
| 1023 |
} |
| 1024 |
|
| 1025 |
if( ! empty( $invalid_hash ) ) { |
| 1026 |
$this->mark_addon_tampered( $plugin_slug, $invalid_hash ); |
| 1027 |
|
| 1028 |
return 'tampered'; |
| 1029 |
} |
| 1030 |
|
| 1031 |
return 'valid'; |
| 1032 |
} |
| 1033 |
|
| 1034 |
/** |
| 1035 |
* Activate an installed addon |
| 1036 |
*/ |
| 1037 |
public function activate( string $plugin_file ): array { |
| 1038 |
if( ! current_user_can( 'activate_plugins' ) ) { |
| 1039 |
return [ 'success' => false, 'error' => __( 'Permission denied', 'gvectors' ) ]; |
| 1040 |
} |
| 1041 |
|
| 1042 |
$result = activate_plugin( $plugin_file ); |
| 1043 |
|
| 1044 |
if( is_wp_error( $result ) ) { |
| 1045 |
return [ 'success' => false, 'error' => $result->get_error_message() ]; |
| 1046 |
} |
| 1047 |
|
| 1048 |
return [ 'success' => true, 'message' => __( 'Addon activated successfully', 'gvectors' ) ]; |
| 1049 |
} |
| 1050 |
|
| 1051 |
/** |
| 1052 |
* Deactivate an addon |
| 1053 |
*/ |
| 1054 |
public function deactivate_addon( string $plugin_file ): array { |
| 1055 |
if( ! current_user_can( 'activate_plugins' ) ) { |
| 1056 |
return [ 'success' => false, 'error' => __( 'Permission denied', 'gvectors' ) ]; |
| 1057 |
} |
| 1058 |
|
| 1059 |
deactivate_plugins( $plugin_file ); |
| 1060 |
|
| 1061 |
return [ 'success' => true, 'message' => __( 'Addon deactivated successfully', 'gvectors' ) ]; |
| 1062 |
} |
| 1063 |
|
| 1064 |
// ========================================== |
| 1065 |
// Legacy License Checking (old gVectors system) |
| 1066 |
// ========================================== |
| 1067 |
|
| 1068 |
public function check_for_updates( $transient ) { |
| 1069 |
if( empty( $transient->checked ) ) return $transient; |
| 1070 |
|
| 1071 |
$licenses = $this->licenseService->get_all(); |
| 1072 |
|
| 1073 |
// Clear cached addon data only once per request, so we fetch fresh version info without DDOSing the server |
| 1074 |
if( ! $this->update_check_done ) { |
| 1075 |
delete_transient( $this->all_addons_transient_name ); |
| 1076 |
$this->update_check_done = true; |
| 1077 |
} |
| 1078 |
|
| 1079 |
// Fetch all addon info from proxy server (version, requires, tested, etc.) |
| 1080 |
$proxy_addons = $this->get_proxy_addons_map(); |
| 1081 |
if( empty( $proxy_addons ) ) return $transient; |
| 1082 |
|
| 1083 |
$site_domain = LicenseModule::get_site_domain(); |
| 1084 |
|
| 1085 |
// Track which plugin files already got a licensed update (so we don't override with unlicensed) |
| 1086 |
$licensed_plugin_files = []; |
| 1087 |
|
| 1088 |
if( ! empty( $licenses ) ) { |
| 1089 |
foreach( $licenses as $product_id => $license ) { |
| 1090 |
if( empty( $license['license_key'] ) ) continue; |
| 1091 |
|
| 1092 |
// Only active/trial licenses get updates - expired licenses do NOT |
| 1093 |
if( ! in_array( $license['status'], [ 'active', 'trial' ], true ) ) continue; |
| 1094 |
|
| 1095 |
// Verify license is activated for this specific domain |
| 1096 |
if( ! empty( $site_domain ) ) { |
| 1097 |
$activated_site = $license['site_domain'] ?? ''; |
| 1098 |
if( ! empty( $activated_site ) && LicenseModule::normalize_domain( $activated_site ) !== LicenseModule::normalize_domain( $site_domain ) ) { |
| 1099 |
continue; |
| 1100 |
} |
| 1101 |
} |
| 1102 |
|
| 1103 |
// Check expiry date - do not offer updates for expired licenses |
| 1104 |
$expires_ts = ! empty( $license['expires_at'] ) ? strtotime( $license['expires_at'] ) : false; |
| 1105 |
if( $expires_ts !== false && $expires_ts < time() ) { |
| 1106 |
continue; |
| 1107 |
} |
| 1108 |
|
| 1109 |
$plugin_slug = $license['plugin_slug'] ?? ''; |
| 1110 |
if( empty( $plugin_slug ) || $this->is_host_plugin( $plugin_slug ) ) continue; |
| 1111 |
|
| 1112 |
$plugin_file = $this->get_installed_plugin_file( $plugin_slug ); |
| 1113 |
if( ! $plugin_file ) continue; |
| 1114 |
|
| 1115 |
$current_version = $transient->checked[ $plugin_file ] ?? '0.0.0'; |
| 1116 |
|
| 1117 |
// Use proxy server version (from addon file header) instead of local options |
| 1118 |
$proxy_info = $proxy_addons[ $plugin_slug ] ?? []; |
| 1119 |
$latest_version = ! empty( $proxy_info['version'] ) ? $proxy_info['version'] : ''; |
| 1120 |
|
| 1121 |
if( $latest_version && version_compare( $latest_version, $current_version, '>' ) ) { |
| 1122 |
$update = new stdClass(); |
| 1123 |
$update->slug = $plugin_slug; |
| 1124 |
$update->plugin = $plugin_file; |
| 1125 |
$update->new_version = $latest_version; |
| 1126 |
$update->url = ! empty( $proxy_info['plugin_uri'] ) ? $proxy_info['plugin_uri'] : ''; |
| 1127 |
|
| 1128 |
// Set package to the proxy server's wp-download endpoint |
| 1129 |
// The proxy validates the license and 302 redirects to a temporary download URL |
| 1130 |
$update->package = add_query_arg( [ |
| 1131 |
'product_id' => $product_id, |
| 1132 |
'license_key' => $license['license_key'], |
| 1133 |
'site_domain' => LicenseModule::get_site_domain(), |
| 1134 |
'site_token' => LicenseModule::get_site_token(), |
| 1135 |
], |
| 1136 |
trailingslashit( |
| 1137 |
$this->config->get_proxy_server_url() |
| 1138 |
) . 'addon/wp-download' ); |
| 1139 |
|
| 1140 |
$update->icons = ! empty( $proxy_info['logo'] ) ? [ '1x' => $proxy_info['logo'], '2x' => $proxy_info['logo'] ] : []; |
| 1141 |
$update->banners = []; |
| 1142 |
$update->tested = ! empty( $proxy_info['tested'] ) ? $proxy_info['tested'] : ''; |
| 1143 |
$update->requires = ! empty( $proxy_info['requires'] ) ? $proxy_info['requires'] : ''; |
| 1144 |
$update->requires_php = ! empty( $proxy_info['requires_php'] ) ? $proxy_info['requires_php'] : ''; |
| 1145 |
|
| 1146 |
$transient->response[ $plugin_file ] = $update; |
| 1147 |
$licensed_plugin_files[] = $plugin_file; |
| 1148 |
} |
| 1149 |
} |
| 1150 |
} |
| 1151 |
|
| 1152 |
// Also check installed addons with an active (non-expired) legacy license. |
| 1153 |
// These users purchased before the new Paddle system and still deserve updates. |
| 1154 |
$all_legacy = get_option( $this->legacy_licenses_option, [] ); |
| 1155 |
if( ! empty( $all_legacy ) ) { |
| 1156 |
foreach( $all_legacy as $plugin_slug => $legacy ) { |
| 1157 |
// Only active, non-expired legacy licenses get updates |
| 1158 |
if( empty( $legacy['has_license'] ) || ! empty( $legacy['expired'] ) ) continue; |
| 1159 |
|
| 1160 |
$plugin_file = $this->get_installed_plugin_file( $plugin_slug ); |
| 1161 |
if( ! $plugin_file ) continue; |
| 1162 |
|
| 1163 |
// Skip if already handled by a new Paddle license above |
| 1164 |
if( in_array( $plugin_file, $licensed_plugin_files, true ) ) continue; |
| 1165 |
|
| 1166 |
// Only process known gVectors addons from the proxy |
| 1167 |
if( ! isset( $proxy_addons[ $plugin_slug ] ) ) continue; |
| 1168 |
|
| 1169 |
// Licensed via / belongs to another host plugin — its instance builds this addon's update entry |
| 1170 |
if( ! $this->manages_addon( $plugin_slug ) ) continue; |
| 1171 |
|
| 1172 |
// Migrate legacy license to new system eagerly — even without a pending update. |
| 1173 |
// On success, save() stores the license in gvectors_licenses so the Paddle loop |
| 1174 |
// handles this slug on the next check_for_updates() call. |
| 1175 |
$migrated = $this->maybe_migrate_legacy_license( $plugin_slug ); |
| 1176 |
|
| 1177 |
$proxy_info = $proxy_addons[ $plugin_slug ]; |
| 1178 |
$latest_version = ! empty( $proxy_info['version'] ) ? $proxy_info['version'] : ''; |
| 1179 |
$current_version = $transient->checked[ $plugin_file ] ?? '0.0.0'; |
| 1180 |
|
| 1181 |
if( $latest_version && version_compare( $latest_version, $current_version, '>' ) ) { |
| 1182 |
$update = new stdClass(); |
| 1183 |
$update->slug = $plugin_slug; |
| 1184 |
$update->plugin = $plugin_file; |
| 1185 |
$update->new_version = $latest_version; |
| 1186 |
$update->url = ! empty( $proxy_info['plugin_uri'] ) ? $proxy_info['plugin_uri'] : ''; |
| 1187 |
if( $migrated && ! empty( $migrated['license_key'] ) ) { |
| 1188 |
$update->package = add_query_arg( [ |
| 1189 |
'product_id' => $migrated['product_id'], |
| 1190 |
'license_key' => $migrated['license_key'], |
| 1191 |
'site_domain' => LicenseModule::get_site_domain(), |
| 1192 |
'site_token' => LicenseModule::get_site_token(), |
| 1193 |
], |
| 1194 |
trailingslashit( |
| 1195 |
$this->config->get_proxy_server_url() |
| 1196 |
) . 'addon/wp-download' ); |
| 1197 |
} else { |
| 1198 |
$update->package = add_query_arg( [ |
| 1199 |
'plugin_slug' => $plugin_slug, |
| 1200 |
'site_domain' => LicenseModule::get_site_domain(), |
| 1201 |
'site_token' => LicenseModule::get_site_token(), |
| 1202 |
], trailingslashit( $this->config->get_proxy_server_url() ) . 'addon/legacy-wp-download' ); |
| 1203 |
} |
| 1204 |
|
| 1205 |
$update->icons = ! empty( $proxy_info['logo'] ) ? [ '1x' => $proxy_info['logo'], '2x' => $proxy_info['logo'] ] : []; |
| 1206 |
$update->banners = []; |
| 1207 |
$update->tested = ! empty( $proxy_info['tested'] ) ? $proxy_info['tested'] : ''; |
| 1208 |
$update->requires = ! empty( $proxy_info['requires'] ) ? $proxy_info['requires'] : ''; |
| 1209 |
$update->requires_php = ! empty( $proxy_info['requires_php'] ) ? $proxy_info['requires_php'] : ''; |
| 1210 |
|
| 1211 |
$transient->response[ $plugin_file ] = $update; |
| 1212 |
$licensed_plugin_files[] = $plugin_file; |
| 1213 |
} |
| 1214 |
} |
| 1215 |
} |
| 1216 |
|
| 1217 |
// Also check installed addons that have a new version but NO active license |
| 1218 |
// Show them as available updates but with empty package (download blocked) |
| 1219 |
if( ! function_exists( 'get_plugins' ) ) { |
| 1220 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 1221 |
} |
| 1222 |
$all_plugins = get_plugins(); |
| 1223 |
|
| 1224 |
// Batch-check legacy license status for any installed gVectors addon slugs |
| 1225 |
// that are not yet in the local cache (e.g. first run before cron has executed). |
| 1226 |
// This prevents showing "Automatic update is unavailable" for legitimate legacy users. |
| 1227 |
$uncached_slugs = []; |
| 1228 |
foreach( $all_plugins as $_pf => $_pd ) { |
| 1229 |
if( in_array( $_pf, $licensed_plugin_files, true ) ) continue; |
| 1230 |
$_slug = dirname( $_pf ); |
| 1231 |
// Store addons only (host plugins are never in the proxy map) |
| 1232 |
if( $_slug === '.' || ! isset( $proxy_addons[ $_slug ] ) ) continue; |
| 1233 |
if( ! $this->manages_addon( $_slug ) ) continue; |
| 1234 |
if( ! isset( $all_legacy[ $_slug ] ) ) $uncached_slugs[] = $_slug; |
| 1235 |
} |
| 1236 |
if( ! empty( $uncached_slugs ) ) { |
| 1237 |
$this->check_legacy_licenses_batch( array_unique( $uncached_slugs ) ); |
| 1238 |
$all_legacy = get_option( $this->legacy_licenses_option, [] ); |
| 1239 |
// Apply legacy download URLs for newly-discovered active licenses |
| 1240 |
foreach( $uncached_slugs as $_slug ) { |
| 1241 |
$_legacy = $all_legacy[ $_slug ] ?? []; |
| 1242 |
if( empty( $_legacy['has_license'] ) || ! empty( $_legacy['expired'] ) ) continue; |
| 1243 |
$_plugin_file = $this->get_installed_plugin_file( $_slug ); |
| 1244 |
if( ! $_plugin_file || in_array( $_plugin_file, $licensed_plugin_files, true ) ) continue; |
| 1245 |
if( ! isset( $proxy_addons[ $_slug ] ) ) continue; |
| 1246 |
// Migrate eagerly — even without a pending update |
| 1247 |
$_migrated = $this->maybe_migrate_legacy_license( $_slug ); |
| 1248 |
$_proxy = $proxy_addons[ $_slug ]; |
| 1249 |
$_latest = $_proxy['version'] ?? ''; |
| 1250 |
$_current = $transient->checked[ $_plugin_file ] ?? '0.0.0'; |
| 1251 |
if( ! $_latest || ! version_compare( $_latest, $_current, '>' ) ) continue; |
| 1252 |
$_update = new stdClass(); |
| 1253 |
$_update->slug = $_slug; |
| 1254 |
$_update->plugin = $_plugin_file; |
| 1255 |
$_update->new_version = $_latest; |
| 1256 |
$_update->url = $_proxy['plugin_uri'] ?? ''; |
| 1257 |
if( $_migrated && ! empty( $_migrated['license_key'] ) ) { |
| 1258 |
$_update->package = add_query_arg( [ |
| 1259 |
'product_id' => $_migrated['product_id'], |
| 1260 |
'license_key' => $_migrated['license_key'], |
| 1261 |
'site_domain' => LicenseModule::get_site_domain(), |
| 1262 |
'site_token' => LicenseModule::get_site_token(), |
| 1263 |
], |
| 1264 |
trailingslashit( |
| 1265 |
$this->config->get_proxy_server_url() |
| 1266 |
) . 'addon/wp-download' ); |
| 1267 |
} else { |
| 1268 |
$_update->package = add_query_arg( [ |
| 1269 |
'plugin_slug' => $_slug, |
| 1270 |
'site_domain' => LicenseModule::get_site_domain(), |
| 1271 |
'site_token' => LicenseModule::get_site_token(), |
| 1272 |
], trailingslashit( $this->config->get_proxy_server_url() ) . 'addon/legacy-wp-download' ); |
| 1273 |
} |
| 1274 |
$_update->icons = ! empty( $_proxy['logo'] ) ? [ '1x' => $_proxy['logo'], '2x' => $_proxy['logo'] ] : []; |
| 1275 |
$_update->banners = []; |
| 1276 |
$_update->tested = $_proxy['tested'] ?? ''; |
| 1277 |
$_update->requires = $_proxy['requires'] ?? ''; |
| 1278 |
$_update->requires_php = $_proxy['requires_php'] ?? ''; |
| 1279 |
$transient->response[ $_plugin_file ] = $_update; |
| 1280 |
$licensed_plugin_files[] = $_plugin_file; |
| 1281 |
} |
| 1282 |
} |
| 1283 |
|
| 1284 |
foreach( $all_plugins as $plugin_file => $plugin_data ) { |
| 1285 |
// Skip if already handled by licensed update above |
| 1286 |
if( in_array( $plugin_file, $licensed_plugin_files, true ) ) continue; |
| 1287 |
|
| 1288 |
$slug = dirname( $plugin_file ); |
| 1289 |
|
| 1290 |
// Only process known gVectors addons from the proxy (host plugins are never in the map) |
| 1291 |
if( $slug === '.' || ! isset( $proxy_addons[ $slug ] ) ) continue; |
| 1292 |
|
| 1293 |
// Licensed via / belongs to another host plugin — don't overwrite that host's update entry |
| 1294 |
if( ! $this->manages_addon( $slug ) ) continue; |
| 1295 |
|
| 1296 |
$proxy_info = $proxy_addons[ $slug ]; |
| 1297 |
$latest_version = ! empty( $proxy_info['version'] ) ? $proxy_info['version'] : ''; |
| 1298 |
$current_version = $transient->checked[ $plugin_file ] ?? '0.0.0'; |
| 1299 |
|
| 1300 |
if( $latest_version && version_compare( $latest_version, $current_version, '>' ) ) { |
| 1301 |
$update = new stdClass(); |
| 1302 |
$update->slug = $slug; |
| 1303 |
$update->plugin = $plugin_file; |
| 1304 |
$update->new_version = $latest_version; |
| 1305 |
$update->url = ! empty( $proxy_info['plugin_uri'] ) ? $proxy_info['plugin_uri'] : ''; |
| 1306 |
$update->package = ''; // Empty package — download blocked without active license |
| 1307 |
$update->icons = ! empty( $proxy_info['logo'] ) ? [ '1x' => $proxy_info['logo'], '2x' => $proxy_info['logo'] ] : []; |
| 1308 |
$update->banners = []; |
| 1309 |
$update->tested = ! empty( $proxy_info['tested'] ) ? $proxy_info['tested'] : ''; |
| 1310 |
$update->requires = ! empty( $proxy_info['requires'] ) ? $proxy_info['requires'] : ''; |
| 1311 |
$update->requires_php = ! empty( $proxy_info['requires_php'] ) ? $proxy_info['requires_php'] : ''; |
| 1312 |
|
| 1313 |
$transient->response[ $plugin_file ] = $update; |
| 1314 |
} |
| 1315 |
} |
| 1316 |
|
| 1317 |
// Entitled updates (licensed / legacy-licensed, with a download package) this site can't install |
| 1318 |
$entitled = []; |
| 1319 |
foreach( array_unique( $licensed_plugin_files ) as $plugin_file ) { |
| 1320 |
$update = $transient->response[ $plugin_file ] ?? null; |
| 1321 |
if( ! $update || empty( $update->package ) ) continue; |
| 1322 |
$entitled[ $update->slug ] = [ |
| 1323 |
'name' => ! empty( $all_plugins[ $plugin_file ]['Name'] ) ? $all_plugins[ $plugin_file ]['Name'] : ( $proxy_addons[ $update->slug ]['name'] ?? $update->slug ), |
| 1324 |
'current_version' => (string) ( $transient->checked[ $plugin_file ] ?? '' ), |
| 1325 |
'new_version' => (string) $update->new_version, |
| 1326 |
]; |
| 1327 |
} |
| 1328 |
$this->queue_blocked_updates( $entitled ); |
| 1329 |
|
| 1330 |
return $transient; |
| 1331 |
} |
| 1332 |
|
| 1333 |
/** |
| 1334 |
* Attempt to migrate an active legacy gVectors license to the new Paddle license system. |
| 1335 |
* |
| 1336 |
* Calls addon/activate-legacy-license on the proxy server, which creates a row in the |
| 1337 |
* new licenses table using the original activation key. On success the returned data |
| 1338 |
* is stored in gvectors_licenses, so from this point forward: |
| 1339 |
* - validate / batch-validate resolve the license from the new table |
| 1340 |
* - check_for_updates() builds an addon/wp-download package URL (not legacy-wp-download) |
| 1341 |
* - downloaded files arrive with a manifest + signed PHP headers |
| 1342 |
* - the addon never re-enters the legacy scan scope (it's in $licensed_slugs) |
| 1343 |
* |
| 1344 |
* The call is idempotent — running it multiple times is safe. |
| 1345 |
* Rate-limited: won't retry for 6 hours after a failure to avoid API spam. |
| 1346 |
* |
| 1347 |
* @return array|null [ 'product_id' => ..., 'license_key' => ... ] on success, null on failure |
| 1348 |
*/ |
| 1349 |
private function maybe_migrate_legacy_license( string $plugin_slug ): ?array { |
| 1350 |
// Rate limit: don't retry within 6 hours after a failure |
| 1351 |
$attempt_key = 'gvectors_lgc_mig_' . md5( $plugin_slug ); |
| 1352 |
if( get_transient( $attempt_key ) ) return null; |
| 1353 |
|
| 1354 |
$response = $this->licenseService->apiService->activate_legacy_license( $plugin_slug ); |
| 1355 |
|
| 1356 |
if( ! empty( $response['success'] ) && ! empty( $response['data'] ) ) { |
| 1357 |
$data = $response['data']; |
| 1358 |
$product_id = $data['product_id'] ?? ''; |
| 1359 |
if( ! empty( $product_id ) && ! empty( $data['license_key'] ) ) { |
| 1360 |
$this->licenseService->save( $product_id, $data ); |
| 1361 |
|
| 1362 |
// Remove slug from legacy caches — it's now a first-class new-system license |
| 1363 |
$all_legacy = get_option( $this->legacy_licenses_option, [] ); |
| 1364 |
if( isset( $all_legacy[ $plugin_slug ] ) ) { |
| 1365 |
unset( $all_legacy[ $plugin_slug ] ); |
| 1366 |
update_option( $this->legacy_licenses_option, $all_legacy ); |
| 1367 |
} |
| 1368 |
$notices = get_option( $this->legacy_notice_option, [] ); |
| 1369 |
if( isset( $notices[ $plugin_slug ] ) ) { |
| 1370 |
unset( $notices[ $plugin_slug ] ); |
| 1371 |
update_option( $this->legacy_notice_option, $notices ); |
| 1372 |
} |
| 1373 |
|
| 1374 |
return [ |
| 1375 |
'product_id' => $product_id, |
| 1376 |
'license_key' => $data['license_key'], |
| 1377 |
]; |
| 1378 |
} |
| 1379 |
} |
| 1380 |
|
| 1381 |
// Cache failure to prevent repeated attempts on every page load |
| 1382 |
set_transient( $attempt_key, 1, 6 * HOUR_IN_SECONDS ); |
| 1383 |
|
| 1384 |
return null; |
| 1385 |
} |
| 1386 |
|
| 1387 |
/** |
| 1388 |
* Batch-check legacy licenses for multiple addon slugs. |
| 1389 |
* Populates the local cache for all slugs in one API call. |
| 1390 |
*/ |
| 1391 |
private function check_legacy_licenses_batch( array $plugin_slugs ): void { |
| 1392 |
if( empty( $plugin_slugs ) ) return; |
| 1393 |
|
| 1394 |
$response = $this->licenseService->apiService->check_legacy_licenses_batch( $plugin_slugs ); |
| 1395 |
|
| 1396 |
if( ! empty( $response['success'] ) && ! empty( $response['data']['addons'] ) ) { |
| 1397 |
$all_legacy = get_option( $this->legacy_licenses_option, [] ); |
| 1398 |
foreach( $response['data']['addons'] as $slug => $data ) { |
| 1399 |
$all_legacy[ $slug ] = [ |
| 1400 |
'has_license' => ! empty( $data['has_legacy_license'] ), |
| 1401 |
'status' => $data['status'] ?? '', |
| 1402 |
'expired' => ! empty( $data['expired'] ), |
| 1403 |
'expired_time' => isset( $data['expired_time'] ) ? (int) $data['expired_time'] : 0, |
| 1404 |
'last_checked' => time(), |
| 1405 |
]; |
| 1406 |
} |
| 1407 |
// Also cache negative results for slugs not returned by the server |
| 1408 |
foreach( $plugin_slugs as $slug ) { |
| 1409 |
if( ! isset( $all_legacy[ $slug ] ) || $all_legacy[ $slug ]['last_checked'] < time() - 60 ) { |
| 1410 |
$all_legacy[ $slug ] = [ |
| 1411 |
'has_license' => false, |
| 1412 |
'status' => '', |
| 1413 |
'expired' => false, |
| 1414 |
'expired_time' => 0, |
| 1415 |
'last_checked' => time(), |
| 1416 |
]; |
| 1417 |
} |
| 1418 |
} |
| 1419 |
update_option( $this->legacy_licenses_option, $all_legacy ); |
| 1420 |
} |
| 1421 |
} |
| 1422 |
|
| 1423 |
/** |
| 1424 |
* WordPress's update check (twice-daily wp_update_plugins cron, or the Updates/Plugins screens) |
| 1425 |
* found new versions of licensed addons, but this site blocks plugin installs — so neither the |
| 1426 |
* auto-updater (disabled outright by DISALLOW_FILE_MODS) nor the Updates screen can install them. |
| 1427 |
* The versions are merged into a shared queue and a single cron event hands them to the news |
| 1428 |
* module (`gvectors_blocked_updates` → one email per admin, deduped per addon version). Never |
| 1429 |
* sends from here: the update check can run during a page load. |
| 1430 |
*/ |
| 1431 |
private function queue_blocked_updates( array $updates ): void { |
| 1432 |
if( ! $updates || self::site_can_install_plugins() ) return; |
| 1433 |
|
| 1434 |
$queued = get_option( self::BLOCKED_UPDATES_OPTION, [] ); |
| 1435 |
$queued = is_array( $queued ) ? $queued : []; |
| 1436 |
$merged = array_merge( $queued, $updates ); |
| 1437 |
if( $merged !== $queued ) { |
| 1438 |
update_option( self::BLOCKED_UPDATES_OPTION, $merged, false ); |
| 1439 |
} |
| 1440 |
if( ! wp_next_scheduled( self::BLOCKED_UPDATES_HOOK ) ) { |
| 1441 |
wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::BLOCKED_UPDATES_HOOK ); |
| 1442 |
} |
| 1443 |
} |
| 1444 |
|
| 1445 |
/** |
| 1446 |
* Cron: hand the queued blocked updates to the news module (sends the admin emails via wp_mail). |
| 1447 |
* Skipped when the site can install plugins again by now — WordPress will just update normally. |
| 1448 |
*/ |
| 1449 |
public static function notify_blocked_updates(): void { |
| 1450 |
$updates = get_option( self::BLOCKED_UPDATES_OPTION, [] ); |
| 1451 |
delete_option( self::BLOCKED_UPDATES_OPTION ); |
| 1452 |
if( ! is_array( $updates ) || ! $updates || self::site_can_install_plugins() ) return; |
| 1453 |
|
| 1454 |
do_action( 'gvectors_blocked_updates', $updates ); |
| 1455 |
} |
| 1456 |
|
| 1457 |
/** |
| 1458 |
* Intercept WordPress updater package downloads to block tampered addons with a visible error. |
| 1459 |
* This hooks into 'upgrader_pre_download' so the user sees a clear message in the update UI. |
| 1460 |
* The actual download is handled by the proxy server's addon/wp-download endpoint (302 redirect). |
| 1461 |
*/ |
| 1462 |
public function block_tampered_update_download( $reply, $package ) { |
| 1463 |
if( is_wp_error( $reply ) || ! is_string( $package ) ) return $reply; |
| 1464 |
|
| 1465 |
// Intercept our own addon download URLs (both regular and legacy-wp-download endpoints) |
| 1466 |
$is_regular = strpos( $package, 'addon/wp-download' ) !== false; |
| 1467 |
$is_legacy = strpos( $package, 'addon/legacy-wp-download' ) !== false; |
| 1468 |
if( ! $is_regular && ! $is_legacy ) return $reply; |
| 1469 |
|
| 1470 |
$parsed = []; |
| 1471 |
parse_str( wp_parse_url( $package, PHP_URL_QUERY ) ?: '', $parsed ); |
| 1472 |
|
| 1473 |
if( $is_legacy ) { |
| 1474 |
// Legacy download — plugin_slug is a direct query param |
| 1475 |
$plugin_slug = self::sanitize_slug( $parsed['plugin_slug'] ?? '' ); |
| 1476 |
} else { |
| 1477 |
// Regular download — resolve plugin_slug via product_id |
| 1478 |
$product_id = $parsed['product_id'] ?? ''; |
| 1479 |
if( empty( $product_id ) ) return $reply; |
| 1480 |
$license = $this->licenseService->get( $product_id ); |
| 1481 |
$plugin_slug = $license['plugin_slug'] ?? ''; |
| 1482 |
} |
| 1483 |
|
| 1484 |
if( $plugin_slug && $this->is_addon_tampered( $plugin_slug ) ) { |
| 1485 |
return new WP_Error( |
| 1486 |
'tampered_addon', |
| 1487 |
__( |
| 1488 |
'This addon cannot be updated because its files have been modified or are not original. To resolve this, please: 1) Go to Plugins and deactivate, then delete this addon. 2) Visit the gVectors Store Addons page and make sure your license is active. 3) Re-install the addon from the gVectors Store Addons page. Once re-installed, everything will work normally again.', |
| 1489 |
'gvectors' |
| 1490 |
) |
| 1491 |
); |
| 1492 |
} |
| 1493 |
|
| 1494 |
return $reply; |
| 1495 |
} |
| 1496 |
|
| 1497 |
/** |
| 1498 |
* Get addon status: 'not_installed', 'installed', 'active' |
| 1499 |
*/ |
| 1500 |
public function get_status( string $plugin_slug ): string { |
| 1501 |
if( ! $this->is_installed( $plugin_slug ) ) return 'not_installed'; |
| 1502 |
if( $this->is_active( $plugin_slug ) ) return 'active'; |
| 1503 |
|
| 1504 |
return 'installed'; |
| 1505 |
} |
| 1506 |
|
| 1507 |
/** |
| 1508 |
* Check if an addon is installed |
| 1509 |
*/ |
| 1510 |
public function is_installed( string $plugin_slug ): bool { |
| 1511 |
return ! empty( $this->get_installed_plugin_file( $plugin_slug ) ); |
| 1512 |
} |
| 1513 |
|
| 1514 |
/** |
| 1515 |
* Check if an addon is active |
| 1516 |
*/ |
| 1517 |
public function is_active( string $plugin_slug ): bool { |
| 1518 |
$file = $this->get_installed_plugin_file( $plugin_slug ); |
| 1519 |
if( empty( $file ) ) return false; |
| 1520 |
|
| 1521 |
return is_plugin_active( $file ); |
| 1522 |
} |
| 1523 |
|
| 1524 |
/** |
| 1525 |
* Intercept plugin activation. If the plugin is a known gVectors addon, |
| 1526 |
* run full validity checks (license, signatures, domain, nulled patterns). |
| 1527 |
* Block activation with wp_die() if any check fails. |
| 1528 |
*/ |
| 1529 |
public function validate_on_activation( string $plugin_file ): void { |
| 1530 |
$slug = dirname( $plugin_file ); |
| 1531 |
if( $slug === '.' || $this->is_host_plugin( $slug ) ) return; |
| 1532 |
|
| 1533 |
// Skip all checks on development/local/staging environments |
| 1534 |
if( LicenseModule::is_development_site() ) return; |
| 1535 |
|
| 1536 |
// Check if this is an addon from the gVectors store list |
| 1537 |
if( ! $this->is_known_addon( $slug ) ) return; |
| 1538 |
|
| 1539 |
// Licensed via / belongs to another host plugin (e.g. wpDiscuz) — that host's activation gate validates it |
| 1540 |
if( ! $this->manages_addon( $slug ) ) return; |
| 1541 |
|
| 1542 |
$reasons = []; |
| 1543 |
|
| 1544 |
// 1) License check — new Paddle license OR legacy gVectors license |
| 1545 |
$has_new_license = $this->addon_has_license( $slug ); |
| 1546 |
$has_legacy_license = false; |
| 1547 |
if( ! $has_new_license ) { |
| 1548 |
$has_legacy_license = $this->has_legacy_license( $slug ); |
| 1549 |
} |
| 1550 |
if( ! $has_new_license && ! $has_legacy_license ) { |
| 1551 |
$reasons[] = __( 'No valid license found for this addon on this site.', 'gvectors' ); |
| 1552 |
} |
| 1553 |
|
| 1554 |
// 2) Signature & integrity checks |
| 1555 |
$sig_result = $this->verify_addon_signatures( $slug ); |
| 1556 |
if( $sig_result !== 'valid' && $sig_result !== 'legacy_valid' ) { |
| 1557 |
$reasons[] = self::signature_failure_reason( $sig_result ); |
| 1558 |
} |
| 1559 |
|
| 1560 |
if( ! empty( $reasons ) ) { |
| 1561 |
// Store a transient so we can show an admin notice on redirect back |
| 1562 |
set_transient( 'gvectors_activation_blocked_' . $slug, $reasons, 60 ); |
| 1563 |
|
| 1564 |
wp_die( |
| 1565 |
'<h2>' . esc_html__( 'gVectors Addon Activation Blocked', 'gvectors' ) . '</h2>' |
| 1566 |
. '<p><strong>' . esc_html( $slug ) . '</strong></p>' |
| 1567 |
. '<ul><li>' . implode( '</li><li>', array_map( 'esc_html', $reasons ) ) . '</li></ul>' |
| 1568 |
. '<p>' . esc_html__( 'Please install a valid licensed copy from the gVectors Store Addons dashboard.', 'gvectors' ) . '</p>', |
| 1569 |
esc_html__( 'Activation Blocked', 'gvectors' ), |
| 1570 |
[ 'back_link' => true ] |
| 1571 |
); |
| 1572 |
} |
| 1573 |
} |
| 1574 |
|
| 1575 |
/** |
| 1576 |
* Human-readable reason for a failed verify_addon_signatures() result |
| 1577 |
*/ |
| 1578 |
private static function signature_failure_reason( string $sig_result ): string { |
| 1579 |
$labels = [ |
| 1580 |
'no_manifest' => __( 'Missing signature manifest — addon was not installed through the official channel.', 'gvectors' ), |
| 1581 |
'tampered' => __( 'File integrity check failed — one or more addon files have been modified.', 'gvectors' ), |
| 1582 |
'domain_mismatch' => __( 'Domain mismatch — this addon copy is signed for a different website.', 'gvectors' ), |
| 1583 |
'no_signatures' => __( 'Missing PHP header signatures — addon files lack required security headers.', 'gvectors' ), |
| 1584 |
'patched' => __( 'Nulled/patched code detected — this addon appears to be a pirated copy.', 'gvectors' ), |
| 1585 |
]; |
| 1586 |
|
| 1587 |
return $labels[ $sig_result ] ?? __( 'Addon verification failed.', 'gvectors' ); |
| 1588 |
} |
| 1589 |
|
| 1590 |
/** |
| 1591 |
* Check if a plugin slug is a gVectors store addon — decided only by the store server's addon list, |
| 1592 |
* never by the plugin's name (e.g. "forums-censure-pro" is recognized just like "wpforo-polls"). |
| 1593 |
* When no store list has ever been fetched, nothing is treated as an addon (fail open). |
| 1594 |
* |
| 1595 |
* @param bool $allow_remote false = never make an HTTP request (for page-load paths like admin_init) |
| 1596 |
*/ |
| 1597 |
private function is_known_addon( string $plugin_slug, bool $allow_remote = true ): bool { |
| 1598 |
if( $plugin_slug === '' || $this->is_host_plugin( $plugin_slug ) ) return false; |
| 1599 |
|
| 1600 |
return isset( $this->get_store_addons( $allow_remote )[ $plugin_slug ] ); |
| 1601 |
} |
| 1602 |
|
| 1603 |
/** |
| 1604 |
* Check if an addon slug has an associated license (local) or is a known addon from proxy. |
| 1605 |
*/ |
| 1606 |
private function addon_has_license( string $plugin_slug ): bool { |
| 1607 |
$licenses = $this->licenseService->get_all(); |
| 1608 |
foreach( $licenses as $license ) { |
| 1609 |
if( isset( $license['plugin_slug'] ) && $license['plugin_slug'] === $plugin_slug ) { |
| 1610 |
return true; |
| 1611 |
} |
| 1612 |
} |
| 1613 |
|
| 1614 |
return false; |
| 1615 |
} |
| 1616 |
|
| 1617 |
/** |
| 1618 |
* Quick check: does this addon have a legacy license (from cache)? |
| 1619 |
* Returns true if the cached legacy license exists and is valid. |
| 1620 |
*/ |
| 1621 |
private function has_legacy_license( string $plugin_slug ): bool { |
| 1622 |
$legacy = $this->check_legacy_license( $plugin_slug ); |
| 1623 |
|
| 1624 |
return ! empty( $legacy['has_license'] ); |
| 1625 |
} |
| 1626 |
|
| 1627 |
/** |
| 1628 |
* Verify all installed addons — uses the proxy's full addon list (not just local licenses). |
| 1629 |
* Checks every installed plugin that matches a known addon slug from the proxy. |
| 1630 |
* Uses a grace period: show FATAL notice first, deactivate after TAMPER_GRACE_DAYS. |
| 1631 |
*/ |
| 1632 |
public function verify_all_addon_signatures(): void { |
| 1633 |
$this->prune_missing_addons(); |
| 1634 |
|
| 1635 |
// Skip all checks on development/local/staging environments |
| 1636 |
if( LicenseModule::is_development_site() ) return; |
| 1637 |
|
| 1638 |
// Collect locally licensed plugin slugs |
| 1639 |
$licenses = $this->licenseService->get_all(); |
| 1640 |
$licensed_slugs = []; |
| 1641 |
foreach( $licenses as $product_id => $license ) { |
| 1642 |
$slug = $license['plugin_slug'] ?? ''; |
| 1643 |
if( ! empty( $slug ) ) $licensed_slugs[] = $slug; |
| 1644 |
} |
| 1645 |
|
| 1646 |
// Scan for installed addons that are known to the proxy but have no license |
| 1647 |
$this->scan_unlicensed_addons( $licensed_slugs ); |
| 1648 |
|
| 1649 |
// Verify signatures for all installed plugins that are in the store addon list |
| 1650 |
if( ! function_exists( 'get_plugins' ) ) { |
| 1651 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 1652 |
} |
| 1653 |
$all_plugins = get_plugins(); |
| 1654 |
|
| 1655 |
foreach( $all_plugins as $file => $data ) { |
| 1656 |
$slug = dirname( $file ); |
| 1657 |
if( $slug === '.' ) continue; |
| 1658 |
|
| 1659 |
// Check against the store's addon list (host plugins excluded) |
| 1660 |
if( ! $this->is_known_addon( $slug ) ) continue; |
| 1661 |
if( ! $this->is_installed( $slug ) ) continue; |
| 1662 |
|
| 1663 |
// Licensed via / belongs to another host plugin — that host verifies it; drop this host's stale tracking |
| 1664 |
if( ! $this->manages_addon( $slug ) ) { |
| 1665 |
$this->clear_tamper_flag( $slug ); |
| 1666 |
$this->clear_legacy_cache( $slug ); |
| 1667 |
continue; |
| 1668 |
} |
| 1669 |
|
| 1670 |
$result = $this->verify_addon_signatures( $slug ); |
| 1671 |
if( $result !== 'valid' && $result !== 'legacy_valid' ) { |
| 1672 |
$this->maybe_deactivate_tampered( $slug ); |
| 1673 |
} |
| 1674 |
} |
| 1675 |
} |
| 1676 |
|
| 1677 |
/** |
| 1678 |
* Scan for installed plugins that are known gVectors addons (from the proxy list) but have no license. |
| 1679 |
* These could be pirated copies installed manually, OR legacy-licensed installations. |
| 1680 |
* Checks legacy license before flagging as tampered. |
| 1681 |
*/ |
| 1682 |
private function scan_unlicensed_addons( array $licensed_slugs ): void { |
| 1683 |
if( ! function_exists( 'get_plugins' ) ) { |
| 1684 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 1685 |
} |
| 1686 |
|
| 1687 |
$all_plugins = get_plugins(); |
| 1688 |
|
| 1689 |
// Two buckets for unlicensed known addons: |
| 1690 |
// - $needs_legacy_check: no manifest → must verify legacy license or flag as tampered |
| 1691 |
// - $needs_legacy_refresh: has manifest (updated via legacy-wp-download) → refresh legacy |
| 1692 |
// cache so check_for_updates() keeps offering update packages; no tamper action here |
| 1693 |
// since verify_all_addon_signatures() handles file integrity for manifest-having addons. |
| 1694 |
$needs_legacy_check = []; |
| 1695 |
$needs_legacy_refresh = []; |
| 1696 |
foreach( $all_plugins as $file => $data ) { |
| 1697 |
$slug = dirname( $file ); |
| 1698 |
if( $slug === '.' ) continue; |
| 1699 |
if( ! $this->is_known_addon( $slug ) ) continue; |
| 1700 |
if( in_array( $slug, $licensed_slugs, true ) ) continue; |
| 1701 |
if( ! $this->manages_addon( $slug ) ) continue; |
| 1702 |
if( ! is_plugin_active( $file ) ) continue; |
| 1703 |
|
| 1704 |
$manifest_file = WP_PLUGIN_DIR . '/' . $slug . '/.addon-signatures.json'; |
| 1705 |
if( ! file_exists( $manifest_file ) ) { |
| 1706 |
$needs_legacy_check[] = $slug; |
| 1707 |
} else { |
| 1708 |
$needs_legacy_refresh[] = $slug; |
| 1709 |
} |
| 1710 |
} |
| 1711 |
|
| 1712 |
// Batch-check legacy licenses for all unlicensed addons in a single API call |
| 1713 |
$all_to_check = array_values( array_unique( array_merge( $needs_legacy_check, $needs_legacy_refresh ) ) ); |
| 1714 |
if( ! empty( $all_to_check ) ) { |
| 1715 |
$this->check_legacy_licenses_batch( $all_to_check ); |
| 1716 |
} |
| 1717 |
|
| 1718 |
// No-manifest addons: apply tamper/clear logic based on legacy license presence |
| 1719 |
foreach( $needs_legacy_check as $slug ) { |
| 1720 |
$legacy = $this->get_cached_legacy_license( $slug ); |
| 1721 |
if( $legacy !== false && ! empty( $legacy['has_license'] ) ) { |
| 1722 |
// Legacy licensed — not piracy. Track for admin notice if expired. |
| 1723 |
$this->clear_tamper_flag( $slug ); |
| 1724 |
$this->update_legacy_notice( $slug, $legacy ); |
| 1725 |
|
| 1726 |
// Proactively migrate active legacy licenses to the new system. |
| 1727 |
// Once migrated, the slug enters $licensed_slugs and exits this scan |
| 1728 |
// scope permanently — future updates go through addon/wp-download. |
| 1729 |
if( empty( $legacy['expired'] ) ) { |
| 1730 |
$this->maybe_migrate_legacy_license( $slug ); |
| 1731 |
} |
| 1732 |
|
| 1733 |
continue; |
| 1734 |
} |
| 1735 |
|
| 1736 |
// No legacy license — suspicious, flag as tampered |
| 1737 |
$this->mark_addon_tampered( $slug, [ |
| 1738 |
'Active gVectors addon without a valid license or signature manifest', |
| 1739 |
], 'no_manifest' ); |
| 1740 |
$this->maybe_deactivate_tampered( $slug ); |
| 1741 |
} |
| 1742 |
|
| 1743 |
// Manifest-having addons: only refresh legacy notice so update offers stay active. |
| 1744 |
// Tamper/integrity decisions are handled by verify_all_addon_signatures() above. |
| 1745 |
foreach( $needs_legacy_refresh as $slug ) { |
| 1746 |
$legacy = $this->get_cached_legacy_license( $slug ); |
| 1747 |
if( $legacy !== false && ! empty( $legacy['has_license'] ) ) { |
| 1748 |
$this->update_legacy_notice( $slug, $legacy ); |
| 1749 |
} |
| 1750 |
} |
| 1751 |
} |
| 1752 |
|
| 1753 |
/** |
| 1754 |
* Decide whether to deactivate a tampered addon based on the grace period. |
| 1755 |
* Grace period: show FATAL notice for TAMPER_GRACE_DAYS days. |
| 1756 |
* After the grace period AND admin has viewed the notice, deactivate. |
| 1757 |
*/ |
| 1758 |
private function maybe_deactivate_tampered( string $plugin_slug ): void { |
| 1759 |
$tampered = get_option( $this->tampered_option, [] ); |
| 1760 |
if( ! isset( $tampered[ $plugin_slug ] ) ) return; |
| 1761 |
|
| 1762 |
$info = $tampered[ $plugin_slug ]; |
| 1763 |
$detected_at = isset( $info['detected_at'] ) ? strtotime( $info['detected_at'] ) : false; |
| 1764 |
if( $detected_at === false || $detected_at <= 0 ) { |
| 1765 |
// Invalid timestamp — reset now so grace period starts fresh |
| 1766 |
$tampered[ $plugin_slug ]['detected_at'] = current_time( 'mysql' ); |
| 1767 |
update_option( $this->tampered_option, $tampered ); |
| 1768 |
|
| 1769 |
return; |
| 1770 |
} |
| 1771 |
$days_since = ( time() - $detected_at ) / DAY_IN_SECONDS; |
| 1772 |
|
| 1773 |
// Check if admin has seen the notice |
| 1774 |
$seen = get_option( $this->tamper_dismissed_option, [] ); |
| 1775 |
$admin_has_seen = ! empty( $seen[ $plugin_slug ] ); |
| 1776 |
|
| 1777 |
// Deactivate after grace period if admin has viewed the notice |
| 1778 |
if( $days_since >= $this->config->get_tamper_grace_days() && $admin_has_seen ) { |
| 1779 |
$plugin_file = $this->get_installed_plugin_file( $plugin_slug ); |
| 1780 |
if( $plugin_file && is_plugin_active( $plugin_file ) ) { |
| 1781 |
deactivate_plugins( $plugin_file ); |
| 1782 |
// Update tamper record to note deactivation |
| 1783 |
$tampered[ $plugin_slug ]['deactivated_at'] = current_time( 'mysql' ); |
| 1784 |
update_option( $this->tampered_option, $tampered ); |
| 1785 |
} |
| 1786 |
} |
| 1787 |
} |
| 1788 |
|
| 1789 |
// ========================================== |
| 1790 |
// Tamper Flags |
| 1791 |
// ========================================== |
| 1792 |
|
| 1793 |
/** |
| 1794 |
* Track when an admin views tamper notices (called on admin_init). |
| 1795 |
* Records the first time admin sees each tamper notice. |
| 1796 |
*/ |
| 1797 |
public function track_tamper_notice_view(): void { |
| 1798 |
if( ! current_user_can( 'administrator' ) ) return; |
| 1799 |
|
| 1800 |
$this->prune_missing_addons(); |
| 1801 |
|
| 1802 |
$tampered = get_option( $this->tampered_option, [] ); |
| 1803 |
if( empty( $tampered ) ) return; |
| 1804 |
|
| 1805 |
$seen = get_option( $this->tamper_dismissed_option, [] ); |
| 1806 |
$updated = false; |
| 1807 |
|
| 1808 |
foreach( $tampered as $slug => $info ) { |
| 1809 |
if( ! isset( $seen[ $slug ] ) ) { |
| 1810 |
$seen[ $slug ] = current_time( 'mysql' ); |
| 1811 |
$updated = true; |
| 1812 |
} |
| 1813 |
} |
| 1814 |
|
| 1815 |
if( $updated ) { |
| 1816 |
update_option( $this->tamper_dismissed_option, $seen ); |
| 1817 |
} |
| 1818 |
} |
| 1819 |
|
| 1820 |
/** |
| 1821 |
* Force-delete the update_plugins transient once per 12-hour cycle. |
| 1822 |
* |
| 1823 |
* This ensures check_for_updates() runs on the next transient access, which: |
| 1824 |
* - Discovers legacy licenses (populates LEGACY_LICENSES_OPTION) |
| 1825 |
* - Triggers migration (maybe_migrate_legacy_license) |
| 1826 |
* - Builds correct download URLs for all addons |
| 1827 |
* |
| 1828 |
* Without this, the transient may contain stale package URLs (from before |
| 1829 |
* migration) that cause "Download failed. Forbidden" on the first update attempt. |
| 1830 |
* |
| 1831 |
* Cost: one extra wp_update_plugins() call per 12h — same as the normal WP refresh interval. |
| 1832 |
* Skips AJAX requests to avoid interfering with in-progress update downloads. |
| 1833 |
*/ |
| 1834 |
public function maybe_refresh_update_transient(): void { |
| 1835 |
if( wp_doing_ajax() ) return; |
| 1836 |
|
| 1837 |
$flag = $this->config->get_core_plugin_slug() . '_gvectors_update_transient_refreshed'; |
| 1838 |
if( get_transient( $flag ) ) return; |
| 1839 |
|
| 1840 |
delete_site_transient( 'update_plugins' ); |
| 1841 |
set_transient( $flag, 1, 12 * HOUR_IN_SECONDS ); |
| 1842 |
} |
| 1843 |
|
| 1844 |
/** |
| 1845 |
* When a plugin is deleted, forget all stored notice/tamper/legacy data for it. |
| 1846 |
*/ |
| 1847 |
public function on_plugin_deleted( string $plugin_file, bool $deleted ): void { |
| 1848 |
if( ! $deleted ) return; |
| 1849 |
|
| 1850 |
$slug = dirname( $plugin_file ); |
| 1851 |
if( $slug && $slug !== '.' ) { |
| 1852 |
$this->forget_addon( $slug ); |
| 1853 |
} |
| 1854 |
} |
| 1855 |
|
| 1856 |
/** |
| 1857 |
* Check if an addon physically exists on disk as a real plugin. |
| 1858 |
* An empty leftover folder (no plugin header file) counts as not present. |
| 1859 |
*/ |
| 1860 |
private function is_addon_present( string $plugin_slug ): bool { |
| 1861 |
if( empty( $plugin_slug ) || ! is_dir( WP_PLUGIN_DIR . '/' . $plugin_slug ) ) return false; |
| 1862 |
|
| 1863 |
return ! empty( $this->get_installed_plugin_file( $plugin_slug ) ); |
| 1864 |
} |
| 1865 |
|
| 1866 |
/** |
| 1867 |
* Remove all per-addon notice, tamper and legacy-cache data for a slug. |
| 1868 |
* License records are intentionally kept — they are paid entitlements used by the store page. |
| 1869 |
*/ |
| 1870 |
private function forget_addon( string $plugin_slug ): void { |
| 1871 |
$expired = get_option( $this->expired_notice_option, [] ); |
| 1872 |
if( isset( $expired[ $plugin_slug ] ) ) { |
| 1873 |
unset( $expired[ $plugin_slug ] ); |
| 1874 |
update_option( $this->expired_notice_option, $expired ); |
| 1875 |
} |
| 1876 |
|
| 1877 |
$this->clear_tamper_flag( $plugin_slug ); |
| 1878 |
$this->clear_legacy_cache( $plugin_slug ); |
| 1879 |
|
| 1880 |
foreach( [ 'tampered', 'expired', 'legacy' ] as $type ) { |
| 1881 |
delete_transient( 'gvectors_' . $type . '_dismissed_' . $plugin_slug ); |
| 1882 |
} |
| 1883 |
} |
| 1884 |
|
| 1885 |
/** |
| 1886 |
* Rewind stored per-addon data for addons that no longer physically exist |
| 1887 |
* (e.g. deleted via FTP / file manager, bypassing the deleted_plugin hook). |
| 1888 |
* Runs once per request per instance. |
| 1889 |
*/ |
| 1890 |
public function prune_missing_addons(): void { |
| 1891 |
if( $this->pruned ) return; |
| 1892 |
$this->pruned = true; |
| 1893 |
|
| 1894 |
$slugs = []; |
| 1895 |
foreach( [ $this->expired_notice_option, $this->tampered_option, $this->tamper_dismissed_option, $this->legacy_licenses_option, $this->legacy_notice_option ] as $option ) { |
| 1896 |
$data = get_option( $option, [] ); |
| 1897 |
if( is_array( $data ) ) $slugs = array_merge( $slugs, array_keys( $data ) ); |
| 1898 |
} |
| 1899 |
|
| 1900 |
foreach( array_unique( $slugs ) as $slug ) { |
| 1901 |
$slug = (string) $slug; |
| 1902 |
if( ! $this->is_addon_present( $slug ) ) { |
| 1903 |
$this->forget_addon( $slug ); |
| 1904 |
} |
| 1905 |
} |
| 1906 |
} |
| 1907 |
|
| 1908 |
/** |
| 1909 |
* Clear the legacy license cache for a specific addon. |
| 1910 |
*/ |
| 1911 |
private function clear_legacy_cache( string $plugin_slug ): void { |
| 1912 |
$all_legacy = get_option( $this->legacy_licenses_option, [] ); |
| 1913 |
if( isset( $all_legacy[ $plugin_slug ] ) ) { |
| 1914 |
unset( $all_legacy[ $plugin_slug ] ); |
| 1915 |
update_option( $this->legacy_licenses_option, $all_legacy ); |
| 1916 |
} |
| 1917 |
$notices = get_option( $this->legacy_notice_option, [] ); |
| 1918 |
if( isset( $notices[ $plugin_slug ] ) ) { |
| 1919 |
unset( $notices[ $plugin_slug ] ); |
| 1920 |
update_option( $this->legacy_notice_option, $notices ); |
| 1921 |
} |
| 1922 |
} |
| 1923 |
|
| 1924 |
// ========================================== |
| 1925 |
// License Validity Check |
| 1926 |
// ========================================== |
| 1927 |
|
| 1928 |
/** |
| 1929 |
* Periodically check all license validity (called by daily cron). |
| 1930 |
* Marks expired licenses for admin notice display. |
| 1931 |
* Does NOT deactivate addons for expired licenses - they keep working. |
| 1932 |
*/ |
| 1933 |
public function check_all_license_validity(): void { |
| 1934 |
$this->prune_missing_addons(); |
| 1935 |
|
| 1936 |
$licenses = $this->licenseService->get_all(); |
| 1937 |
if( empty( $licenses ) ) return; |
| 1938 |
|
| 1939 |
$expired_notices = get_option( $this->expired_notice_option, [] ); |
| 1940 |
|
| 1941 |
foreach( $licenses as $license ) { |
| 1942 |
$plugin_slug = $license['plugin_slug'] ?? ''; |
| 1943 |
if( empty( $plugin_slug ) ) continue; |
| 1944 |
if( ! $this->is_installed( $plugin_slug ) ) { |
| 1945 |
unset( $expired_notices[ $plugin_slug ] ); |
| 1946 |
continue; |
| 1947 |
} |
| 1948 |
|
| 1949 |
$status = $license['status'] ?? ''; |
| 1950 |
$expires_at = $license['expires_at'] ?? ''; |
| 1951 |
$is_expired = false; |
| 1952 |
|
| 1953 |
// Check if status is expired/canceled |
| 1954 |
if( in_array( $status, [ 'expired', 'cancelled' ], true ) ) { |
| 1955 |
$is_expired = true; |
| 1956 |
} |
| 1957 |
|
| 1958 |
// Check if expiry date has passed |
| 1959 |
$expires_ts = ! empty( $expires_at ) ? strtotime( $expires_at ) : false; |
| 1960 |
if( $expires_ts !== false && $expires_ts < time() ) { |
| 1961 |
$is_expired = true; |
| 1962 |
} |
| 1963 |
|
| 1964 |
if( $is_expired ) { |
| 1965 |
$latest_version = $license['latest_version'] ?? ''; |
| 1966 |
$plugin_file = $this->get_installed_plugin_file( $plugin_slug ); |
| 1967 |
$current_version = ''; |
| 1968 |
if( $plugin_file ) { |
| 1969 |
$plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, false, false ); |
| 1970 |
$current_version = $plugin_data['Version'] ?? ''; |
| 1971 |
} |
| 1972 |
|
| 1973 |
$has_update = $latest_version && $current_version && version_compare( $latest_version, $current_version, '>' ); |
| 1974 |
|
| 1975 |
$expired_notices[ $plugin_slug ] = [ |
| 1976 |
'product_name' => $license['product_name'] ?? $plugin_slug, |
| 1977 |
'status' => $status, |
| 1978 |
'expires_at' => $expires_at, |
| 1979 |
'has_update' => $has_update, |
| 1980 |
'latest_version' => $latest_version, |
| 1981 |
'current_version' => $current_version, |
| 1982 |
]; |
| 1983 |
} else { |
| 1984 |
// License is valid - remove any expired notice |
| 1985 |
unset( $expired_notices[ $plugin_slug ] ); |
| 1986 |
} |
| 1987 |
} |
| 1988 |
|
| 1989 |
update_option( $this->expired_notice_option, $expired_notices ); |
| 1990 |
} |
| 1991 |
|
| 1992 |
// ========================================== |
| 1993 |
// Admin Notices |
| 1994 |
// ========================================== |
| 1995 |
|
| 1996 |
/** |
| 1997 |
* Check if the current admin page should display addon notices. |
| 1998 |
* Allowed pages: plugin's own admin pages, Dashboard Home, Updates, Installed Plugins, Add Plugins. |
| 1999 |
*/ |
| 2000 |
private function is_notice_page(): bool { |
| 2001 |
if( ! is_admin() ) return false; |
| 2002 |
|
| 2003 |
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; |
| 2004 |
if( $screen ) { |
| 2005 |
// Dashboard Home, Updates, Plugins, Add Plugins |
| 2006 |
if( in_array( $screen->id, [ 'dashboard', 'update-core', 'plugins', 'plugin-install' ], true ) ) { |
| 2007 |
return true; |
| 2008 |
} |
| 2009 |
// Any page belonging to this plugin (screen id contains the plugin slug) |
| 2010 |
if( strpos( $screen->id, $this->config->get_core_plugin_slug() ) !== false ) { |
| 2011 |
return true; |
| 2012 |
} |
| 2013 |
} |
| 2014 |
|
| 2015 |
return false; |
| 2016 |
} |
| 2017 |
|
| 2018 |
/** |
| 2019 |
* Handle dismissal of dev environment admin notices via a nonce-secured GET parameter. |
| 2020 |
* Saves a transient so the notice is suppressed for a set period. |
| 2021 |
*/ |
| 2022 |
public function handle_dev_notice_dismiss(): void { |
| 2023 |
if( ! current_user_can( 'administrator' ) ) return; |
| 2024 |
|
| 2025 |
if( ! empty( $_GET['gvectors_dismiss_dev_env'] ) ) { |
| 2026 |
check_admin_referer( 'gvectors_dismiss_dev_env' ); |
| 2027 |
set_transient( self::$shared_dev_env_transient, 1, 7 * DAY_IN_SECONDS ); |
| 2028 |
wp_safe_redirect( remove_query_arg( [ 'gvectors_dismiss_dev_env', '_wpnonce' ] ) ); |
| 2029 |
exit; |
| 2030 |
} |
| 2031 |
|
| 2032 |
if( ! empty( $_GET['gvectors_dismiss_dev_licenses'] ) ) { |
| 2033 |
check_admin_referer( 'gvectors_dismiss_dev_licenses' ); |
| 2034 |
set_transient( self::$shared_dev_licenses_transient, 1, DAY_IN_SECONDS ); |
| 2035 |
wp_safe_redirect( remove_query_arg( [ 'gvectors_dismiss_dev_licenses', '_wpnonce' ] ) ); |
| 2036 |
exit; |
| 2037 |
} |
| 2038 |
|
| 2039 |
if( ! empty( $_GET['gvectors_dismiss_addon_notice'] ) && ! empty( $_GET['gvectors_notice_slug'] ) ) { |
| 2040 |
$type = sanitize_key( $_GET['gvectors_dismiss_addon_notice'] ); |
| 2041 |
$slug = sanitize_key( $_GET['gvectors_notice_slug'] ); |
| 2042 |
if( in_array( $type, [ 'tampered', 'expired', 'legacy' ], true ) ) { |
| 2043 |
check_admin_referer( 'gvectors_dismiss_' . $type . '_' . $slug ); |
| 2044 |
set_transient( 'gvectors_' . $type . '_dismissed_' . $slug, 1, 5 * DAY_IN_SECONDS ); |
| 2045 |
wp_safe_redirect( remove_query_arg( [ 'gvectors_dismiss_addon_notice', 'gvectors_notice_slug', '_wpnonce' ] ) ); |
| 2046 |
exit; |
| 2047 |
} |
| 2048 |
} |
| 2049 |
} |
| 2050 |
|
| 2051 |
/** |
| 2052 |
* Warn administrators that the site is running in a development/staging environment. |
| 2053 |
* All local addon validation and tamper checks are bypassed in this state. |
| 2054 |
* Dismissible for 7 days; reappears automatically as a periodic reminder. |
| 2055 |
*/ |
| 2056 |
public function dev_environment_notice(): void { |
| 2057 |
if( ! $this->is_notice_page() ) return; |
| 2058 |
if( self::$dev_env_notice_shown ) return; |
| 2059 |
if( ! current_user_can( 'administrator' ) ) return; |
| 2060 |
if( ! LicenseModule::is_development_site() ) return; |
| 2061 |
if( get_transient( self::$shared_dev_env_transient ) ) return; |
| 2062 |
|
| 2063 |
self::$dev_env_notice_shown = true; |
| 2064 |
|
| 2065 |
$dismiss_url = wp_nonce_url( |
| 2066 |
add_query_arg( 'gvectors_dismiss_dev_env', '1' ), |
| 2067 |
'gvectors_dismiss_dev_env' |
| 2068 |
); |
| 2069 |
|
| 2070 |
printf( |
| 2071 |
'<div class="notice notice-warning">' |
| 2072 |
. '<p><strong>⚠️ %s</strong></p>' |
| 2073 |
. '<p>%s</p>' |
| 2074 |
. '<p><a href="%s">%s</a></p>' |
| 2075 |
. '</div>', |
| 2076 |
esc_html__( 'gVectors: Development Environment Detected', 'gvectors' ), |
| 2077 |
esc_html__( |
| 2078 |
'This site is running in a development environment. Some features, including gVectors-Addons updates are disabled. Please contact your developer to configure the site for production.', |
| 2079 |
'gvectors' |
| 2080 |
), |
| 2081 |
esc_url( $dismiss_url ), |
| 2082 |
esc_html__( 'Dismiss for 7 days', 'gvectors' ) |
| 2083 |
); |
| 2084 |
} |
| 2085 |
|
| 2086 |
/** |
| 2087 |
* Warn administrators about active licenses on the current development domain. |
| 2088 |
* Lists each active license with its Transaction ID or License Key so the admin |
| 2089 |
* can note them before deactivating and re-activating on the production domain. |
| 2090 |
* Dismissible for 24 hours. |
| 2091 |
*/ |
| 2092 |
public function dev_licenses_notice(): void { |
| 2093 |
if( ! $this->is_notice_page() ) return; |
| 2094 |
if( ! current_user_can( 'administrator' ) ) return; |
| 2095 |
if( ! LicenseModule::is_development_site() ) return; |
| 2096 |
if( get_transient( self::$shared_dev_licenses_transient ) ) return; |
| 2097 |
|
| 2098 |
// Collect this instance's active licenses into the shared static array |
| 2099 |
$licenses = $this->licenseService->get_all(); |
| 2100 |
foreach( $licenses as $product_id => $license ) { |
| 2101 |
if( empty( $license['status'] ) ) continue; |
| 2102 |
if( ! in_array( $license['status'], [ 'active', 'trial' ], true ) ) continue; |
| 2103 |
if( ! empty( $license['expires_at'] ) && strtotime( $license['expires_at'] ) < time() ) continue; |
| 2104 |
self::$dev_licenses_collected[ $product_id ] = $license; |
| 2105 |
} |
| 2106 |
|
| 2107 |
// Register the actual rendering callback once (fires after all instances have collected) |
| 2108 |
if( ! self::$dev_licenses_registered ) { |
| 2109 |
self::$dev_licenses_registered = true; |
| 2110 |
add_action( 'admin_notices', [ __CLASS__, 'render_dev_licenses_notice' ], 999 ); |
| 2111 |
} |
| 2112 |
} |
| 2113 |
|
| 2114 |
/** |
| 2115 |
* Render a single consolidated dev-licenses notice with licenses from all plugin instances. |
| 2116 |
* Fires at priority 999 so all instances have collected their licenses first. |
| 2117 |
*/ |
| 2118 |
public static function render_dev_licenses_notice(): void { |
| 2119 |
if( empty( self::$dev_licenses_collected ) ) return; |
| 2120 |
|
| 2121 |
$dismiss_url = wp_nonce_url( |
| 2122 |
add_query_arg( 'gvectors_dismiss_dev_licenses', '1' ), |
| 2123 |
'gvectors_dismiss_dev_licenses' |
| 2124 |
); |
| 2125 |
|
| 2126 |
$rows = ''; |
| 2127 |
foreach( self::$dev_licenses_collected as $product_id => $license ) { |
| 2128 |
$name = ! empty( $license['product_name'] ) ? $license['product_name'] : ( $license['plugin_slug'] ?? '' ); |
| 2129 |
$plan = ! empty( $license['plan_name'] ) ? $license['plan_name'] : $product_id; |
| 2130 |
$txn = ! empty( $license['transaction_id'] ) ? $license['transaction_id'] : ''; |
| 2131 |
$key = ! empty( $license['license_key'] ) ? $license['license_key'] : ''; |
| 2132 |
|
| 2133 |
if( $txn ) { |
| 2134 |
$rows .= '<li><strong>' . esc_html( $name . ' (' . $plan . ')' ) . '</strong> — ' |
| 2135 |
. esc_html__( 'Transaction ID', 'gvectors' ) . ': <code>' . esc_html( $txn ) . '</code>'; |
| 2136 |
} elseif( $key ) { |
| 2137 |
$rows .= '<li><strong>' . esc_html( $name . ' (' . $plan . ')' ) . '</strong> — ' |
| 2138 |
. esc_html__( 'License Key', 'gvectors' ) . ': <code>' . esc_html( $key ) . '</code>'; |
| 2139 |
} else { |
| 2140 |
$rows .= '<li><strong>' . esc_html( $name . ' (' . $plan . ')' ) . '</strong>'; |
| 2141 |
} |
| 2142 |
|
| 2143 |
if( ! empty( $license['status'] ) && $license['status'] === 'trial' ) { |
| 2144 |
$rows .= ' <em>(' . esc_html__( 'Trial', 'gvectors' ) . ')</em>'; |
| 2145 |
} |
| 2146 |
$rows .= '</li>'; |
| 2147 |
} |
| 2148 |
|
| 2149 |
printf( |
| 2150 |
'<div class="notice notice-info">' |
| 2151 |
. '<p><strong>ℹ️ %s</strong></p>' |
| 2152 |
. '<p>%s</p>' |
| 2153 |
. '<ul style="list-style:disc;padding-left:20px;margin:.4em 0 .8em;">%s</ul>' |
| 2154 |
. '<p>%s</p>' |
| 2155 |
. '<p><a href="%s">%s</a></p>' |
| 2156 |
. '</div>', |
| 2157 |
esc_html__( 'gVectors: Active Licenses on Development Domain', 'gvectors' ), |
| 2158 |
esc_html__( |
| 2159 |
'You have active addon licenses on this development/staging site. Before deploying to production, note the Transaction IDs or License Keys below, deactivate all licenses from this domain, then re-activate them on your live site using the Transaction ID or License Key:', |
| 2160 |
'gvectors' |
| 2161 |
), |
| 2162 |
$rows, |
| 2163 |
sprintf( |
| 2164 |
esc_html__( 'On your production site go to %s and activate each license using its Transaction ID or License Key.', 'gvectors' ), |
| 2165 |
'<a href="' . esc_url( admin_url( 'admin.php?page=gvectors-addons' ) ) . '">' . esc_html__( 'gVectors Store Addons', 'gvectors' ) . '</a>' |
| 2166 |
), |
| 2167 |
esc_url( $dismiss_url ), |
| 2168 |
esc_html__( 'Dismiss for 24 hours', 'gvectors' ) |
| 2169 |
); |
| 2170 |
} |
| 2171 |
|
| 2172 |
/** |
| 2173 |
* Display FATAL admin notice for tampered/nulled/pirated addons. |
| 2174 |
* Shows permanently until resolved. After the grace period + admin view, addon gets deactivated. |
| 2175 |
*/ |
| 2176 |
public function tampered_addon_notice(): void { |
| 2177 |
if( ! $this->is_notice_page() ) return; |
| 2178 |
if( ! current_user_can( 'administrator' ) ) return; |
| 2179 |
|
| 2180 |
$this->prune_missing_addons(); |
| 2181 |
|
| 2182 |
if( LicenseModule::is_development_site() ) return; |
| 2183 |
|
| 2184 |
$tampered = get_option( $this->tampered_option, [] ); |
| 2185 |
if( empty( $tampered ) ) return; |
| 2186 |
|
| 2187 |
foreach( $tampered as $slug => $info ) { |
| 2188 |
if( ! $this->is_addon_present( $slug ) ) continue; |
| 2189 |
if( get_transient( 'gvectors_tampered_dismissed_' . $slug ) ) continue; |
| 2190 |
if( ! self::claim_notice( 'tampered', $slug ) ) continue; |
| 2191 |
|
| 2192 |
$files = $info['files'] ?? []; |
| 2193 |
$reason = $info['reason'] ?? 'tampered'; |
| 2194 |
$detected = $info['detected_at'] ?? ''; |
| 2195 |
$deactivated = $info['deactivated_at'] ?? ''; |
| 2196 |
|
| 2197 |
$reason_labels = [ |
| 2198 |
'tampered' => __( 'File integrity check failed — files have been modified.', 'gvectors' ), |
| 2199 |
'no_manifest' => __( 'Missing signature manifest — this copy was not obtained through an authorized license.', 'gvectors' ), |
| 2200 |
'domain_mismatch' => __( 'Domain signature mismatch — this addon was licensed for a different website.', 'gvectors' ), |
| 2201 |
'no_signatures' => __( 'Missing file header signatures — files have been stripped of authorization data.', 'gvectors' ), |
| 2202 |
'patched' => __( 'Suspicious code patterns detected — this appears to be a nulled or patched version.', 'gvectors' ), |
| 2203 |
]; |
| 2204 |
|
| 2205 |
$reason_text = $reason_labels[ $reason ] ?? $reason_labels['tampered']; |
| 2206 |
|
| 2207 |
if( $deactivated ) { |
| 2208 |
$status_text = sprintf( |
| 2209 |
'<strong style="color:#dc3232;">%s %s</strong>', |
| 2210 |
esc_html__( 'This addon has been deactivated on:', 'gvectors' ), |
| 2211 |
esc_html( $deactivated ) |
| 2212 |
); |
| 2213 |
} else { |
| 2214 |
$days_left = $this->config->get_tamper_grace_days(); |
| 2215 |
if( $detected ) { |
| 2216 |
$detected_ts = strtotime( $detected ); |
| 2217 |
if( $detected_ts !== false && $detected_ts > 0 ) { |
| 2218 |
$days_since = ( time() - $detected_ts ) / DAY_IN_SECONDS; |
| 2219 |
$days_left = max( 0, ceil( $this->config->get_tamper_grace_days() - $days_since ) ); |
| 2220 |
} |
| 2221 |
} |
| 2222 |
if( $days_left > 0 ) { |
| 2223 |
$status_text = sprintf( |
| 2224 |
'<strong style="color:#dc3232;">%s</strong>', |
| 2225 |
sprintf( |
| 2226 |
esc_html__( 'This addon will be automatically deactivated in %d day(s) if not resolved.', 'gvectors' ), |
| 2227 |
$days_left |
| 2228 |
) |
| 2229 |
); |
| 2230 |
} else { |
| 2231 |
$status_text = sprintf( |
| 2232 |
'<strong style="color:#dc3232;">%s</strong>', |
| 2233 |
esc_html__( 'This addon will be deactivated on the next security check.', 'gvectors' ) |
| 2234 |
); |
| 2235 |
} |
| 2236 |
} |
| 2237 |
|
| 2238 |
$dismiss_url = wp_nonce_url( |
| 2239 |
add_query_arg( [ 'gvectors_dismiss_addon_notice' => 'tampered', 'gvectors_notice_slug' => $slug ] ), |
| 2240 |
'gvectors_dismiss_tampered_' . $slug |
| 2241 |
); |
| 2242 |
|
| 2243 |
printf( |
| 2244 |
'<div class="notice notice-error" style="border-left-color:#dc3232;border-left-width:4px;">' |
| 2245 |
. '<p><strong style="font-size:14px;">⚠️ %s</strong> %s</p>' |
| 2246 |
. '<p>%s</p>' |
| 2247 |
. '<p>%s</p>' |
| 2248 |
. '<p>%s</p>' |
| 2249 |
. '<p><a href="%s">%s</a></p>' |
| 2250 |
. '</div>', |
| 2251 |
esc_html__( 'gVectors Security Alert — Unauthorized Addon Detected', 'gvectors' ), |
| 2252 |
'<code>' . esc_html( $slug ) . '</code>', |
| 2253 |
esc_html( $reason_text ), |
| 2254 |
$status_text, |
| 2255 |
sprintf( |
| 2256 |
esc_html__( 'Please purchase a valid license at %s or remove the unauthorized addon.', 'gvectors' ), |
| 2257 |
'<a href="' . admin_url( $this->config->get_dashboard_addons_store_url() ) . '">Addons Store</a>' |
| 2258 |
), |
| 2259 |
esc_url( $dismiss_url ), |
| 2260 |
esc_html__( 'Dismiss for 5 days', 'gvectors' ) |
| 2261 |
); |
| 2262 |
} |
| 2263 |
} |
| 2264 |
|
| 2265 |
/** |
| 2266 |
* Register after_plugin_row hooks for installed addons that have updates but no active license. |
| 2267 |
* Shows a notice row on the Plugins page explaining that a license is required to update. |
| 2268 |
*/ |
| 2269 |
public function register_unlicensed_update_row_hooks(): void { |
| 2270 |
if( ! is_admin() ) return; |
| 2271 |
|
| 2272 |
$update_plugins = get_site_transient( 'update_plugins' ); |
| 2273 |
if( empty( $update_plugins->response ) ) return; |
| 2274 |
|
| 2275 |
$licenses = $this->licenseService->get_all(); |
| 2276 |
|
| 2277 |
// Build a set of plugin slugs that have an active/trial license for this domain |
| 2278 |
$active_licensed_slugs = []; |
| 2279 |
$site_domain = LicenseModule::get_site_domain(); |
| 2280 |
foreach( $licenses as $license ) { |
| 2281 |
if( empty( $license['license_key'] ) ) continue; |
| 2282 |
if( ! in_array( $license['status'] ?? '', [ 'active', 'trial' ], true ) ) continue; |
| 2283 |
if( ! empty( $license['expires_at'] ) && strtotime( $license['expires_at'] ) < time() ) continue; |
| 2284 |
if( ! empty( $site_domain ) ) { |
| 2285 |
$activated_site = $license['site_domain'] ?? ''; |
| 2286 |
if( ! empty( $activated_site ) && LicenseModule::normalize_domain( $activated_site ) !== LicenseModule::normalize_domain( $site_domain ) ) continue; |
| 2287 |
} |
| 2288 |
$slug = $license['plugin_slug'] ?? ''; |
| 2289 |
if( ! empty( $slug ) ) $active_licensed_slugs[] = $slug; |
| 2290 |
} |
| 2291 |
|
| 2292 |
// Also include slugs that have an active (non-expired) legacy license |
| 2293 |
$all_legacy = get_option( $this->legacy_licenses_option, [] ); |
| 2294 |
foreach( $all_legacy as $legacy_slug => $legacy ) { |
| 2295 |
if( empty( $legacy['has_license'] ) || ! empty( $legacy['expired'] ) ) continue; |
| 2296 |
$active_licensed_slugs[] = $legacy_slug; |
| 2297 |
} |
| 2298 |
|
| 2299 |
foreach( $update_plugins->response as $plugin_file => $update_data ) { |
| 2300 |
$slug = dirname( $plugin_file ); |
| 2301 |
if( $slug === '.' ) continue; |
| 2302 |
|
| 2303 |
// Only for our addons that have empty package (no active license) |
| 2304 |
$package = is_object( $update_data ) ? ( $update_data->package ?? '' ) : ''; |
| 2305 |
if( ! empty( $package ) ) continue; |
| 2306 |
|
| 2307 |
// Confirm it's an addon from the gVectors store list (no HTTP request on page load) |
| 2308 |
if( ! $this->is_known_addon( $slug, false ) ) continue; |
| 2309 |
|
| 2310 |
// Confirm no active license |
| 2311 |
if( in_array( $slug, $active_licensed_slugs, true ) ) continue; |
| 2312 |
|
| 2313 |
// Licensed via / belongs to another host plugin — that host's instance renders the row (and its store link) |
| 2314 |
if( ! $this->manages_addon( $slug, false ) ) continue; |
| 2315 |
|
| 2316 |
if( ! self::claim_notice( 'unlicensed_row', $slug ) ) continue; |
| 2317 |
|
| 2318 |
add_action( "after_plugin_row_$plugin_file", [ $this, 'unlicensed_update_notice_row' ] ); |
| 2319 |
} |
| 2320 |
} |
| 2321 |
|
| 2322 |
/** |
| 2323 |
* Display an inline notice row on the Plugins page for addons that need a license to update. |
| 2324 |
*/ |
| 2325 |
public function unlicensed_update_notice_row( $plugin_file ): void { |
| 2326 |
$update_plugins = get_site_transient( 'update_plugins' ); |
| 2327 |
$update = $update_plugins->response[ $plugin_file ] ?? null; |
| 2328 |
if( ! $update ) return; |
| 2329 |
|
| 2330 |
$wp_list_table = _get_list_table( 'WP_Plugins_List_Table' ); |
| 2331 |
$columns_count = $wp_list_table ? $wp_list_table->get_column_count() : 3; |
| 2332 |
|
| 2333 |
echo '<tr class="plugin-update-tr' . ( is_plugin_active( $plugin_file ) ? ' active' : '' ) . '" id="' . esc_attr( dirname( $plugin_file ) ) . '-update-license-notice">'; |
| 2334 |
echo '<td colspan="' . esc_attr( $columns_count ) . '" class="colspanchange" style="padding:0;">'; |
| 2335 |
echo '<div class="update-message notice inline notice-warning notice-alt" style="padding:9px 12px;">'; |
| 2336 |
printf( |
| 2337 |
'<p>' . |
| 2338 |
__( 'Warning: your license is not active. Please <a href="%1$s">activate your existing license</a> or <a href="%1$s">purchase a new one</a> to receive updates.', 'gvectors' ) . |
| 2339 |
'</p>', |
| 2340 |
esc_url( admin_url( $this->config->get_dashboard_addons_store_url() ) ) |
| 2341 |
); |
| 2342 |
echo '</div>'; |
| 2343 |
echo '</td>'; |
| 2344 |
echo '</tr>'; |
| 2345 |
} |
| 2346 |
|
| 2347 |
/** |
| 2348 |
* Display persistent admin notice for expired licenses. |
| 2349 |
* Addon keeps working, but no updates are available. |
| 2350 |
*/ |
| 2351 |
public function expired_license_notice(): void { |
| 2352 |
if( ! $this->is_notice_page() ) return; |
| 2353 |
if( ! current_user_can( 'administrator' ) ) return; |
| 2354 |
|
| 2355 |
$this->prune_missing_addons(); |
| 2356 |
|
| 2357 |
$expired = get_option( $this->expired_notice_option, [] ); |
| 2358 |
if( empty( $expired ) ) return; |
| 2359 |
|
| 2360 |
foreach( $expired as $slug => $info ) { |
| 2361 |
if( ! $this->is_addon_present( $slug ) ) continue; |
| 2362 |
if( get_transient( 'gvectors_expired_dismissed_' . $slug ) ) continue; |
| 2363 |
if( ! self::claim_notice( 'expired', $slug ) ) continue; |
| 2364 |
|
| 2365 |
$product_name = $info['product_name'] ?? $slug; |
| 2366 |
$has_update = ! empty( $info['has_update'] ); |
| 2367 |
$latest = $info['latest_version'] ?? ''; |
| 2368 |
$current = $info['current_version'] ?? ''; |
| 2369 |
|
| 2370 |
$update_text = ''; |
| 2371 |
if( $has_update ) { |
| 2372 |
$update_text = sprintf( |
| 2373 |
' ' . esc_html__( 'A new version (%1$s) is available but your current version (%2$s) cannot be updated without an active subscription.', 'gvectors' ), |
| 2374 |
'<strong>' . esc_html( $latest ) . '</strong>', |
| 2375 |
'<strong>' . esc_html( $current ) . '</strong>' |
| 2376 |
); |
| 2377 |
} |
| 2378 |
|
| 2379 |
$dismiss_url = wp_nonce_url( |
| 2380 |
add_query_arg( [ 'gvectors_dismiss_addon_notice' => 'expired', 'gvectors_notice_slug' => $slug ] ), |
| 2381 |
'gvectors_dismiss_expired_' . $slug |
| 2382 |
); |
| 2383 |
|
| 2384 |
printf( |
| 2385 |
'<div class="notice notice-warning" style="border-left-color:#ffb900;border-left-width:4px;">' |
| 2386 |
. '<p><strong>%s</strong> %s%s</p>' |
| 2387 |
. '<p>%s</p>' |
| 2388 |
. '<p><a href="%s">%s</a></p>' |
| 2389 |
. '</div>', |
| 2390 |
esc_html__( 'gVectors License Expired:', 'gvectors' ), |
| 2391 |
sprintf( |
| 2392 |
esc_html__( 'Your license for "%s" has expired. The addon will continue to work, but you will not receive updates or support.', 'gvectors' ), |
| 2393 |
'<strong>' . esc_html( $product_name ) . '</strong>' |
| 2394 |
), |
| 2395 |
$update_text, |
| 2396 |
sprintf( |
| 2397 |
esc_html__( 'Renew your subscription at %s to receive updates and support.', 'gvectors' ), |
| 2398 |
'<a href="' . admin_url( $this->config->get_dashboard_addons_store_url() ) . '">Addons Store</a>' |
| 2399 |
), |
| 2400 |
esc_url( $dismiss_url ), |
| 2401 |
esc_html__( 'Dismiss for 5 days', 'gvectors' ) |
| 2402 |
); |
| 2403 |
} |
| 2404 |
} |
| 2405 |
|
| 2406 |
/** |
| 2407 |
* Display admin notice for expired legacy-licensed addons. |
| 2408 |
* Informs admin that addon works but cannot receive updates without a new subscription. |
| 2409 |
* Active (non-expired) legacy licenses show NO notice — completely silent. |
| 2410 |
*/ |
| 2411 |
public function legacy_license_notice(): void { |
| 2412 |
if( ! $this->is_notice_page() ) return; |
| 2413 |
if( ! current_user_can( 'administrator' ) ) return; |
| 2414 |
|
| 2415 |
$this->prune_missing_addons(); |
| 2416 |
|
| 2417 |
$notices = get_option( $this->legacy_notice_option, [] ); |
| 2418 |
if( empty( $notices ) ) return; |
| 2419 |
|
| 2420 |
$addons_page_url = admin_url( $this->config->get_dashboard_addons_store_url() ); |
| 2421 |
|
| 2422 |
foreach( $notices as $slug => $info ) { |
| 2423 |
// Only show notices for expired legacy licenses |
| 2424 |
if( empty( $info['status'] ) || $info['status'] !== 'expired' ) continue; |
| 2425 |
|
| 2426 |
// Verify the addon is still installed |
| 2427 |
if( ! $this->is_addon_present( $slug ) ) continue; |
| 2428 |
|
| 2429 |
if( get_transient( 'gvectors_legacy_dismissed_' . $slug ) ) continue; |
| 2430 |
if( ! self::claim_notice( 'legacy', $slug ) ) continue; |
| 2431 |
|
| 2432 |
$plugin_name = $info['plugin_name'] ?? $slug; |
| 2433 |
|
| 2434 |
$dismiss_url = wp_nonce_url( |
| 2435 |
add_query_arg( [ 'gvectors_dismiss_addon_notice' => 'legacy', 'gvectors_notice_slug' => $slug ] ), |
| 2436 |
'gvectors_dismiss_legacy_' . $slug |
| 2437 |
); |
| 2438 |
|
| 2439 |
printf( |
| 2440 |
'<div class="notice notice-info" style="border-left-color:#0073aa;border-left-width:4px;">' |
| 2441 |
. '<p><strong>%s</strong> %s</p>' |
| 2442 |
. '<p>%s</p>' |
| 2443 |
. '<p><a href="%s">%s</a></p>' |
| 2444 |
. '</div>', |
| 2445 |
esc_html__( 'gVectors Addon — Legacy License:', 'gvectors' ), |
| 2446 |
sprintf( |
| 2447 |
esc_html__( |
| 2448 |
'Your "%s" addon is using a legacy license that has expired. The addon will continue to work without any issues, but automatic updates are not available.', |
| 2449 |
'gvectors' |
| 2450 |
), |
| 2451 |
'<strong>' . esc_html( $plugin_name ) . '</strong>' |
| 2452 |
), |
| 2453 |
sprintf( |
| 2454 |
esc_html__( |
| 2455 |
'To receive new updates, please purchase a new subscription at the %1$s. After completing the transaction, re-download and install the addon to get the latest version with full license activation.', |
| 2456 |
'gvectors' |
| 2457 |
), |
| 2458 |
'<a href="' . esc_url( $addons_page_url ) . '">' . esc_html__( 'Addons Store', 'gvectors' ) . '</a>' |
| 2459 |
), |
| 2460 |
esc_url( $dismiss_url ), |
| 2461 |
esc_html__( 'Dismiss for 5 days', 'gvectors' ) |
| 2462 |
); |
| 2463 |
} |
| 2464 |
} |
| 2465 |
} |
| 2466 |
|