PluginProbe
wpForo Forum / 3.2.1
wpForo Forum v3.2.1
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.1, at admin/pages/license/src/Services/AddonsService.php

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