PluginProbe
wpForo Forum / 3.2.0
wpForo Forum v3.2.0
3.2.1 3.2.0 3.1.7 3.1.6 3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 All 141 releases
wpforo / admin / pages / license / src / Services / AddonsService.php

AddonsService.php in wpForo Forum 3.2.0, at admin/pages/license/src/Services/AddonsService.php

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