PluginProbe
wpForo Forum / 3.1.6
wpForo Forum v3.1.6
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 1.4.11 1.4.12 1.4.13 All 138 releases
wpforo / admin / pages / license / src / Services / AddonsService.php

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

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