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