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 +276 -62 3.2.1 → 3.2.2 View file →
@@ -132,13 +132,21 @@
132 132 if( $plugin_file ) {
133 133 // Verify here so a bad manual upload gets a clear JSON error instead of the activation gate's wp_die()
134 134 $sig_result = $this->verify_addon_signatures( $plugin_slug );
135 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 +
136 144 return [
137 145 'success' => false,
138 146 'error' => self::signature_failure_reason( $sig_result ) . ' ' . sprintf(
139 147 /* translators: %s: addon folder name */
140 - __( 'Please delete the "%s" folder from wp-content/plugins and upload the folder again from the ZIP you downloaded (including the hidden .addon-signatures.json file, using binary transfer mode).', 'gvectors' ),
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' ),
141 149 $plugin_slug
142 150 ),
143 151 'manual_install' => true,
144 152 ];
@@ -195,8 +203,22 @@
195 203 return $can_install = defined( 'FTP_HOST' ) && defined( 'FTP_USER' ) && ( defined( 'FTP_PASS' ) || defined( 'FTP_PRIKEY' ) );
196 204 }
197 205
198 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 + /**
199 221 * Signed, one-time addon ZIP URL for the admin's browser — the manual install path (FTP upload)
200 222 * for sites where WordPress can't write plugins. Needs an active license only, not install_plugins,
201 223 * and is allowed for tampered addons too: a clean copy is how those get fixed.
202 224 */
@@ -261,9 +283,9 @@
261 283 if( $plugin_slug && $this->is_addon_tampered( $plugin_slug ) ) {
262 284 return [
263 285 'success' => false,
264 286 'error' => __(
265 - 'This addon cannot be updated because its files have been modified or are not original. To resolve this, please: 1) Go to Plugins and deactivate, then delete this addon. 2) Visit the gVectors Store Addons page and make sure your license is active. 3) Re-install the addon from the gVectors Store Addons page. Once re-installed, everything will work normally again.',
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.',
266 288 'gvectors'
267 289 ),
268 290 ];
269 291 }
@@ -291,22 +313,24 @@
291 313 } else {
292 314 $result = $upgrader->install( $download_url );
293 315 }
294 316
295 - // Upgrader failures are mostly filesystem problems — offer the manual (ZIP + FTP) install path
317 + // Only filesystem/permission failures offer the manual (ZIP + FTP) install path; others (download, archive...) just report the error
296 318 if( is_wp_error( $result ) ) {
297 319 error_log( '[gVectors Addon] WP_Error from upgrader: ' . $result->get_error_message() );
298 320
299 - return [ 'success' => false, 'error' => $result->get_error_message(), 'manual_install' => true ];
321 + return [ 'success' => false, 'error' => $result->get_error_message(), 'manual_install' => self::is_filesystem_error( $result ) ];
300 322 }
301 323
302 324 if( $result === false ) {
303 325 $errors = $skin->get_errors();
304 - $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' );
305 328 $skin_feedback = method_exists( $skin, 'get_upgrade_messages' ) ? $skin->get_upgrade_messages() : [];
306 329 error_log( '[gVectors Addon] Install result=false. Error: ' . $error . ' | Feedback: ' . print_r( $skin_feedback, true ) );
307 330
308 - return [ 'success' => false, 'error' => $error, 'manual_install' => true ];
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 ) ];
309 333 }
310 334
311 335 error_log( '[gVectors Addon] Install result: ' . print_r( $result, true ) );
312 336 error_log( '[gVectors Addon] Skin messages: ' . print_r( $skin->get_upgrade_messages(), true ) );
@@ -357,8 +381,95 @@
357 381 ];
358 382 }
359 383
360 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 + /**
361 472 * Check if an addon is flagged as tampered/unauthorized
362 473 */
363 474 public function is_addon_tampered( string $plugin_slug ): bool {
364 475 $tampered = get_option( $this->tampered_option, [] );
@@ -592,13 +703,21 @@
592 703 if( $patch_check !== 'valid' ) {
593 704 return $patch_check;
594 705 }
595 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 +
596 715 // Check if this addon has a legacy license from the old gVectors system
597 716 $legacy = $this->check_legacy_license( $plugin_slug );
598 717 if( ! empty( $legacy['has_license'] ) ) {
599 718 // Legacy licensed addon — clear any previous tamper flags
600 - $this->clear_tamper_flag( $plugin_slug );
719 + $this->clear_tamper_flag( $plugin_slug, true );
601 720 // Track expired legacy licenses for admin notice
602 721 $this->update_legacy_notice( $plugin_slug, $legacy );
603 722
604 723 return 'legacy_valid';
@@ -603,8 +722,11 @@
603 722
604 723 return 'legacy_valid';
605 724 }
606 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 +
607 729 // No legacy license either — this is an unauthorized copy
608 730 $this->mark_addon_tampered( $plugin_slug, [ 'Missing signature manifest' ], 'no_manifest' );
609 731
610 732 return 'no_manifest';
@@ -736,9 +858,9 @@
736 858 return $patch_check;
737 859 }
738 860
739 861 // All checks passed - clear any previous tamper flags
740 - $this->clear_tamper_flag( $plugin_slug );
862 + $this->clear_tamper_flag( $plugin_slug, true );
741 863
742 864 return 'valid';
743 865 }
744 866
@@ -854,36 +976,58 @@
854 976
855 977 $response = $this->licenseService->apiService->check_legacy_license( $plugin_slug );
856 978
857 979 if( ! empty( $response['success'] ) && ! empty( $response['data'] ) ) {
858 - $data = $response['data'];
859 - $legacy_data = [
860 - 'has_license' => ! empty( $data['has_legacy_license'] ),
861 - 'status' => $data['status'] ?? '',
862 - 'expired' => ! empty( $data['expired'] ),
863 - 'expired_time' => isset( $data['expired_time'] ) ? (int) $data['expired_time'] : 0,
864 - 'last_checked' => time(),
865 - ];
866 - $this->save_cached_legacy_license( $plugin_slug, $legacy_data );
867 -
868 - return $legacy_data;
980 + return $this->save_cached_legacy_license( $plugin_slug, self::legacy_result_from_api( $response['data'] ) );
869 981 }
870 982
871 - // API call failed — cache a negative result with a shorter TTL (1 hour)
872 - // so we retry sooner, but don't hammer the server on every cron run
873 - $negative = [
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 ] ?? [
874 987 'has_license' => false,
988 + 'unknown' => true,
875 989 'status' => '',
876 990 'expired' => false,
877 991 'expired_time' => 0,
878 - 'last_checked' => time() - $this->config->get_legacy_check_period() + HOUR_IN_SECONDS,
879 992 ];
880 - $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 );
881 996
882 - return $negative;
997 + return $known;
883 998 }
884 999
885 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 + /**
886 1030 * Get cached legacy license data for a slug.
887 1031 * Returns the cached array or false if not cached or stale.
888 1032 */
889 1033 private function get_cached_legacy_license( string $plugin_slug ) {
@@ -903,14 +1047,16 @@
903 1047 // Activation Gate
904 1048 // ==========================================
905 1049
906 1050 /**
907 - * 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.
908 1052 */
909 - 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 {
910 1054 $all_legacy = get_option( $this->legacy_licenses_option, [] );
911 - $all_legacy[ $plugin_slug ] = $data;
1055 + $all_legacy[ $plugin_slug ] = self::merge_legacy_result( $all_legacy[ $plugin_slug ] ?? [], $data );
912 1056 update_option( $this->legacy_licenses_option, $all_legacy );
1057 +
1058 + return $all_legacy[ $plugin_slug ];
913 1059 }
914 1060
915 1061 // ==========================================
916 1062 // Signature & Piracy Verification
@@ -917,14 +1063,19 @@
917 1063 // ==========================================
918 1064
919 1065 /**
920 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)
921 1070 */
922 - private function clear_tamper_flag( string $plugin_slug ): void {
1071 + private function clear_tamper_flag( string $plugin_slug, bool $restore = false ): void {
923 1072 $tampered = get_option( $this->tampered_option, [] );
924 1073 if( isset( $tampered[ $plugin_slug ] ) ) {
1074 + $deactivated = ! empty( $tampered[ $plugin_slug ]['deactivated_at'] );
925 1075 unset( $tampered[ $plugin_slug ] );
926 1076 update_option( $this->tampered_option, $tampered );
1077 + if( $restore && $deactivated ) $this->reactivate_addon( $plugin_slug );
927 1078 }
928 1079
929 1080 // Also clear the seen flag
930 1081 $seen = get_option( $this->tamper_dismissed_option, [] );
@@ -934,8 +1085,29 @@
934 1085 }
935 1086 }
936 1087
937 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 + /**
938 1110 * Track legacy-licensed addons that have expired licenses for admin notice.
939 1111 */
940 1112 private function update_legacy_notice( string $plugin_slug, array $legacy_data ): void {
941 1113 $notices = get_option( $this->legacy_notice_option, [] );
@@ -1385,9 +1557,10 @@
1385 1557 }
1386 1558
1387 1559 /**
1388 1560 * Batch-check legacy licenses for multiple addon slugs.
1389 - * 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.
1390 1563 */
1391 1564 private function check_legacy_licenses_batch( array $plugin_slugs ): void {
1392 1565 if( empty( $plugin_slugs ) ) return;
1393 1566
@@ -1392,30 +1565,13 @@
1392 1565 if( empty( $plugin_slugs ) ) return;
1393 1566
1394 1567 $response = $this->licenseService->apiService->check_legacy_licenses_batch( $plugin_slugs );
1395 1568
1396 - if( ! empty( $response['success'] ) && ! empty( $response['data']['addons'] ) ) {
1569 + if( ! empty( $response['success'] ) && ! empty( $response['data']['addons'] ) && is_array( $response['data']['addons'] ) ) {
1397 1570 $all_legacy = get_option( $this->legacy_licenses_option, [] );
1398 - foreach( $response['data']['addons'] as $slug => $data ) {
1399 - $all_legacy[ $slug ] = [
1400 - 'has_license' => ! empty( $data['has_legacy_license'] ),
1401 - 'status' => $data['status'] ?? '',
1402 - 'expired' => ! empty( $data['expired'] ),
1403 - 'expired_time' => isset( $data['expired_time'] ) ? (int) $data['expired_time'] : 0,
1404 - 'last_checked' => time(),
1405 - ];
1406 - }
1407 - // Also cache negative results for slugs not returned by the server
1408 1571 foreach( $plugin_slugs as $slug ) {
1409 - if( ! isset( $all_legacy[ $slug ] ) || $all_legacy[ $slug ]['last_checked'] < time() - 60 ) {
1410 - $all_legacy[ $slug ] = [
1411 - 'has_license' => false,
1412 - 'status' => '',
1413 - 'expired' => false,
1414 - 'expired_time' => 0,
1415 - 'last_checked' => time(),
1416 - ];
1417 - }
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 : [] ) );
1418 1574 }
1419 1575 update_option( $this->legacy_licenses_option, $all_legacy );
1420 1576 }
1421 1577 }
@@ -1484,9 +1640,9 @@
1484 1640 if( $plugin_slug && $this->is_addon_tampered( $plugin_slug ) ) {
1485 1641 return new WP_Error(
1486 1642 'tampered_addon',
1487 1643 __(
1488 - 'This addon cannot be updated because its files have been modified or are not original. To resolve this, please: 1) Go to Plugins and deactivate, then delete this addon. 2) Visit the gVectors Store Addons page and make sure your license is active. 3) Re-install the addon from the gVectors Store Addons page. Once re-installed, everything will work normally again.',
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.',
1489 1645 'gvectors'
1490 1646 )
1491 1647 );
1492 1648 }
@@ -1576,9 +1732,9 @@
1576 1732 * Human-readable reason for a failed verify_addon_signatures() result
1577 1733 */
1578 1734 private static function signature_failure_reason( string $sig_result ): string {
1579 1735 $labels = [
1580 - 'no_manifest' => __( 'Missing signature manifest — addon was not installed through the official channel.', 'gvectors' ),
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' ),
1581 1737 'tampered' => __( 'File integrity check failed — one or more addon files have been modified.', 'gvectors' ),
1582 1738 'domain_mismatch' => __( 'Domain mismatch — this addon copy is signed for a different website.', 'gvectors' ),
1583 1739 'no_signatures' => __( 'Missing PHP header signatures — addon files lack required security headers.', 'gvectors' ),
1584 1740 'patched' => __( 'Nulled/patched code detected — this addon appears to be a pirated copy.', 'gvectors' ),
@@ -1614,15 +1770,27 @@
1614 1770 return false;
1615 1771 }
1616 1772
1617 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 + /**
1618 1785 * Quick check: does this addon have a legacy license (from cache)?
1619 - * 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).
1620 1788 */
1621 1789 private function has_legacy_license( string $plugin_slug ): bool {
1622 1790 $legacy = $this->check_legacy_license( $plugin_slug );
1623 1791
1624 - return ! empty( $legacy['has_license'] );
1792 + return ! empty( $legacy['has_license'] ) || ! empty( $legacy['unknown'] );
1625 1793 }
1626 1794
1627 1795 /**
1628 1796 * Verify all installed addons — uses the proxy's full addon list (not just local licenses).
@@ -1674,8 +1842,27 @@
1674 1842 }
1675 1843 }
1676 1844
1677 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 + /**
1678 1865 * Scan for installed plugins that are known gVectors addons (from the proxy list) but have no license.
1679 1866 * These could be pirated copies installed manually, OR legacy-licensed installations.
1680 1867 * Checks legacy license before flagging as tampered.
1681 1868 */
@@ -1719,9 +1906,9 @@
1719 1906 foreach( $needs_legacy_check as $slug ) {
1720 1907 $legacy = $this->get_cached_legacy_license( $slug );
1721 1908 if( $legacy !== false && ! empty( $legacy['has_license'] ) ) {
1722 1909 // Legacy licensed — not piracy. Track for admin notice if expired.
1723 - $this->clear_tamper_flag( $slug );
1910 + $this->clear_tamper_flag( $slug, true );
1724 1911 $this->update_legacy_notice( $slug, $legacy );
1725 1912
1726 1913 // Proactively migrate active legacy licenses to the new system.
1727 1914 // Once migrated, the slug enters $licensed_slugs and exits this scan
@@ -1732,8 +1919,11 @@
1732 1919
1733 1920 continue;
1734 1921 }
1735 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 +
1736 1926 // No legacy license — suspicious, flag as tampered
1737 1927 $this->mark_addon_tampered( $slug, [
1738 1928 'Active gVectors addon without a valid license or signature manifest',
1739 1929 ], 'no_manifest' );
@@ -2193,11 +2383,13 @@
2193 2383 $reason = $info['reason'] ?? 'tampered';
2194 2384 $detected = $info['detected_at'] ?? '';
2195 2385 $deactivated = $info['deactivated_at'] ?? '';
2196 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';
2197 2389 $reason_labels = [
2198 2390 'tampered' => __( 'File integrity check failed — files have been modified.', 'gvectors' ),
2199 - 'no_manifest' => __( 'Missing signature manifest — this copy was not obtained through an authorized license.', 'gvectors' ),
2391 + 'no_manifest' => __( 'We couldn\'t find a license for this addon on this domain.', 'gvectors' ),
2200 2392 'domain_mismatch' => __( 'Domain signature mismatch — this addon was licensed for a different website.', 'gvectors' ),
2201 2393 'no_signatures' => __( 'Missing file header signatures — files have been stripped of authorization data.', 'gvectors' ),
2202 2394 'patched' => __( 'Suspicious code patterns detected — this appears to be a nulled or patched version.', 'gvectors' ),
2203 2395 ];
@@ -2239,10 +2431,33 @@
2239 2431 add_query_arg( [ 'gvectors_dismiss_addon_notice' => 'tampered', 'gvectors_notice_slug' => $slug ] ),
2240 2432 'gvectors_dismiss_tampered_' . $slug
2241 2433 );
2242 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 + }
2243 2458 printf(
2244 - '<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">'
2245 2460 . '<p><strong style="font-size:14px;">⚠️ %s</strong> %s</p>'
2246 2461 . '<p>%s</p>'
2247 2462 . '<p>%s</p>'
2248 2463 . '<p>%s</p>'
@@ -2247,16 +2462,15 @@
2247 2462 . '<p>%s</p>'
2248 2463 . '<p>%s</p>'
2249 2464 . '<p><a href="%s">%s</a></p>'
2250 2465 . '</div>',
2251 - esc_html__( 'gVectors Security Alert — Unauthorized Addon Detected', 'gvectors' ),
2466 + $unlicensed ? 'notice-warning' : 'notice-error',
2467 + $unlicensed ? '' : 'border-left-color:#dc3232;',
2468 + $title,
2252 2469 '<code>' . esc_html( $slug ) . '</code>',
2253 2470 esc_html( $reason_text ),
2254 2471 $status_text,
2255 - sprintf(
2256 - esc_html__( 'Please purchase a valid license at %s or remove the unauthorized addon.', 'gvectors' ),
2257 - '<a href="' . admin_url( $this->config->get_dashboard_addons_store_url() ) . '">Addons Store</a>'
2258 - ),
2472 + $action_text,
2259 2473 esc_url( $dismiss_url ),
2260 2474 esc_html__( 'Dismiss for 5 days', 'gvectors' )
2261 2475 );
2262 2476 }