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
← All changes | admin/pages/license/src/Services/AddonsService.php +708 -154 3.1.7 → 3.2.2 View file →
@@ -29,8 +29,9 @@
29 29 * only offers updates for licenses that are active/trial AND activated for this domain.
30 30 * Expired licenses are NOT offered updates (addon keeps working but no new versions).
31 31 */
32 32 private $update_check_done = false;
33 + private $pruned = false;
33 34 private $all_addons_transient_name;
34 35 private $signature_check_hook;
35 36 private $license_check_hook;
36 37 private $tampered_option;
@@ -37,8 +38,11 @@
37 38 private $expired_notice_option;
38 39 private $tamper_dismissed_option;
39 40 private $legacy_licenses_option;
40 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;
41 45 /** Shared transient (not slug-prefixed) so one dismissing covers all plugin instances */
42 46 private static $shared_dev_env_transient = 'gvectors_dev_env_notice_dismissed';
43 47 private static $shared_dev_licenses_transient = 'gvectors_dev_licenses_notice_dismissed';
44 48
@@ -45,8 +49,13 @@
45 49 /** Static collectors for cross-instance notice deduplication */
46 50 private static $dev_env_notice_shown = false;
47 51 private static $dev_licenses_collected = [];
48 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';
49 58
50 59 public function __construct( Config $config, LicenseService $licenseService ) {
51 60 $this->config = $config;
52 61 $this->licenseService = $licenseService;
@@ -57,8 +66,9 @@
57 66 $this->expired_notice_option = $this->licenseService->expired_notice_option;
58 67 $this->tamper_dismissed_option = $this->config->get_core_plugin_slug() . '_gvectors_tamper_notice_seen';
59 68 $this->legacy_licenses_option = $this->config->get_core_plugin_slug() . '_gvectors_legacy_addon_licenses';
60 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';
61 71 $this->init_hooks();
62 72 }
63 73
64 74 private function init_hooks() {
@@ -103,14 +113,49 @@
103 113 add_filter( 'upgrader_pre_download', [ $this, 'block_tampered_update_download' ], 10, 2 );
104 114
105 115 // Clear tamper flag when a plugin is deleted
106 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' ] );
107 120 }
108 121
109 122 /**
110 - * Install and activate an addon in one step
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.
111 126 */
112 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 +
113 158 $install_result = $this->install( $product_id );
114 159 if( empty( $install_result['success'] ) ) return $install_result;
115 160
116 161 $plugin_file = $install_result['plugin_file'];
@@ -130,13 +175,103 @@
130 175 ];
131 176 }
132 177
133 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 + /**
134 265 * Download and install an addon from the proxy server
135 266 */
136 267 public function install( string $product_id ): array {
137 - if( ! current_user_can( 'install_plugins' ) ) {
138 - return [ 'success' => false, 'error' => __( 'Permission denied', 'gvectors' ) ];
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 + ];
139 274 }
140 275
141 276 $license = $this->licenseService->get( $product_id );
142 277 if( empty( $license ) || empty( $license['license_key'] ) ) {
@@ -148,9 +283,9 @@
148 283 if( $plugin_slug && $this->is_addon_tampered( $plugin_slug ) ) {
149 284 return [
150 285 'success' => false,
151 286 'error' => __(
152 - 'This addon cannot be updated because its files have been modified or are not original. To resolve this, please: 1) Go to Plugins and deactivate, then delete this addon. 2) Visit the gVectors Store Addons page and make sure your license is active. 3) Re-install the addon from the gVectors Store Addons page. Once re-installed, everything will work normally again.',
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.',
153 288 'gvectors'
154 289 ),
155 290 ];
156 291 }
@@ -155,22 +290,13 @@
155 290 ];
156 291 }
157 292
158 293 // Get signed download URL from proxy
159 - $response = $this->licenseService->apiService->get_addon_download_url( $product_id, $license['license_key'] );
160 - error_log( '[gVectors Addon] download-url response: ' . print_r( $response, true ) );
161 - if( empty( $response['success'] ) || empty( $response['data']['download_url'] ) ) {
162 - $error = $response['error'] ?? __( 'Failed to get download URL', 'gvectors' );
163 - if( isset( $response['data']['error'] ) ) $error = $response['data']['error'];
164 - error_log( '[gVectors Addon] Failed to get download URL: ' . $error );
165 -
166 - return [ 'success' => false, 'error' => $error ];
167 - }
294 + $download = $this->request_download( $product_id );
295 + if( empty( $download['success'] ) ) return $download;
168 296
169 - $download_url = $response['data']['download_url'];
170 - $download_url = add_query_arg( 'site_domain', rawurlencode( LicenseModule::get_site_domain() ), $download_url );
171 - $plugin_slug = $response['data']['plugin_slug'] ?? '';
172 - error_log( '[gVectors Addon] download_url: ' . $download_url . ' | plugin_slug: ' . $plugin_slug );
297 + $download_url = $download['download_url'];
298 + $plugin_slug = $download['plugin_slug'];
173 299
174 300 // Use WordPress built-in plugin installer
175 301 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
176 302 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
@@ -187,21 +313,24 @@
187 313 } else {
188 314 $result = $upgrader->install( $download_url );
189 315 }
190 316
317 + // Only filesystem/permission failures offer the manual (ZIP + FTP) install path; others (download, archive...) just report the error
191 318 if( is_wp_error( $result ) ) {
192 319 error_log( '[gVectors Addon] WP_Error from upgrader: ' . $result->get_error_message() );
193 320
194 - return [ 'success' => false, 'error' => $result->get_error_message() ];
321 + return [ 'success' => false, 'error' => $result->get_error_message(), 'manual_install' => self::is_filesystem_error( $result ) ];
195 322 }
196 323
197 324 if( $result === false ) {
198 325 $errors = $skin->get_errors();
199 - $error = is_wp_error( $errors ) ? $errors->get_error_message() : __( 'Installation failed', 'gvectors' );
326 + $has_errors = is_wp_error( $errors ) && $errors->has_errors();
327 + $error = $has_errors ? $errors->get_error_message() : __( 'Installation failed', 'gvectors' );
200 328 $skin_feedback = method_exists( $skin, 'get_upgrade_messages' ) ? $skin->get_upgrade_messages() : [];
201 329 error_log( '[gVectors Addon] Install result=false. Error: ' . $error . ' | Feedback: ' . print_r( $skin_feedback, true ) );
202 330
203 - return [ 'success' => false, 'error' => $error ];
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 ) ];
204 333 }
205 334
206 335 error_log( '[gVectors Addon] Install result: ' . print_r( $result, true ) );
207 336 error_log( '[gVectors Addon] Skin messages: ' . print_r( $skin->get_upgrade_messages(), true ) );
@@ -252,8 +381,95 @@
252 381 ];
253 382 }
254 383
255 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 + /**
256 472 * Check if an addon is flagged as tampered/unauthorized
257 473 */
258 474 public function is_addon_tampered( string $plugin_slug ): bool {
259 475 $tampered = get_option( $this->tampered_option, [] );
@@ -333,23 +549,138 @@
333 549
334 550 /**
335 551 * Fetch all addon info from the proxy server, keyed by slug.
336 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.
337 554 */
338 555 private function get_proxy_addons_map(): array {
339 556 $response = $this->licenseService->apiService->get_all_addons();
340 - if( empty( $response['success'] ) || empty( $response['data']['addons'] ) ) {
557 + if( empty( $response['success'] ) || empty( $response['data']['addons'] ) || ! is_array( $response['data']['addons'] ) ) {
341 558 return [];
342 559 }
343 560 $map = [];
344 561 foreach( $response['data']['addons'] as $addon ) {
345 - if( ! empty( $addon['slug'] ) ) {
562 + if( ! empty( $addon['slug'] ) && is_string( $addon['slug'] ) && ! $this->is_host_plugin( $addon['slug'] ) ) {
346 563 $map[ $addon['slug'] ] = $addon;
347 564 }
348 565 }
349 -
566 + $this->remember_store_addons( $map );
567 +
350 568 return $map;
351 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 + }
352 683
353 684 /**
354 685 * Verify signatures of a single addon by its slug.
355 686 * Checks: manifest existence, file hashes, domain signature, PHP header signatures.
@@ -372,13 +703,21 @@
372 703 if( $patch_check !== 'valid' ) {
373 704 return $patch_check;
374 705 }
375 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 +
376 715 // Check if this addon has a legacy license from the old gVectors system
377 716 $legacy = $this->check_legacy_license( $plugin_slug );
378 717 if( ! empty( $legacy['has_license'] ) ) {
379 718 // Legacy licensed addon — clear any previous tamper flags
380 - $this->clear_tamper_flag( $plugin_slug );
719 + $this->clear_tamper_flag( $plugin_slug, true );
381 720 // Track expired legacy licenses for admin notice
382 721 $this->update_legacy_notice( $plugin_slug, $legacy );
383 722
384 723 return 'legacy_valid';
@@ -383,8 +722,11 @@
383 722
384 723 return 'legacy_valid';
385 724 }
386 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 +
387 729 // No legacy license either — this is an unauthorized copy
388 730 $this->mark_addon_tampered( $plugin_slug, [ 'Missing signature manifest' ], 'no_manifest' );
389 731
390 732 return 'no_manifest';
@@ -516,9 +858,9 @@
516 858 return $patch_check;
517 859 }
518 860
519 861 // All checks passed - clear any previous tamper flags
520 - $this->clear_tamper_flag( $plugin_slug );
862 + $this->clear_tamper_flag( $plugin_slug, true );
521 863
522 864 return 'valid';
523 865 }
524 866
@@ -634,36 +976,58 @@
634 976
635 977 $response = $this->licenseService->apiService->check_legacy_license( $plugin_slug );
636 978
637 979 if( ! empty( $response['success'] ) && ! empty( $response['data'] ) ) {
638 - $data = $response['data'];
639 - $legacy_data = [
640 - 'has_license' => ! empty( $data['has_legacy_license'] ),
641 - 'status' => $data['status'] ?? '',
642 - 'expired' => ! empty( $data['expired'] ),
643 - 'expired_time' => isset( $data['expired_time'] ) ? (int) $data['expired_time'] : 0,
644 - 'last_checked' => time(),
645 - ];
646 - $this->save_cached_legacy_license( $plugin_slug, $legacy_data );
647 -
648 - return $legacy_data;
980 + return $this->save_cached_legacy_license( $plugin_slug, self::legacy_result_from_api( $response['data'] ) );
649 981 }
650 982
651 - // API call failed — cache a negative result with a shorter TTL (1 hour)
652 - // so we retry sooner, but don't hammer the server on every cron run
653 - $negative = [
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 ] ?? [
654 987 'has_license' => false,
988 + 'unknown' => true,
655 989 'status' => '',
656 990 'expired' => false,
657 991 'expired_time' => 0,
658 - 'last_checked' => time() - $this->config->get_legacy_check_period() + HOUR_IN_SECONDS,
659 992 ];
660 - $this->save_cached_legacy_license( $plugin_slug, $negative );
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 );
661 996
662 - return $negative;
997 + return $known;
663 998 }
664 999
665 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 + /**
666 1030 * Get cached legacy license data for a slug.
667 1031 * Returns the cached array or false if not cached or stale.
668 1032 */
669 1033 private function get_cached_legacy_license( string $plugin_slug ) {
@@ -683,14 +1047,16 @@
683 1047 // Activation Gate
684 1048 // ==========================================
685 1049
686 1050 /**
687 - * Save legacy license check result to the persistent cache.
1051 + * Save a legacy license check result to the persistent cache (see merge_legacy_result()) and return what was stored.
688 1052 */
689 - private function save_cached_legacy_license( string $plugin_slug, array $data ): void {
1053 + private function save_cached_legacy_license( string $plugin_slug, array $data ): array {
690 1054 $all_legacy = get_option( $this->legacy_licenses_option, [] );
691 - $all_legacy[ $plugin_slug ] = $data;
1055 + $all_legacy[ $plugin_slug ] = self::merge_legacy_result( $all_legacy[ $plugin_slug ] ?? [], $data );
692 1056 update_option( $this->legacy_licenses_option, $all_legacy );
1057 +
1058 + return $all_legacy[ $plugin_slug ];
693 1059 }
694 1060
695 1061 // ==========================================
696 1062 // Signature & Piracy Verification
@@ -697,14 +1063,19 @@
697 1063 // ==========================================
698 1064
699 1065 /**
700 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)
701 1070 */
702 - private function clear_tamper_flag( string $plugin_slug ): void {
1071 + private function clear_tamper_flag( string $plugin_slug, bool $restore = false ): void {
703 1072 $tampered = get_option( $this->tampered_option, [] );
704 1073 if( isset( $tampered[ $plugin_slug ] ) ) {
1074 + $deactivated = ! empty( $tampered[ $plugin_slug ]['deactivated_at'] );
705 1075 unset( $tampered[ $plugin_slug ] );
706 1076 update_option( $this->tampered_option, $tampered );
1077 + if( $restore && $deactivated ) $this->reactivate_addon( $plugin_slug );
707 1078 }
708 1079
709 1080 // Also clear the seen flag
710 1081 $seen = get_option( $this->tamper_dismissed_option, [] );
@@ -714,8 +1085,29 @@
714 1085 }
715 1086 }
716 1087
717 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 + /**
718 1110 * Track legacy-licensed addons that have expired licenses for admin notice.
719 1111 */
720 1112 private function update_legacy_notice( string $plugin_slug, array $legacy_data ): void {
721 1113 $notices = get_option( $this->legacy_notice_option, [] );
@@ -886,14 +1278,13 @@
886 1278 continue;
887 1279 }
888 1280
889 1281 $plugin_slug = $license['plugin_slug'] ?? '';
890 - if( empty( $plugin_slug ) ) continue;
1282 + if( empty( $plugin_slug ) || $this->is_host_plugin( $plugin_slug ) ) continue;
891 1283
892 1284 $plugin_file = $this->get_installed_plugin_file( $plugin_slug );
893 1285 if( ! $plugin_file ) continue;
894 1286
895 -
896 1287 $current_version = $transient->checked[ $plugin_file ] ?? '0.0.0';
897 1288
898 1289 // Use proxy server version (from addon file header) instead of local options
899 1290 $proxy_info = $proxy_addons[ $plugin_slug ] ?? [];
@@ -945,8 +1336,11 @@
945 1336 if( in_array( $plugin_file, $licensed_plugin_files, true ) ) continue;
946 1337
947 1338 // Only process known gVectors addons from the proxy
948 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;
949 1343
950 1344 // Migrate legacy license to new system eagerly — even without a pending update.
951 1345 // On success, save() stores the license in gvectors_licenses so the Paddle loop
952 1346 // handles this slug on the next check_for_updates() call.
@@ -1005,10 +1399,11 @@
1005 1399 $uncached_slugs = [];
1006 1400 foreach( $all_plugins as $_pf => $_pd ) {
1007 1401 if( in_array( $_pf, $licensed_plugin_files, true ) ) continue;
1008 1402 $_slug = dirname( $_pf );
1009 - if( $_slug === '.' || $_slug === $this->config->get_core_plugin_slug() ) continue;
1010 - if( ! isset( $proxy_addons[ $_slug ] ) ) continue;
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;
1011 1406 if( ! isset( $all_legacy[ $_slug ] ) ) $uncached_slugs[] = $_slug;
1012 1407 }
1013 1408 if( ! empty( $uncached_slugs ) ) {
1014 1409 $this->check_legacy_licenses_batch( array_unique( $uncached_slugs ) );
@@ -1062,12 +1457,14 @@
1062 1457 // Skip if already handled by licensed update above
1063 1458 if( in_array( $plugin_file, $licensed_plugin_files, true ) ) continue;
1064 1459
1065 1460 $slug = dirname( $plugin_file );
1066 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue;
1067 1461
1068 - // Only process known gVectors addons from the proxy
1069 - if( ! isset( $proxy_addons[ $slug ] ) ) continue;
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;
1070 1467
1071 1468 $proxy_info = $proxy_addons[ $slug ];
1072 1469 $latest_version = ! empty( $proxy_info['version'] ) ? $proxy_info['version'] : '';
1073 1470 $current_version = $transient->checked[ $plugin_file ] ?? '0.0.0';
@@ -1088,8 +1485,21 @@
1088 1485 $transient->response[ $plugin_file ] = $update;
1089 1486 }
1090 1487 }
1091 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 +
1092 1502 return $transient;
1093 1503 }
1094 1504
1095 1505 /**
@@ -1147,9 +1557,10 @@
1147 1557 }
1148 1558
1149 1559 /**
1150 1560 * Batch-check legacy licenses for multiple addon slugs.
1151 - * Populates the local cache for all slugs in one API call.
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.
1152 1563 */
1153 1564 private function check_legacy_licenses_batch( array $plugin_slugs ): void {
1154 1565 if( empty( $plugin_slugs ) ) return;
1155 1566
@@ -1154,30 +1565,13 @@
1154 1565 if( empty( $plugin_slugs ) ) return;
1155 1566
1156 1567 $response = $this->licenseService->apiService->check_legacy_licenses_batch( $plugin_slugs );
1157 1568
1158 - if( ! empty( $response['success'] ) && ! empty( $response['data']['addons'] ) ) {
1569 + if( ! empty( $response['success'] ) && ! empty( $response['data']['addons'] ) && is_array( $response['data']['addons'] ) ) {
1159 1570 $all_legacy = get_option( $this->legacy_licenses_option, [] );
1160 - foreach( $response['data']['addons'] as $slug => $data ) {
1161 - $all_legacy[ $slug ] = [
1162 - 'has_license' => ! empty( $data['has_legacy_license'] ),
1163 - 'status' => $data['status'] ?? '',
1164 - 'expired' => ! empty( $data['expired'] ),
1165 - 'expired_time' => isset( $data['expired_time'] ) ? (int) $data['expired_time'] : 0,
1166 - 'last_checked' => time(),
1167 - ];
1168 - }
1169 - // Also cache negative results for slugs not returned by the server
1170 1571 foreach( $plugin_slugs as $slug ) {
1171 - if( ! isset( $all_legacy[ $slug ] ) || $all_legacy[ $slug ]['last_checked'] < time() - 60 ) {
1172 - $all_legacy[ $slug ] = [
1173 - 'has_license' => false,
1174 - 'status' => '',
1175 - 'expired' => false,
1176 - 'expired_time' => 0,
1177 - 'last_checked' => time(),
1178 - ];
1179 - }
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 : [] ) );
1180 1574 }
1181 1575 update_option( $this->legacy_licenses_option, $all_legacy );
1182 1576 }
1183 1577 }
@@ -1182,8 +1576,42 @@
1182 1576 }
1183 1577 }
1184 1578
1185 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 + /**
1186 1614 * Intercept WordPress updater package downloads to block tampered addons with a visible error.
1187 1615 * This hooks into 'upgrader_pre_download' so the user sees a clear message in the update UI.
1188 1616 * The actual download is handled by the proxy server's addon/wp-download endpoint (302 redirect).
1189 1617 */
@@ -1212,9 +1640,9 @@
1212 1640 if( $plugin_slug && $this->is_addon_tampered( $plugin_slug ) ) {
1213 1641 return new WP_Error(
1214 1642 'tampered_addon',
1215 1643 __(
1216 - 'This addon cannot be updated because its files have been modified or are not original. To resolve this, please: 1) Go to Plugins and deactivate, then delete this addon. 2) Visit the gVectors Store Addons page and make sure your license is active. 3) Re-install the addon from the gVectors Store Addons page. Once re-installed, everything will work normally again.',
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.',
1217 1645 'gvectors'
1218 1646 )
1219 1647 );
1220 1648 }
@@ -1255,19 +1683,21 @@
1255 1683 * Block activation with wp_die() if any check fails.
1256 1684 */
1257 1685 public function validate_on_activation( string $plugin_file ): void {
1258 1686 $slug = dirname( $plugin_file );
1259 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) return;
1687 + if( $slug === '.' || $this->is_host_plugin( $slug ) ) return;
1260 1688
1261 1689 // Skip all checks on development/local/staging environments
1262 1690 if( LicenseModule::is_development_site() ) return;
1263 1691
1264 - // Check if this is a known gVectors addon
1265 - $all_addon_slugs = $this->get_all_addon_slugs_from_proxy();
1266 - if( ! $this->is_known_addon( $slug, $all_addon_slugs ) ) return;
1267 -
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 +
1268 1698 $reasons = [];
1269 -
1699 +
1270 1700 // 1) License check — new Paddle license OR legacy gVectors license
1271 1701 $has_new_license = $this->addon_has_license( $slug );
1272 1702 $has_legacy_license = false;
1273 1703 if( ! $has_new_license ) {
@@ -1279,16 +1709,9 @@
1279 1709
1280 1710 // 2) Signature & integrity checks
1281 1711 $sig_result = $this->verify_addon_signatures( $slug );
1282 1712 if( $sig_result !== 'valid' && $sig_result !== 'legacy_valid' ) {
1283 - $labels = [
1284 - 'no_manifest' => __( 'Missing signature manifest — addon was not installed through the official channel.', 'gvectors' ),
1285 - 'tampered' => __( 'File integrity check failed — one or more addon files have been modified.', 'gvectors' ),
1286 - 'domain_mismatch' => __( 'Domain mismatch — this addon copy is signed for a different website.', 'gvectors' ),
1287 - 'no_signatures' => __( 'Missing PHP header signatures — addon files lack required security headers.', 'gvectors' ),
1288 - 'patched' => __( 'Nulled/patched code detected — this addon appears to be a pirated copy.', 'gvectors' ),
1289 - ];
1290 - $reasons[] = $labels[ $sig_result ] ?? __( 'Addon verification failed.', 'gvectors' );
1713 + $reasons[] = self::signature_failure_reason( $sig_result );
1291 1714 }
1292 1715
1293 1716 if( ! empty( $reasons ) ) {
1294 1717 // Store a transient so we can show an admin notice on redirect back
@@ -1305,30 +1728,33 @@
1305 1728 }
1306 1729 }
1307 1730
1308 1731 /**
1309 - * Fetch all known addon slugs from the proxy server.
1310 - * Returns array of slug strings.
1732 + * Human-readable reason for a failed verify_addon_signatures() result
1311 1733 */
1312 - private function get_all_addon_slugs_from_proxy(): array {
1313 - $response = $this->licenseService->apiService->get_all_addons();
1314 - if( empty( $response['success'] ) || empty( $response['data']['addons'] ) ) {
1315 - return [];
1316 - }
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 + ];
1317 1742
1318 - return array_column( $response['data']['addons'], 'slug' );
1743 + return $labels[ $sig_result ] ?? __( 'Addon verification failed.', 'gvectors' );
1319 1744 }
1320 1745
1321 1746 /**
1322 - * Check if a plugin slug is a known gVectors addon by querying the proxy's full addon list.
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)
1323 1752 */
1324 - private function is_known_addon( string $plugin_slug, array $all_addon_slugs = [] ): bool {
1325 - if( ! empty( $all_addon_slugs ) ) {
1326 - return in_array( $plugin_slug, $all_addon_slugs, true );
1327 - }
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;
1328 1755
1329 - // Fallback: check by naming convention
1330 - return ( strpos( $plugin_slug, $this->config->get_core_plugin_slug() . '-' ) === 0 || strpos( $plugin_slug, $this->config->get_core_plugin_slug() . '_' ) === 0 );
1756 + return isset( $this->get_store_addons( $allow_remote )[ $plugin_slug ] );
1331 1757 }
1332 1758
1333 1759 /**
1334 1760 * Check if an addon slug has an associated license (local) or is a known addon from proxy.
@@ -1344,15 +1770,27 @@
1344 1770 return false;
1345 1771 }
1346 1772
1347 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 + /**
1348 1785 * Quick check: does this addon have a legacy license (from cache)?
1349 - * Returns true if the cached legacy license exists and is valid.
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).
1350 1788 */
1351 1789 private function has_legacy_license( string $plugin_slug ): bool {
1352 1790 $legacy = $this->check_legacy_license( $plugin_slug );
1353 1791
1354 - return ! empty( $legacy['has_license'] );
1792 + return ! empty( $legacy['has_license'] ) || ! empty( $legacy['unknown'] );
1355 1793 }
1356 1794
1357 1795 /**
1358 1796 * Verify all installed addons — uses the proxy's full addon list (not just local licenses).
@@ -1359,14 +1797,13 @@
1359 1797 * Checks every installed plugin that matches a known addon slug from the proxy.
1360 1798 * Uses a grace period: show FATAL notice first, deactivate after TAMPER_GRACE_DAYS.
1361 1799 */
1362 1800 public function verify_all_addon_signatures(): void {
1801 + $this->prune_missing_addons();
1802 +
1363 1803 // Skip all checks on development/local/staging environments
1364 1804 if( LicenseModule::is_development_site() ) return;
1365 1805
1366 - // Get the full list of known addon slugs from the proxy server
1367 - $all_addon_slugs = $this->get_all_addon_slugs_from_proxy();
1368 -
1369 1806 // Collect locally licensed plugin slugs
1370 1807 $licenses = $this->licenseService->get_all();
1371 1808 $licensed_slugs = [];
1372 1809 foreach( $licenses as $product_id => $license ) {
@@ -1374,11 +1811,11 @@
1374 1811 if( ! empty( $slug ) ) $licensed_slugs[] = $slug;
1375 1812 }
1376 1813
1377 1814 // Scan for installed addons that are known to the proxy but have no license
1378 - $this->scan_unlicensed_addons( $licensed_slugs, $all_addon_slugs );
1815 + $this->scan_unlicensed_addons( $licensed_slugs );
1379 1816
1380 - // Verify signatures for all installed plugins that match known addon slugs
1817 + // Verify signatures for all installed plugins that are in the store addon list
1381 1818 if( ! function_exists( 'get_plugins' ) ) {
1382 1819 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1383 1820 }
1384 1821 $all_plugins = get_plugins();
@@ -1384,13 +1821,20 @@
1384 1821 $all_plugins = get_plugins();
1385 1822
1386 1823 foreach( $all_plugins as $file => $data ) {
1387 1824 $slug = dirname( $file );
1388 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue;
1825 + if( $slug === '.' ) continue;
1389 1826
1390 - // Check against proxy's known addon list
1391 - if( ! $this->is_known_addon( $slug, $all_addon_slugs ) ) continue;
1827 + // Check against the store's addon list (host plugins excluded)
1828 + if( ! $this->is_known_addon( $slug ) ) continue;
1392 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 + }
1393 1837
1394 1838 $result = $this->verify_addon_signatures( $slug );
1395 1839 if( $result !== 'valid' && $result !== 'legacy_valid' ) {
1396 1840 $this->maybe_deactivate_tampered( $slug );
@@ -1398,13 +1842,32 @@
1398 1842 }
1399 1843 }
1400 1844
1401 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 + /**
1402 1865 * Scan for installed plugins that are known gVectors addons (from the proxy list) but have no license.
1403 1866 * These could be pirated copies installed manually, OR legacy-licensed installations.
1404 1867 * Checks legacy license before flagging as tampered.
1405 1868 */
1406 - private function scan_unlicensed_addons( array $licensed_slugs, array $all_addon_slugs = [] ): void {
1869 + private function scan_unlicensed_addons( array $licensed_slugs ): void {
1407 1870 if( ! function_exists( 'get_plugins' ) ) {
1408 1871 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1409 1872 }
1410 1873
@@ -1418,11 +1881,12 @@
1418 1881 $needs_legacy_check = [];
1419 1882 $needs_legacy_refresh = [];
1420 1883 foreach( $all_plugins as $file => $data ) {
1421 1884 $slug = dirname( $file );
1422 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue;
1423 - if( ! $this->is_known_addon( $slug, $all_addon_slugs ) ) continue;
1885 + if( $slug === '.' ) continue;
1886 + if( ! $this->is_known_addon( $slug ) ) continue;
1424 1887 if( in_array( $slug, $licensed_slugs, true ) ) continue;
1888 + if( ! $this->manages_addon( $slug ) ) continue;
1425 1889 if( ! is_plugin_active( $file ) ) continue;
1426 1890
1427 1891 $manifest_file = WP_PLUGIN_DIR . '/' . $slug . '/.addon-signatures.json';
1428 1892 if( ! file_exists( $manifest_file ) ) {
@@ -1442,9 +1906,9 @@
1442 1906 foreach( $needs_legacy_check as $slug ) {
1443 1907 $legacy = $this->get_cached_legacy_license( $slug );
1444 1908 if( $legacy !== false && ! empty( $legacy['has_license'] ) ) {
1445 1909 // Legacy licensed — not piracy. Track for admin notice if expired.
1446 - $this->clear_tamper_flag( $slug );
1910 + $this->clear_tamper_flag( $slug, true );
1447 1911 $this->update_legacy_notice( $slug, $legacy );
1448 1912
1449 1913 // Proactively migrate active legacy licenses to the new system.
1450 1914 // Once migrated, the slug enters $licensed_slugs and exits this scan
@@ -1455,8 +1919,11 @@
1455 1919
1456 1920 continue;
1457 1921 }
1458 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 +
1459 1926 // No legacy license — suspicious, flag as tampered
1460 1927 $this->mark_addon_tampered( $slug, [
1461 1928 'Active gVectors addon without a valid license or signature manifest',
1462 1929 ], 'no_manifest' );
@@ -1518,12 +1985,14 @@
1518 1985 * Records the first time admin sees each tamper notice.
1519 1986 */
1520 1987 public function track_tamper_notice_view(): void {
1521 1988 if( ! current_user_can( 'administrator' ) ) return;
1522 -
1989 +
1990 + $this->prune_missing_addons();
1991 +
1523 1992 $tampered = get_option( $this->tampered_option, [] );
1524 1993 if( empty( $tampered ) ) return;
1525 -
1994 +
1526 1995 $seen = get_option( $this->tamper_dismissed_option, [] );
1527 1996 $updated = false;
1528 1997
1529 1998 foreach( $tampered as $slug => $info ) {
@@ -1562,19 +2031,70 @@
1562 2031 set_transient( $flag, 1, 12 * HOUR_IN_SECONDS );
1563 2032 }
1564 2033
1565 2034 /**
1566 - * When a plugin is deleted, clear its tamper flag if it had one.
2035 + * When a plugin is deleted, forget all stored notice/tamper/legacy data for it.
1567 2036 */
1568 2037 public function on_plugin_deleted( string $plugin_file, bool $deleted ): void {
1569 2038 if( ! $deleted ) return;
1570 -
2039 +
1571 2040 $slug = dirname( $plugin_file );
1572 2041 if( $slug && $slug !== '.' ) {
1573 - $this->clear_tamper_flag( $slug );
1574 - $this->clear_legacy_cache( $slug );
2042 + $this->forget_addon( $slug );
1575 2043 }
1576 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 + }
1577 2097
1578 2098 /**
1579 2099 * Clear the legacy license cache for a specific addon.
1580 2100 */
@@ -1600,17 +2120,22 @@
1600 2120 * Marks expired licenses for admin notice display.
1601 2121 * Does NOT deactivate addons for expired licenses - they keep working.
1602 2122 */
1603 2123 public function check_all_license_validity(): void {
2124 + $this->prune_missing_addons();
2125 +
1604 2126 $licenses = $this->licenseService->get_all();
1605 2127 if( empty( $licenses ) ) return;
1606 -
2128 +
1607 2129 $expired_notices = get_option( $this->expired_notice_option, [] );
1608 -
2130 +
1609 2131 foreach( $licenses as $license ) {
1610 2132 $plugin_slug = $license['plugin_slug'] ?? '';
1611 2133 if( empty( $plugin_slug ) ) continue;
1612 - if( ! $this->is_installed( $plugin_slug ) ) continue;
2134 + if( ! $this->is_installed( $plugin_slug ) ) {
2135 + unset( $expired_notices[ $plugin_slug ] );
2136 + continue;
2137 + }
1613 2138
1614 2139 $status = $license['status'] ?? '';
1615 2140 $expires_at = $license['expires_at'] ?? '';
1616 2141 $is_expired = false;
@@ -1840,27 +2365,20 @@
1840 2365 */
1841 2366 public function tampered_addon_notice(): void {
1842 2367 if( ! $this->is_notice_page() ) return;
1843 2368 if( ! current_user_can( 'administrator' ) ) return;
2369 +
2370 + $this->prune_missing_addons();
2371 +
1844 2372 if( LicenseModule::is_development_site() ) return;
1845 -
2373 +
1846 2374 $tampered = get_option( $this->tampered_option, [] );
1847 2375 if( empty( $tampered ) ) return;
1848 -
1849 - $changed = false;
2376 +
1850 2377 foreach( $tampered as $slug => $info ) {
1851 - if( ! is_dir( WP_PLUGIN_DIR . '/' . $slug ) ) {
1852 - unset( $tampered[ $slug ] );
1853 - $changed = true;
1854 - }
1855 - }
1856 - if( $changed ) {
1857 - update_option( $this->tampered_option, $tampered );
1858 - }
1859 - if( empty( $tampered ) ) return;
1860 -
1861 - foreach( $tampered as $slug => $info ) {
2378 + if( ! $this->is_addon_present( $slug ) ) continue;
1862 2379 if( get_transient( 'gvectors_tampered_dismissed_' . $slug ) ) continue;
2380 + if( ! self::claim_notice( 'tampered', $slug ) ) continue;
1863 2381
1864 2382 $files = $info['files'] ?? [];
1865 2383 $reason = $info['reason'] ?? 'tampered';
1866 2384 $detected = $info['detected_at'] ?? '';
@@ -1865,11 +2383,13 @@
1865 2383 $reason = $info['reason'] ?? 'tampered';
1866 2384 $detected = $info['detected_at'] ?? '';
1867 2385 $deactivated = $info['deactivated_at'] ?? '';
1868 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';
1869 2389 $reason_labels = [
1870 2390 'tampered' => __( 'File integrity check failed — files have been modified.', 'gvectors' ),
1871 - 'no_manifest' => __( 'Missing signature manifest — this copy was not obtained through an authorized license.', 'gvectors' ),
2391 + 'no_manifest' => __( 'We couldn\'t find a license for this addon on this domain.', 'gvectors' ),
1872 2392 'domain_mismatch' => __( 'Domain signature mismatch — this addon was licensed for a different website.', 'gvectors' ),
1873 2393 'no_signatures' => __( 'Missing file header signatures — files have been stripped of authorization data.', 'gvectors' ),
1874 2394 'patched' => __( 'Suspicious code patterns detected — this appears to be a nulled or patched version.', 'gvectors' ),
1875 2395 ];
@@ -1911,10 +2431,33 @@
1911 2431 add_query_arg( [ 'gvectors_dismiss_addon_notice' => 'tampered', 'gvectors_notice_slug' => $slug ] ),
1912 2432 'gvectors_dismiss_tampered_' . $slug
1913 2433 );
1914 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 + }
1915 2458 printf(
1916 - '<div class="notice notice-error" style="border-left-color:#dc3232;border-left-width:4px;">'
2459 + '<div class="notice %s" style="border-left-width:4px;%s">'
1917 2460 . '<p><strong style="font-size:14px;">⚠️ %s</strong> %s</p>'
1918 2461 . '<p>%s</p>'
1919 2462 . '<p>%s</p>'
1920 2463 . '<p>%s</p>'
@@ -1919,16 +2462,15 @@
1919 2462 . '<p>%s</p>'
1920 2463 . '<p>%s</p>'
1921 2464 . '<p><a href="%s">%s</a></p>'
1922 2465 . '</div>',
1923 - esc_html__( 'gVectors Security Alert — Unauthorized Addon Detected', 'gvectors' ),
2466 + $unlicensed ? 'notice-warning' : 'notice-error',
2467 + $unlicensed ? '' : 'border-left-color:#dc3232;',
2468 + $title,
1924 2469 '<code>' . esc_html( $slug ) . '</code>',
1925 2470 esc_html( $reason_text ),
1926 2471 $status_text,
1927 - sprintf(
1928 - esc_html__( 'Please purchase a valid license at %s or remove the unauthorized addon.', 'gvectors' ),
1929 - '<a href="' . admin_url( $this->config->get_dashboard_addons_store_url() ) . '">Addons Store</a>'
1930 - ),
2472 + $action_text,
1931 2473 esc_url( $dismiss_url ),
1932 2474 esc_html__( 'Dismiss for 5 days', 'gvectors' )
1933 2475 );
1934 2476 }
@@ -1969,19 +2511,24 @@
1969 2511 }
1970 2512
1971 2513 foreach( $update_plugins->response as $plugin_file => $update_data ) {
1972 2514 $slug = dirname( $plugin_file );
1973 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue;
2515 + if( $slug === '.' ) continue;
1974 2516
1975 2517 // Only for our addons that have empty package (no active license)
1976 2518 $package = is_object( $update_data ) ? ( $update_data->package ?? '' ) : '';
1977 2519 if( ! empty( $package ) ) continue;
1978 2520
1979 - // Confirm it's a known gVectors addon
1980 - if( ! $this->is_known_addon( $slug ) ) continue;
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;
1981 2523
1982 2524 // Confirm no active license
1983 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;
1984 2531
1985 2532 add_action( "after_plugin_row_$plugin_file", [ $this, 'unlicensed_update_notice_row' ] );
1986 2533 }
1987 2534 }
@@ -2018,13 +2565,17 @@
2018 2565 public function expired_license_notice(): void {
2019 2566 if( ! $this->is_notice_page() ) return;
2020 2567 if( ! current_user_can( 'administrator' ) ) return;
2021 2568
2569 + $this->prune_missing_addons();
2570 +
2022 2571 $expired = get_option( $this->expired_notice_option, [] );
2023 2572 if( empty( $expired ) ) return;
2024 -
2573 +
2025 2574 foreach( $expired as $slug => $info ) {
2575 + if( ! $this->is_addon_present( $slug ) ) continue;
2026 2576 if( get_transient( 'gvectors_expired_dismissed_' . $slug ) ) continue;
2577 + if( ! self::claim_notice( 'expired', $slug ) ) continue;
2027 2578
2028 2579 $product_name = $info['product_name'] ?? $slug;
2029 2580 $has_update = ! empty( $info['has_update'] );
2030 2581 $latest = $info['latest_version'] ?? '';
@@ -2074,11 +2625,13 @@
2074 2625 public function legacy_license_notice(): void {
2075 2626 if( ! $this->is_notice_page() ) return;
2076 2627 if( ! current_user_can( 'administrator' ) ) return;
2077 2628
2629 + $this->prune_missing_addons();
2630 +
2078 2631 $notices = get_option( $this->legacy_notice_option, [] );
2079 2632 if( empty( $notices ) ) return;
2080 -
2633 +
2081 2634 $addons_page_url = admin_url( $this->config->get_dashboard_addons_store_url() );
2082 2635
2083 2636 foreach( $notices as $slug => $info ) {
2084 2637 // Only show notices for expired legacy licenses
@@ -2084,11 +2637,12 @@
2084 2637 // Only show notices for expired legacy licenses
2085 2638 if( empty( $info['status'] ) || $info['status'] !== 'expired' ) continue;
2086 2639
2087 2640 // Verify the addon is still installed
2088 - if( ! is_dir( WP_PLUGIN_DIR . '/' . $slug ) ) continue;
2641 + if( ! $this->is_addon_present( $slug ) ) continue;
2089 2642
2090 2643 if( get_transient( 'gvectors_legacy_dismissed_' . $slug ) ) continue;
2644 + if( ! self::claim_notice( 'legacy', $slug ) ) continue;
2091 2645
2092 2646 $plugin_name = $info['plugin_name'] ?? $slug;
2093 2647
2094 2648 $dismiss_url = wp_nonce_url(