PluginProbe
wpForo Forum / 3.2.2
wpForo Forum v3.2.2
3.2.2 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 All 142 releases
wpforo / admin / pages / license / src / Services / AddonsService.php

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

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