← 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 | } |