← All changes
|
admin/pages/license/src/Services/AddonsService.php
+437
-97
3.1.7
→
3.2.1
View file →
| @@ -29,8 +29,9 @@ | ||
| 29 | 29 | * only offers updates for licenses that are active/trial AND activated for this domain. |
| 30 | 30 | * Expired licenses are NOT offered updates (addon keeps working but no new versions). |
| 31 | 31 | */ |
| 32 | 32 | private $update_check_done = false; |
| 33 | + private $pruned = false; | |
| 33 | 34 | private $all_addons_transient_name; |
| 34 | 35 | private $signature_check_hook; |
| 35 | 36 | private $license_check_hook; |
| 36 | 37 | private $tampered_option; |
| @@ -37,8 +38,11 @@ | ||
| 37 | 38 | private $expired_notice_option; |
| 38 | 39 | private $tamper_dismissed_option; |
| 39 | 40 | private $legacy_licenses_option; |
| 40 | 41 | private $legacy_notice_option; |
| 42 | + /** Last successfully fetched store addon list (slug => parent host slugs) — used while the store server is unreachable */ | |
| 43 | + private $store_addons_option; | |
| 44 | + private $store_addons = null; | |
| 41 | 45 | /** Shared transient (not slug-prefixed) so one dismissing covers all plugin instances */ |
| 42 | 46 | private static $shared_dev_env_transient = 'gvectors_dev_env_notice_dismissed'; |
| 43 | 47 | private static $shared_dev_licenses_transient = 'gvectors_dev_licenses_notice_dismissed'; |
| 44 | 48 | |
| @@ -45,8 +49,13 @@ | ||
| 45 | 49 | /** Static collectors for cross-instance notice deduplication */ |
| 46 | 50 | private static $dev_env_notice_shown = false; |
| 47 | 51 | private static $dev_licenses_collected = []; |
| 48 | 52 | private static $dev_licenses_registered = false; |
| 53 | + /** Per-request "already rendered" markers so several host plugins never duplicate addon notices/rows */ | |
| 54 | + private static $notices_shown = []; | |
| 55 | + /** Shared (not slug-prefixed) queue + single-event hook for "updates can't be installed" notices — one email for all hosts */ | |
| 56 | + private const BLOCKED_UPDATES_OPTION = 'gvectors_blocked_updates_queue'; | |
| 57 | + private const BLOCKED_UPDATES_HOOK = 'gvectors_blocked_updates_notify'; | |
| 49 | 58 | |
| 50 | 59 | public function __construct( Config $config, LicenseService $licenseService ) { |
| 51 | 60 | $this->config = $config; |
| 52 | 61 | $this->licenseService = $licenseService; |
| @@ -57,8 +66,9 @@ | ||
| 57 | 66 | $this->expired_notice_option = $this->licenseService->expired_notice_option; |
| 58 | 67 | $this->tamper_dismissed_option = $this->config->get_core_plugin_slug() . '_gvectors_tamper_notice_seen'; |
| 59 | 68 | $this->legacy_licenses_option = $this->config->get_core_plugin_slug() . '_gvectors_legacy_addon_licenses'; |
| 60 | 69 | $this->legacy_notice_option = $this->config->get_core_plugin_slug() . '_gvectors_legacy_license_notices'; |
| 70 | + $this->store_addons_option = $this->config->get_core_plugin_slug() . '_gvectors_store_addons'; | |
| 61 | 71 | $this->init_hooks(); |
| 62 | 72 | } |
| 63 | 73 | |
| 64 | 74 | private function init_hooks() { |
| @@ -103,14 +113,41 @@ | ||
| 103 | 113 | add_filter( 'upgrader_pre_download', [ $this, 'block_tampered_update_download' ], 10, 2 ); |
| 104 | 114 | |
| 105 | 115 | // Clear tamper flag when a plugin is deleted |
| 106 | 116 | add_action( 'deleted_plugin', [ $this, 'on_plugin_deleted' ], 10, 2 ); |
| 117 | + | |
| 118 | + // Entitled updates the site can't install → email the admins (news module), from cron | |
| 119 | + add_action( self::BLOCKED_UPDATES_HOOK, [ self::class, 'notify_blocked_updates' ] ); | |
| 107 | 120 | } |
| 108 | 121 | |
| 109 | 122 | /** |
| 110 | - * Install and activate an addon in one step | |
| 123 | + * Install and activate an addon in one step. | |
| 124 | + * An addon already on disk (installed but inactive, or uploaded manually via FTP) is only activated — | |
| 125 | + * re-running the installer would attempt an update, which fails when none is pending or WordPress can't write plugins. | |
| 111 | 126 | */ |
| 112 | 127 | public function install_and_activate( string $product_id ): array { |
| 128 | + $license = $this->licenseService->get( $product_id ); | |
| 129 | + $plugin_slug = self::sanitize_slug( $license['plugin_slug'] ?? '' ); | |
| 130 | + $plugin_file = ! empty( $license['license_key'] ) ? $this->get_installed_plugin_file( $plugin_slug ) : ''; | |
| 131 | + | |
| 132 | + if( $plugin_file ) { | |
| 133 | + // Verify here so a bad manual upload gets a clear JSON error instead of the activation gate's wp_die() | |
| 134 | + $sig_result = $this->verify_addon_signatures( $plugin_slug ); | |
| 135 | + if( ! in_array( $sig_result, [ 'valid', 'legacy_valid' ], true ) ) { | |
| 136 | + return [ | |
| 137 | + 'success' => false, | |
| 138 | + 'error' => self::signature_failure_reason( $sig_result ) . ' ' . sprintf( | |
| 139 | + /* 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' ), | |
| 141 | + $plugin_slug | |
| 142 | + ), | |
| 143 | + 'manual_install' => true, | |
| 144 | + ]; | |
| 145 | + } | |
| 146 | + | |
| 147 | + return $this->activate( $plugin_file ); | |
| 148 | + } | |
| 149 | + | |
| 113 | 150 | $install_result = $this->install( $product_id ); |
| 114 | 151 | if( empty( $install_result['success'] ) ) return $install_result; |
| 115 | 152 | |
| 116 | 153 | $plugin_file = $install_result['plugin_file']; |
| @@ -130,13 +167,89 @@ | ||
| 130 | 167 | ]; |
| 131 | 168 | } |
| 132 | 169 | |
| 133 | 170 | /** |
| 171 | + * Whether WordPress can install addons on this site from an AJAX request. | |
| 172 | + * False when file modifications are disabled (DISALLOW_FILE_MODS strips install_plugins), or when the | |
| 173 | + * plugins folder isn't directly writable and no FTP/SSH credentials are predefined (AJAX can't prompt for them). | |
| 174 | + * Such sites get the addon ZIP for a manual upload instead. | |
| 175 | + */ | |
| 176 | + public function can_install_addons(): bool { | |
| 177 | + return current_user_can( 'install_plugins' ) && self::site_can_install_plugins(); | |
| 178 | + } | |
| 179 | + | |
| 180 | + /** | |
| 181 | + * Site-level half of can_install_addons(), without any user context (safe in cron): file | |
| 182 | + * modifications allowed (DISALLOW_FILE_MODS / the file_mod_allowed filter) and a filesystem | |
| 183 | + * WordPress can write plugins to — direct access for the context the upgrader's fs_connect() | |
| 184 | + * uses plus a writable plugins folder, or predefined FTP/SSH credentials. Static per request. | |
| 185 | + */ | |
| 186 | + public static function site_can_install_plugins(): bool { | |
| 187 | + static $can_install = null; | |
| 188 | + if( $can_install !== null ) return $can_install; | |
| 189 | + | |
| 190 | + if( ! wp_is_file_mod_allowed( 'gvectors_addon_install' ) ) return $can_install = false; | |
| 191 | + | |
| 192 | + require_once ABSPATH . 'wp-admin/includes/file.php'; | |
| 193 | + if( get_filesystem_method( [], WP_CONTENT_DIR ) === 'direct' ) return $can_install = wp_is_writable( WP_PLUGIN_DIR ); | |
| 194 | + | |
| 195 | + return $can_install = defined( 'FTP_HOST' ) && defined( 'FTP_USER' ) && ( defined( 'FTP_PASS' ) || defined( 'FTP_PRIKEY' ) ); | |
| 196 | + } | |
| 197 | + | |
| 198 | + /** | |
| 199 | + * Signed, one-time addon ZIP URL for the admin's browser — the manual install path (FTP upload) | |
| 200 | + * for sites where WordPress can't write plugins. Needs an active license only, not install_plugins, | |
| 201 | + * and is allowed for tampered addons too: a clean copy is how those get fixed. | |
| 202 | + */ | |
| 203 | + public function get_download_link( string $product_id ): array { | |
| 204 | + if( ! $this->licenseService->is_active( $product_id ) ) { | |
| 205 | + return [ 'success' => false, 'error' => __( 'No active license for this product', 'gvectors' ) ]; | |
| 206 | + } | |
| 207 | + | |
| 208 | + $download = $this->request_download( $product_id ); | |
| 209 | + if( empty( $download['success'] ) ) return $download; | |
| 210 | + | |
| 211 | + $download['file_name'] = ( $download['plugin_slug'] ?: 'addon' ) . '.zip'; | |
| 212 | + | |
| 213 | + return $download; | |
| 214 | + } | |
| 215 | + | |
| 216 | + /** | |
| 217 | + * Request a signed, one-time download URL for a licensed addon from the proxy server | |
| 218 | + */ | |
| 219 | + private function request_download( string $product_id ): array { | |
| 220 | + $license = $this->licenseService->get( $product_id ); | |
| 221 | + if( empty( $license ) || empty( $license['license_key'] ) ) { | |
| 222 | + return [ 'success' => false, 'error' => __( 'No active license for this product', 'gvectors' ) ]; | |
| 223 | + } | |
| 224 | + | |
| 225 | + $response = $this->licenseService->apiService->get_addon_download_url( $product_id, $license['license_key'] ); | |
| 226 | + error_log( '[gVectors Addon] download-url response: ' . print_r( $response, true ) ); | |
| 227 | + if( empty( $response['success'] ) || empty( $response['data']['download_url'] ) ) { | |
| 228 | + $error = $response['error'] ?? __( 'Failed to get download URL', 'gvectors' ); | |
| 229 | + if( isset( $response['data']['error'] ) ) $error = $response['data']['error']; | |
| 230 | + error_log( '[gVectors Addon] Failed to get download URL: ' . $error ); | |
| 231 | + | |
| 232 | + return [ 'success' => false, 'error' => $error ]; | |
| 233 | + } | |
| 234 | + | |
| 235 | + $download_url = add_query_arg( 'site_domain', rawurlencode( LicenseModule::get_site_domain() ), $response['data']['download_url'] ); | |
| 236 | + $plugin_slug = self::sanitize_slug( $response['data']['plugin_slug'] ?? '' ); | |
| 237 | + error_log( '[gVectors Addon] download_url: ' . $download_url . ' | plugin_slug: ' . $plugin_slug ); | |
| 238 | + | |
| 239 | + return [ 'success' => true, 'download_url' => $download_url, 'plugin_slug' => $plugin_slug ]; | |
| 240 | + } | |
| 241 | + | |
| 242 | + /** | |
| 134 | 243 | * Download and install an addon from the proxy server |
| 135 | 244 | */ |
| 136 | 245 | public function install( string $product_id ): array { |
| 137 | - if( ! current_user_can( 'install_plugins' ) ) { | |
| 138 | - return [ 'success' => false, 'error' => __( 'Permission denied', 'gvectors' ) ]; | |
| 246 | + if( ! $this->can_install_addons() ) { | |
| 247 | + return [ | |
| 248 | + 'success' => false, | |
| 249 | + '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' ), | |
| 250 | + 'manual_install' => true, | |
| 251 | + ]; | |
| 139 | 252 | } |
| 140 | 253 | |
| 141 | 254 | $license = $this->licenseService->get( $product_id ); |
| 142 | 255 | if( empty( $license ) || empty( $license['license_key'] ) ) { |
| @@ -155,22 +268,13 @@ | ||
| 155 | 268 | ]; |
| 156 | 269 | } |
| 157 | 270 | |
| 158 | 271 | // Get signed download URL from proxy |
| 159 | - $response = $this->licenseService->apiService->get_addon_download_url( $product_id, $license['license_key'] ); | |
| 160 | - error_log( '[gVectors Addon] download-url response: ' . print_r( $response, true ) ); | |
| 161 | - if( empty( $response['success'] ) || empty( $response['data']['download_url'] ) ) { | |
| 162 | - $error = $response['error'] ?? __( 'Failed to get download URL', 'gvectors' ); | |
| 163 | - if( isset( $response['data']['error'] ) ) $error = $response['data']['error']; | |
| 164 | - error_log( '[gVectors Addon] Failed to get download URL: ' . $error ); | |
| 165 | - | |
| 166 | - return [ 'success' => false, 'error' => $error ]; | |
| 167 | - } | |
| 272 | + $download = $this->request_download( $product_id ); | |
| 273 | + if( empty( $download['success'] ) ) return $download; | |
| 168 | 274 | |
| 169 | - $download_url = $response['data']['download_url']; | |
| 170 | - $download_url = add_query_arg( 'site_domain', rawurlencode( LicenseModule::get_site_domain() ), $download_url ); | |
| 171 | - $plugin_slug = $response['data']['plugin_slug'] ?? ''; | |
| 172 | - error_log( '[gVectors Addon] download_url: ' . $download_url . ' | plugin_slug: ' . $plugin_slug ); | |
| 275 | + $download_url = $download['download_url']; | |
| 276 | + $plugin_slug = $download['plugin_slug']; | |
| 173 | 277 | |
| 174 | 278 | // Use WordPress built-in plugin installer |
| 175 | 279 | require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; |
| 176 | 280 | require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; |
| @@ -187,12 +291,13 @@ | ||
| 187 | 291 | } else { |
| 188 | 292 | $result = $upgrader->install( $download_url ); |
| 189 | 293 | } |
| 190 | 294 | |
| 295 | + // Upgrader failures are mostly filesystem problems — offer the manual (ZIP + FTP) install path | |
| 191 | 296 | if( is_wp_error( $result ) ) { |
| 192 | 297 | error_log( '[gVectors Addon] WP_Error from upgrader: ' . $result->get_error_message() ); |
| 193 | 298 | |
| 194 | - return [ 'success' => false, 'error' => $result->get_error_message() ]; | |
| 299 | + return [ 'success' => false, 'error' => $result->get_error_message(), 'manual_install' => true ]; | |
| 195 | 300 | } |
| 196 | 301 | |
| 197 | 302 | if( $result === false ) { |
| 198 | 303 | $errors = $skin->get_errors(); |
| @@ -199,9 +304,9 @@ | ||
| 199 | 304 | $error = is_wp_error( $errors ) ? $errors->get_error_message() : __( 'Installation failed', 'gvectors' ); |
| 200 | 305 | $skin_feedback = method_exists( $skin, 'get_upgrade_messages' ) ? $skin->get_upgrade_messages() : []; |
| 201 | 306 | error_log( '[gVectors Addon] Install result=false. Error: ' . $error . ' | Feedback: ' . print_r( $skin_feedback, true ) ); |
| 202 | 307 | |
| 203 | - return [ 'success' => false, 'error' => $error ]; | |
| 308 | + return [ 'success' => false, 'error' => $error, 'manual_install' => true ]; | |
| 204 | 309 | } |
| 205 | 310 | |
| 206 | 311 | error_log( '[gVectors Addon] Install result: ' . print_r( $result, true ) ); |
| 207 | 312 | error_log( '[gVectors Addon] Skin messages: ' . print_r( $skin->get_upgrade_messages(), true ) ); |
| @@ -333,23 +438,138 @@ | ||
| 333 | 438 | |
| 334 | 439 | /** |
| 335 | 440 | * Fetch all addon info from the proxy server, keyed by slug. |
| 336 | 441 | * Returns associative array: slug => [ name, version, description, author, requires, tested, requires_php, plugin_uri, ... ] |
| 442 | + * Host plugins (wpForo, wpDiscuz, ...) are never part of the map — they update from wordpress.org. | |
| 337 | 443 | */ |
| 338 | 444 | private function get_proxy_addons_map(): array { |
| 339 | 445 | $response = $this->licenseService->apiService->get_all_addons(); |
| 340 | - if( empty( $response['success'] ) || empty( $response['data']['addons'] ) ) { | |
| 446 | + if( empty( $response['success'] ) || empty( $response['data']['addons'] ) || ! is_array( $response['data']['addons'] ) ) { | |
| 341 | 447 | return []; |
| 342 | 448 | } |
| 343 | 449 | $map = []; |
| 344 | 450 | foreach( $response['data']['addons'] as $addon ) { |
| 345 | - if( ! empty( $addon['slug'] ) ) { | |
| 451 | + if( ! empty( $addon['slug'] ) && is_string( $addon['slug'] ) && ! $this->is_host_plugin( $addon['slug'] ) ) { | |
| 346 | 452 | $map[ $addon['slug'] ] = $addon; |
| 347 | 453 | } |
| 348 | 454 | } |
| 349 | - | |
| 455 | + $this->remember_store_addons( $map ); | |
| 456 | + | |
| 350 | 457 | return $map; |
| 351 | 458 | } |
| 459 | + | |
| 460 | + /** | |
| 461 | + * All addons sold in the gVectors store: slug => host plugin slugs the addon belongs to | |
| 462 | + * (from the products' Paddle `parent_slug`; [] = belongs to every host, e.g. wpForo AND wpDiscuz). | |
| 463 | + * This list is the ONLY way an installed plugin is recognized as one of our addons — plugin/folder | |
| 464 | + * names are never used. Falls back to the last successfully fetched list while the store is unreachable. | |
| 465 | + * | |
| 466 | + * @param bool $allow_remote false = never make an HTTP request (for page-load paths like admin_init) | |
| 467 | + */ | |
| 468 | + private function get_store_addons( bool $allow_remote = true ): array { | |
| 469 | + if( $this->store_addons !== null ) return $this->store_addons; | |
| 470 | + | |
| 471 | + if( $allow_remote ) { | |
| 472 | + $map = $this->get_proxy_addons_map(); | |
| 473 | + if( ! empty( $map ) ) return $this->store_addons = self::extract_parent_slugs( $map ); | |
| 474 | + } | |
| 475 | + | |
| 476 | + $known = get_option( $this->store_addons_option, [] ); | |
| 477 | + if( ! is_array( $known ) ) return []; | |
| 478 | + | |
| 479 | + $addons = []; | |
| 480 | + foreach( $known as $slug => $parents ) { | |
| 481 | + if( is_string( $slug ) && $slug !== '' && ! $this->is_host_plugin( $slug ) ) { | |
| 482 | + $addons[ $slug ] = is_array( $parents ) ? $parents : []; | |
| 483 | + } | |
| 484 | + } | |
| 485 | + | |
| 486 | + return $addons; | |
| 487 | + } | |
| 488 | + | |
| 489 | + /** | |
| 490 | + * slug => sanitized host plugin slugs from the store's `parent_slugs` ([] or missing = all hosts). | |
| 491 | + */ | |
| 492 | + private static function extract_parent_slugs( array $proxy_addons ): array { | |
| 493 | + $addons = []; | |
| 494 | + foreach( $proxy_addons as $slug => $addon ) { | |
| 495 | + $parents = isset( $addon['parent_slugs'] ) && is_array( $addon['parent_slugs'] ) ? $addon['parent_slugs'] : []; | |
| 496 | + $parents = array_values( array_unique( array_filter( $parents, function( $parent ) { | |
| 497 | + return is_string( $parent ) && $parent !== ''; | |
| 498 | + } ) ) ); | |
| 499 | + sort( $parents ); | |
| 500 | + $addons[ (string) $slug ] = $parents; | |
| 501 | + } | |
| 502 | + ksort( $addons ); | |
| 503 | + | |
| 504 | + return $addons; | |
| 505 | + } | |
| 506 | + | |
| 507 | + /** | |
| 508 | + * Persist the store addon list (not autoloaded) so addon checks keep working during store outages. | |
| 509 | + */ | |
| 510 | + private function remember_store_addons( array $proxy_addons ): void { | |
| 511 | + if( empty( $proxy_addons ) ) return; | |
| 512 | + $addons = self::extract_parent_slugs( $proxy_addons ); | |
| 513 | + if( get_option( $this->store_addons_option ) !== $addons ) { | |
| 514 | + update_option( $this->store_addons_option, $addons, false ); | |
| 515 | + } | |
| 516 | + } | |
| 517 | + | |
| 518 | + /** | |
| 519 | + * Does the addon belong to this host plugin? Products with an empty/missing Paddle `parent_slug` | |
| 520 | + * belong to every host; otherwise only to the listed host(s). | |
| 521 | + */ | |
| 522 | + private function addon_belongs_to_host( string $plugin_slug, bool $allow_remote = true ): bool { | |
| 523 | + $parents = $this->get_store_addons( $allow_remote )[ $plugin_slug ] ?? []; | |
| 524 | + | |
| 525 | + return empty( $parents ) || in_array( $this->config->get_core_plugin_slug(), $parents, true ); | |
| 526 | + } | |
| 527 | + | |
| 528 | + /** | |
| 529 | + * Should this host instance handle the addon (updates without own license, activation gate, | |
| 530 | + * integrity scan, notices)? Yes when this host holds a license for it; otherwise only when no | |
| 531 | + * other host holds a license and the addon belongs to this host (or to all hosts). | |
| 532 | + */ | |
| 533 | + private function manages_addon( string $plugin_slug, bool $allow_remote = true ): bool { | |
| 534 | + if( $this->addon_has_license( $plugin_slug ) ) return true; | |
| 535 | + if( $this->is_licensed_by_other_host( $plugin_slug ) ) return false; | |
| 536 | + | |
| 537 | + return $this->addon_belongs_to_host( $plugin_slug, $allow_remote ); | |
| 538 | + } | |
| 539 | + | |
| 540 | + /** | |
| 541 | + * Host plugins running this module (wpForo, wpDiscuz, ...) are distributed via wordpress.org. | |
| 542 | + * They must never be treated as store addons, so their core updates are never touched. | |
| 543 | + */ | |
| 544 | + private function is_host_plugin( string $plugin_slug ): bool { | |
| 545 | + return $plugin_slug === $this->config->get_core_plugin_slug() || in_array( $plugin_slug, LicenseModule::get_host_slugs(), true ); | |
| 546 | + } | |
| 547 | + | |
| 548 | + /** | |
| 549 | + * Does another host plugin on this site (e.g. wpDiscuz when this instance is wpForo) hold a license for the addon? | |
| 550 | + * That host's instance then owns the addon's updates, activation gate and integrity checks. | |
| 551 | + */ | |
| 552 | + private function is_licensed_by_other_host( string $plugin_slug ): bool { | |
| 553 | + foreach( LicenseModule::get_host_slugs() as $host ) { | |
| 554 | + if( $host === $this->config->get_core_plugin_slug() ) continue; | |
| 555 | + $actions = LicenseModule::getActionsService( $host ); | |
| 556 | + if( $actions && $actions->addonsService->addon_has_license( $plugin_slug ) ) return true; | |
| 557 | + } | |
| 558 | + | |
| 559 | + return false; | |
| 560 | + } | |
| 561 | + | |
| 562 | + /** | |
| 563 | + * Claim the right to render a per-addon notice/row once per request across all host plugin instances. | |
| 564 | + */ | |
| 565 | + private static function claim_notice( string $type, string $plugin_slug ): bool { | |
| 566 | + $key = $type . ':' . $plugin_slug; | |
| 567 | + if( isset( self::$notices_shown[ $key ] ) ) return false; | |
| 568 | + self::$notices_shown[ $key ] = true; | |
| 569 | + | |
| 570 | + return true; | |
| 571 | + } | |
| 352 | 572 | |
| 353 | 573 | /** |
| 354 | 574 | * Verify signatures of a single addon by its slug. |
| 355 | 575 | * Checks: manifest existence, file hashes, domain signature, PHP header signatures. |
| @@ -886,14 +1106,13 @@ | ||
| 886 | 1106 | continue; |
| 887 | 1107 | } |
| 888 | 1108 | |
| 889 | 1109 | $plugin_slug = $license['plugin_slug'] ?? ''; |
| 890 | - if( empty( $plugin_slug ) ) continue; | |
| 1110 | + if( empty( $plugin_slug ) || $this->is_host_plugin( $plugin_slug ) ) continue; | |
| 891 | 1111 | |
| 892 | 1112 | $plugin_file = $this->get_installed_plugin_file( $plugin_slug ); |
| 893 | 1113 | if( ! $plugin_file ) continue; |
| 894 | 1114 | |
| 895 | - | |
| 896 | 1115 | $current_version = $transient->checked[ $plugin_file ] ?? '0.0.0'; |
| 897 | 1116 | |
| 898 | 1117 | // Use proxy server version (from addon file header) instead of local options |
| 899 | 1118 | $proxy_info = $proxy_addons[ $plugin_slug ] ?? []; |
| @@ -945,8 +1164,11 @@ | ||
| 945 | 1164 | if( in_array( $plugin_file, $licensed_plugin_files, true ) ) continue; |
| 946 | 1165 | |
| 947 | 1166 | // Only process known gVectors addons from the proxy |
| 948 | 1167 | if( ! isset( $proxy_addons[ $plugin_slug ] ) ) continue; |
| 1168 | + | |
| 1169 | + // Licensed via / belongs to another host plugin — its instance builds this addon's update entry | |
| 1170 | + if( ! $this->manages_addon( $plugin_slug ) ) continue; | |
| 949 | 1171 | |
| 950 | 1172 | // Migrate legacy license to new system eagerly — even without a pending update. |
| 951 | 1173 | // On success, save() stores the license in gvectors_licenses so the Paddle loop |
| 952 | 1174 | // handles this slug on the next check_for_updates() call. |
| @@ -1005,10 +1227,11 @@ | ||
| 1005 | 1227 | $uncached_slugs = []; |
| 1006 | 1228 | foreach( $all_plugins as $_pf => $_pd ) { |
| 1007 | 1229 | if( in_array( $_pf, $licensed_plugin_files, true ) ) continue; |
| 1008 | 1230 | $_slug = dirname( $_pf ); |
| 1009 | - if( $_slug === '.' || $_slug === $this->config->get_core_plugin_slug() ) continue; | |
| 1010 | - if( ! isset( $proxy_addons[ $_slug ] ) ) continue; | |
| 1231 | + // Store addons only (host plugins are never in the proxy map) | |
| 1232 | + if( $_slug === '.' || ! isset( $proxy_addons[ $_slug ] ) ) continue; | |
| 1233 | + if( ! $this->manages_addon( $_slug ) ) continue; | |
| 1011 | 1234 | if( ! isset( $all_legacy[ $_slug ] ) ) $uncached_slugs[] = $_slug; |
| 1012 | 1235 | } |
| 1013 | 1236 | if( ! empty( $uncached_slugs ) ) { |
| 1014 | 1237 | $this->check_legacy_licenses_batch( array_unique( $uncached_slugs ) ); |
| @@ -1062,12 +1285,14 @@ | ||
| 1062 | 1285 | // Skip if already handled by licensed update above |
| 1063 | 1286 | if( in_array( $plugin_file, $licensed_plugin_files, true ) ) continue; |
| 1064 | 1287 | |
| 1065 | 1288 | $slug = dirname( $plugin_file ); |
| 1066 | - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue; | |
| 1067 | 1289 | |
| 1068 | - // Only process known gVectors addons from the proxy | |
| 1069 | - if( ! isset( $proxy_addons[ $slug ] ) ) continue; | |
| 1290 | + // Only process known gVectors addons from the proxy (host plugins are never in the map) | |
| 1291 | + if( $slug === '.' || ! isset( $proxy_addons[ $slug ] ) ) continue; | |
| 1292 | + | |
| 1293 | + // Licensed via / belongs to another host plugin — don't overwrite that host's update entry | |
| 1294 | + if( ! $this->manages_addon( $slug ) ) continue; | |
| 1070 | 1295 | |
| 1071 | 1296 | $proxy_info = $proxy_addons[ $slug ]; |
| 1072 | 1297 | $latest_version = ! empty( $proxy_info['version'] ) ? $proxy_info['version'] : ''; |
| 1073 | 1298 | $current_version = $transient->checked[ $plugin_file ] ?? '0.0.0'; |
| @@ -1088,8 +1313,21 @@ | ||
| 1088 | 1313 | $transient->response[ $plugin_file ] = $update; |
| 1089 | 1314 | } |
| 1090 | 1315 | } |
| 1091 | 1316 | |
| 1317 | + // Entitled updates (licensed / legacy-licensed, with a download package) this site can't install | |
| 1318 | + $entitled = []; | |
| 1319 | + foreach( array_unique( $licensed_plugin_files ) as $plugin_file ) { | |
| 1320 | + $update = $transient->response[ $plugin_file ] ?? null; | |
| 1321 | + if( ! $update || empty( $update->package ) ) continue; | |
| 1322 | + $entitled[ $update->slug ] = [ | |
| 1323 | + 'name' => ! empty( $all_plugins[ $plugin_file ]['Name'] ) ? $all_plugins[ $plugin_file ]['Name'] : ( $proxy_addons[ $update->slug ]['name'] ?? $update->slug ), | |
| 1324 | + 'current_version' => (string) ( $transient->checked[ $plugin_file ] ?? '' ), | |
| 1325 | + 'new_version' => (string) $update->new_version, | |
| 1326 | + ]; | |
| 1327 | + } | |
| 1328 | + $this->queue_blocked_updates( $entitled ); | |
| 1329 | + | |
| 1092 | 1330 | return $transient; |
| 1093 | 1331 | } |
| 1094 | 1332 | |
| 1095 | 1333 | /** |
| @@ -1182,8 +1420,42 @@ | ||
| 1182 | 1420 | } |
| 1183 | 1421 | } |
| 1184 | 1422 | |
| 1185 | 1423 | /** |
| 1424 | + * WordPress's update check (twice-daily wp_update_plugins cron, or the Updates/Plugins screens) | |
| 1425 | + * found new versions of licensed addons, but this site blocks plugin installs — so neither the | |
| 1426 | + * auto-updater (disabled outright by DISALLOW_FILE_MODS) nor the Updates screen can install them. | |
| 1427 | + * The versions are merged into a shared queue and a single cron event hands them to the news | |
| 1428 | + * module (`gvectors_blocked_updates` → one email per admin, deduped per addon version). Never | |
| 1429 | + * sends from here: the update check can run during a page load. | |
| 1430 | + */ | |
| 1431 | + private function queue_blocked_updates( array $updates ): void { | |
| 1432 | + if( ! $updates || self::site_can_install_plugins() ) return; | |
| 1433 | + | |
| 1434 | + $queued = get_option( self::BLOCKED_UPDATES_OPTION, [] ); | |
| 1435 | + $queued = is_array( $queued ) ? $queued : []; | |
| 1436 | + $merged = array_merge( $queued, $updates ); | |
| 1437 | + if( $merged !== $queued ) { | |
| 1438 | + update_option( self::BLOCKED_UPDATES_OPTION, $merged, false ); | |
| 1439 | + } | |
| 1440 | + if( ! wp_next_scheduled( self::BLOCKED_UPDATES_HOOK ) ) { | |
| 1441 | + wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::BLOCKED_UPDATES_HOOK ); | |
| 1442 | + } | |
| 1443 | + } | |
| 1444 | + | |
| 1445 | + /** | |
| 1446 | + * Cron: hand the queued blocked updates to the news module (sends the admin emails via wp_mail). | |
| 1447 | + * Skipped when the site can install plugins again by now — WordPress will just update normally. | |
| 1448 | + */ | |
| 1449 | + public static function notify_blocked_updates(): void { | |
| 1450 | + $updates = get_option( self::BLOCKED_UPDATES_OPTION, [] ); | |
| 1451 | + delete_option( self::BLOCKED_UPDATES_OPTION ); | |
| 1452 | + if( ! is_array( $updates ) || ! $updates || self::site_can_install_plugins() ) return; | |
| 1453 | + | |
| 1454 | + do_action( 'gvectors_blocked_updates', $updates ); | |
| 1455 | + } | |
| 1456 | + | |
| 1457 | + /** | |
| 1186 | 1458 | * Intercept WordPress updater package downloads to block tampered addons with a visible error. |
| 1187 | 1459 | * This hooks into 'upgrader_pre_download' so the user sees a clear message in the update UI. |
| 1188 | 1460 | * The actual download is handled by the proxy server's addon/wp-download endpoint (302 redirect). |
| 1189 | 1461 | */ |
| @@ -1255,19 +1527,21 @@ | ||
| 1255 | 1527 | * Block activation with wp_die() if any check fails. |
| 1256 | 1528 | */ |
| 1257 | 1529 | public function validate_on_activation( string $plugin_file ): void { |
| 1258 | 1530 | $slug = dirname( $plugin_file ); |
| 1259 | - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) return; | |
| 1531 | + if( $slug === '.' || $this->is_host_plugin( $slug ) ) return; | |
| 1260 | 1532 | |
| 1261 | 1533 | // Skip all checks on development/local/staging environments |
| 1262 | 1534 | if( LicenseModule::is_development_site() ) return; |
| 1263 | 1535 | |
| 1264 | - // Check if this is a known gVectors addon | |
| 1265 | - $all_addon_slugs = $this->get_all_addon_slugs_from_proxy(); | |
| 1266 | - if( ! $this->is_known_addon( $slug, $all_addon_slugs ) ) return; | |
| 1267 | - | |
| 1536 | + // Check if this is an addon from the gVectors store list | |
| 1537 | + if( ! $this->is_known_addon( $slug ) ) return; | |
| 1538 | + | |
| 1539 | + // Licensed via / belongs to another host plugin (e.g. wpDiscuz) — that host's activation gate validates it | |
| 1540 | + if( ! $this->manages_addon( $slug ) ) return; | |
| 1541 | + | |
| 1268 | 1542 | $reasons = []; |
| 1269 | - | |
| 1543 | + | |
| 1270 | 1544 | // 1) License check — new Paddle license OR legacy gVectors license |
| 1271 | 1545 | $has_new_license = $this->addon_has_license( $slug ); |
| 1272 | 1546 | $has_legacy_license = false; |
| 1273 | 1547 | if( ! $has_new_license ) { |
| @@ -1279,16 +1553,9 @@ | ||
| 1279 | 1553 | |
| 1280 | 1554 | // 2) Signature & integrity checks |
| 1281 | 1555 | $sig_result = $this->verify_addon_signatures( $slug ); |
| 1282 | 1556 | if( $sig_result !== 'valid' && $sig_result !== 'legacy_valid' ) { |
| 1283 | - $labels = [ | |
| 1284 | - 'no_manifest' => __( 'Missing signature manifest — addon was not installed through the official channel.', 'gvectors' ), | |
| 1285 | - 'tampered' => __( 'File integrity check failed — one or more addon files have been modified.', 'gvectors' ), | |
| 1286 | - 'domain_mismatch' => __( 'Domain mismatch — this addon copy is signed for a different website.', 'gvectors' ), | |
| 1287 | - 'no_signatures' => __( 'Missing PHP header signatures — addon files lack required security headers.', 'gvectors' ), | |
| 1288 | - 'patched' => __( 'Nulled/patched code detected — this addon appears to be a pirated copy.', 'gvectors' ), | |
| 1289 | - ]; | |
| 1290 | - $reasons[] = $labels[ $sig_result ] ?? __( 'Addon verification failed.', 'gvectors' ); | |
| 1557 | + $reasons[] = self::signature_failure_reason( $sig_result ); | |
| 1291 | 1558 | } |
| 1292 | 1559 | |
| 1293 | 1560 | if( ! empty( $reasons ) ) { |
| 1294 | 1561 | // Store a transient so we can show an admin notice on redirect back |
| @@ -1305,30 +1572,33 @@ | ||
| 1305 | 1572 | } |
| 1306 | 1573 | } |
| 1307 | 1574 | |
| 1308 | 1575 | /** |
| 1309 | - * Fetch all known addon slugs from the proxy server. | |
| 1310 | - * Returns array of slug strings. | |
| 1576 | + * Human-readable reason for a failed verify_addon_signatures() result | |
| 1311 | 1577 | */ |
| 1312 | - private function get_all_addon_slugs_from_proxy(): array { | |
| 1313 | - $response = $this->licenseService->apiService->get_all_addons(); | |
| 1314 | - if( empty( $response['success'] ) || empty( $response['data']['addons'] ) ) { | |
| 1315 | - return []; | |
| 1316 | - } | |
| 1578 | + private static function signature_failure_reason( string $sig_result ): string { | |
| 1579 | + $labels = [ | |
| 1580 | + 'no_manifest' => __( 'Missing signature manifest — addon was not installed through the official channel.', 'gvectors' ), | |
| 1581 | + 'tampered' => __( 'File integrity check failed — one or more addon files have been modified.', 'gvectors' ), | |
| 1582 | + 'domain_mismatch' => __( 'Domain mismatch — this addon copy is signed for a different website.', 'gvectors' ), | |
| 1583 | + 'no_signatures' => __( 'Missing PHP header signatures — addon files lack required security headers.', 'gvectors' ), | |
| 1584 | + 'patched' => __( 'Nulled/patched code detected — this addon appears to be a pirated copy.', 'gvectors' ), | |
| 1585 | + ]; | |
| 1317 | 1586 | |
| 1318 | - return array_column( $response['data']['addons'], 'slug' ); | |
| 1587 | + return $labels[ $sig_result ] ?? __( 'Addon verification failed.', 'gvectors' ); | |
| 1319 | 1588 | } |
| 1320 | 1589 | |
| 1321 | 1590 | /** |
| 1322 | - * Check if a plugin slug is a known gVectors addon by querying the proxy's full addon list. | |
| 1591 | + * Check if a plugin slug is a gVectors store addon — decided only by the store server's addon list, | |
| 1592 | + * never by the plugin's name (e.g. "forums-censure-pro" is recognized just like "wpforo-polls"). | |
| 1593 | + * When no store list has ever been fetched, nothing is treated as an addon (fail open). | |
| 1594 | + * | |
| 1595 | + * @param bool $allow_remote false = never make an HTTP request (for page-load paths like admin_init) | |
| 1323 | 1596 | */ |
| 1324 | - private function is_known_addon( string $plugin_slug, array $all_addon_slugs = [] ): bool { | |
| 1325 | - if( ! empty( $all_addon_slugs ) ) { | |
| 1326 | - return in_array( $plugin_slug, $all_addon_slugs, true ); | |
| 1327 | - } | |
| 1597 | + private function is_known_addon( string $plugin_slug, bool $allow_remote = true ): bool { | |
| 1598 | + if( $plugin_slug === '' || $this->is_host_plugin( $plugin_slug ) ) return false; | |
| 1328 | 1599 | |
| 1329 | - // Fallback: check by naming convention | |
| 1330 | - return ( strpos( $plugin_slug, $this->config->get_core_plugin_slug() . '-' ) === 0 || strpos( $plugin_slug, $this->config->get_core_plugin_slug() . '_' ) === 0 ); | |
| 1600 | + return isset( $this->get_store_addons( $allow_remote )[ $plugin_slug ] ); | |
| 1331 | 1601 | } |
| 1332 | 1602 | |
| 1333 | 1603 | /** |
| 1334 | 1604 | * Check if an addon slug has an associated license (local) or is a known addon from proxy. |
| @@ -1359,14 +1629,13 @@ | ||
| 1359 | 1629 | * Checks every installed plugin that matches a known addon slug from the proxy. |
| 1360 | 1630 | * Uses a grace period: show FATAL notice first, deactivate after TAMPER_GRACE_DAYS. |
| 1361 | 1631 | */ |
| 1362 | 1632 | public function verify_all_addon_signatures(): void { |
| 1633 | + $this->prune_missing_addons(); | |
| 1634 | + | |
| 1363 | 1635 | // Skip all checks on development/local/staging environments |
| 1364 | 1636 | if( LicenseModule::is_development_site() ) return; |
| 1365 | 1637 | |
| 1366 | - // Get the full list of known addon slugs from the proxy server | |
| 1367 | - $all_addon_slugs = $this->get_all_addon_slugs_from_proxy(); | |
| 1368 | - | |
| 1369 | 1638 | // Collect locally licensed plugin slugs |
| 1370 | 1639 | $licenses = $this->licenseService->get_all(); |
| 1371 | 1640 | $licensed_slugs = []; |
| 1372 | 1641 | foreach( $licenses as $product_id => $license ) { |
| @@ -1374,11 +1643,11 @@ | ||
| 1374 | 1643 | if( ! empty( $slug ) ) $licensed_slugs[] = $slug; |
| 1375 | 1644 | } |
| 1376 | 1645 | |
| 1377 | 1646 | // Scan for installed addons that are known to the proxy but have no license |
| 1378 | - $this->scan_unlicensed_addons( $licensed_slugs, $all_addon_slugs ); | |
| 1647 | + $this->scan_unlicensed_addons( $licensed_slugs ); | |
| 1379 | 1648 | |
| 1380 | - // Verify signatures for all installed plugins that match known addon slugs | |
| 1649 | + // Verify signatures for all installed plugins that are in the store addon list | |
| 1381 | 1650 | if( ! function_exists( 'get_plugins' ) ) { |
| 1382 | 1651 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 1383 | 1652 | } |
| 1384 | 1653 | $all_plugins = get_plugins(); |
| @@ -1384,13 +1653,20 @@ | ||
| 1384 | 1653 | $all_plugins = get_plugins(); |
| 1385 | 1654 | |
| 1386 | 1655 | foreach( $all_plugins as $file => $data ) { |
| 1387 | 1656 | $slug = dirname( $file ); |
| 1388 | - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue; | |
| 1657 | + if( $slug === '.' ) continue; | |
| 1389 | 1658 | |
| 1390 | - // Check against proxy's known addon list | |
| 1391 | - if( ! $this->is_known_addon( $slug, $all_addon_slugs ) ) continue; | |
| 1659 | + // Check against the store's addon list (host plugins excluded) | |
| 1660 | + if( ! $this->is_known_addon( $slug ) ) continue; | |
| 1392 | 1661 | if( ! $this->is_installed( $slug ) ) continue; |
| 1662 | + | |
| 1663 | + // Licensed via / belongs to another host plugin — that host verifies it; drop this host's stale tracking | |
| 1664 | + if( ! $this->manages_addon( $slug ) ) { | |
| 1665 | + $this->clear_tamper_flag( $slug ); | |
| 1666 | + $this->clear_legacy_cache( $slug ); | |
| 1667 | + continue; | |
| 1668 | + } | |
| 1393 | 1669 | |
| 1394 | 1670 | $result = $this->verify_addon_signatures( $slug ); |
| 1395 | 1671 | if( $result !== 'valid' && $result !== 'legacy_valid' ) { |
| 1396 | 1672 | $this->maybe_deactivate_tampered( $slug ); |
| @@ -1402,9 +1678,9 @@ | ||
| 1402 | 1678 | * Scan for installed plugins that are known gVectors addons (from the proxy list) but have no license. |
| 1403 | 1679 | * These could be pirated copies installed manually, OR legacy-licensed installations. |
| 1404 | 1680 | * Checks legacy license before flagging as tampered. |
| 1405 | 1681 | */ |
| 1406 | - private function scan_unlicensed_addons( array $licensed_slugs, array $all_addon_slugs = [] ): void { | |
| 1682 | + private function scan_unlicensed_addons( array $licensed_slugs ): void { | |
| 1407 | 1683 | if( ! function_exists( 'get_plugins' ) ) { |
| 1408 | 1684 | require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 1409 | 1685 | } |
| 1410 | 1686 | |
| @@ -1418,11 +1694,12 @@ | ||
| 1418 | 1694 | $needs_legacy_check = []; |
| 1419 | 1695 | $needs_legacy_refresh = []; |
| 1420 | 1696 | foreach( $all_plugins as $file => $data ) { |
| 1421 | 1697 | $slug = dirname( $file ); |
| 1422 | - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue; | |
| 1423 | - if( ! $this->is_known_addon( $slug, $all_addon_slugs ) ) continue; | |
| 1698 | + if( $slug === '.' ) continue; | |
| 1699 | + if( ! $this->is_known_addon( $slug ) ) continue; | |
| 1424 | 1700 | if( in_array( $slug, $licensed_slugs, true ) ) continue; |
| 1701 | + if( ! $this->manages_addon( $slug ) ) continue; | |
| 1425 | 1702 | if( ! is_plugin_active( $file ) ) continue; |
| 1426 | 1703 | |
| 1427 | 1704 | $manifest_file = WP_PLUGIN_DIR . '/' . $slug . '/.addon-signatures.json'; |
| 1428 | 1705 | if( ! file_exists( $manifest_file ) ) { |
| @@ -1518,12 +1795,14 @@ | ||
| 1518 | 1795 | * Records the first time admin sees each tamper notice. |
| 1519 | 1796 | */ |
| 1520 | 1797 | public function track_tamper_notice_view(): void { |
| 1521 | 1798 | if( ! current_user_can( 'administrator' ) ) return; |
| 1522 | - | |
| 1799 | + | |
| 1800 | + $this->prune_missing_addons(); | |
| 1801 | + | |
| 1523 | 1802 | $tampered = get_option( $this->tampered_option, [] ); |
| 1524 | 1803 | if( empty( $tampered ) ) return; |
| 1525 | - | |
| 1804 | + | |
| 1526 | 1805 | $seen = get_option( $this->tamper_dismissed_option, [] ); |
| 1527 | 1806 | $updated = false; |
| 1528 | 1807 | |
| 1529 | 1808 | foreach( $tampered as $slug => $info ) { |
| @@ -1562,19 +1841,70 @@ | ||
| 1562 | 1841 | set_transient( $flag, 1, 12 * HOUR_IN_SECONDS ); |
| 1563 | 1842 | } |
| 1564 | 1843 | |
| 1565 | 1844 | /** |
| 1566 | - * When a plugin is deleted, clear its tamper flag if it had one. | |
| 1845 | + * When a plugin is deleted, forget all stored notice/tamper/legacy data for it. | |
| 1567 | 1846 | */ |
| 1568 | 1847 | public function on_plugin_deleted( string $plugin_file, bool $deleted ): void { |
| 1569 | 1848 | if( ! $deleted ) return; |
| 1570 | - | |
| 1849 | + | |
| 1571 | 1850 | $slug = dirname( $plugin_file ); |
| 1572 | 1851 | if( $slug && $slug !== '.' ) { |
| 1573 | - $this->clear_tamper_flag( $slug ); | |
| 1574 | - $this->clear_legacy_cache( $slug ); | |
| 1852 | + $this->forget_addon( $slug ); | |
| 1575 | 1853 | } |
| 1576 | 1854 | } |
| 1855 | + | |
| 1856 | + /** | |
| 1857 | + * Check if an addon physically exists on disk as a real plugin. | |
| 1858 | + * An empty leftover folder (no plugin header file) counts as not present. | |
| 1859 | + */ | |
| 1860 | + private function is_addon_present( string $plugin_slug ): bool { | |
| 1861 | + if( empty( $plugin_slug ) || ! is_dir( WP_PLUGIN_DIR . '/' . $plugin_slug ) ) return false; | |
| 1862 | + | |
| 1863 | + return ! empty( $this->get_installed_plugin_file( $plugin_slug ) ); | |
| 1864 | + } | |
| 1865 | + | |
| 1866 | + /** | |
| 1867 | + * Remove all per-addon notice, tamper and legacy-cache data for a slug. | |
| 1868 | + * License records are intentionally kept — they are paid entitlements used by the store page. | |
| 1869 | + */ | |
| 1870 | + private function forget_addon( string $plugin_slug ): void { | |
| 1871 | + $expired = get_option( $this->expired_notice_option, [] ); | |
| 1872 | + if( isset( $expired[ $plugin_slug ] ) ) { | |
| 1873 | + unset( $expired[ $plugin_slug ] ); | |
| 1874 | + update_option( $this->expired_notice_option, $expired ); | |
| 1875 | + } | |
| 1876 | + | |
| 1877 | + $this->clear_tamper_flag( $plugin_slug ); | |
| 1878 | + $this->clear_legacy_cache( $plugin_slug ); | |
| 1879 | + | |
| 1880 | + foreach( [ 'tampered', 'expired', 'legacy' ] as $type ) { | |
| 1881 | + delete_transient( 'gvectors_' . $type . '_dismissed_' . $plugin_slug ); | |
| 1882 | + } | |
| 1883 | + } | |
| 1884 | + | |
| 1885 | + /** | |
| 1886 | + * Rewind stored per-addon data for addons that no longer physically exist | |
| 1887 | + * (e.g. deleted via FTP / file manager, bypassing the deleted_plugin hook). | |
| 1888 | + * Runs once per request per instance. | |
| 1889 | + */ | |
| 1890 | + public function prune_missing_addons(): void { | |
| 1891 | + if( $this->pruned ) return; | |
| 1892 | + $this->pruned = true; | |
| 1893 | + | |
| 1894 | + $slugs = []; | |
| 1895 | + foreach( [ $this->expired_notice_option, $this->tampered_option, $this->tamper_dismissed_option, $this->legacy_licenses_option, $this->legacy_notice_option ] as $option ) { | |
| 1896 | + $data = get_option( $option, [] ); | |
| 1897 | + if( is_array( $data ) ) $slugs = array_merge( $slugs, array_keys( $data ) ); | |
| 1898 | + } | |
| 1899 | + | |
| 1900 | + foreach( array_unique( $slugs ) as $slug ) { | |
| 1901 | + $slug = (string) $slug; | |
| 1902 | + if( ! $this->is_addon_present( $slug ) ) { | |
| 1903 | + $this->forget_addon( $slug ); | |
| 1904 | + } | |
| 1905 | + } | |
| 1906 | + } | |
| 1577 | 1907 | |
| 1578 | 1908 | /** |
| 1579 | 1909 | * Clear the legacy license cache for a specific addon. |
| 1580 | 1910 | */ |
| @@ -1600,17 +1930,22 @@ | ||
| 1600 | 1930 | * Marks expired licenses for admin notice display. |
| 1601 | 1931 | * Does NOT deactivate addons for expired licenses - they keep working. |
| 1602 | 1932 | */ |
| 1603 | 1933 | public function check_all_license_validity(): void { |
| 1934 | + $this->prune_missing_addons(); | |
| 1935 | + | |
| 1604 | 1936 | $licenses = $this->licenseService->get_all(); |
| 1605 | 1937 | if( empty( $licenses ) ) return; |
| 1606 | - | |
| 1938 | + | |
| 1607 | 1939 | $expired_notices = get_option( $this->expired_notice_option, [] ); |
| 1608 | - | |
| 1940 | + | |
| 1609 | 1941 | foreach( $licenses as $license ) { |
| 1610 | 1942 | $plugin_slug = $license['plugin_slug'] ?? ''; |
| 1611 | 1943 | if( empty( $plugin_slug ) ) continue; |
| 1612 | - if( ! $this->is_installed( $plugin_slug ) ) continue; | |
| 1944 | + if( ! $this->is_installed( $plugin_slug ) ) { | |
| 1945 | + unset( $expired_notices[ $plugin_slug ] ); | |
| 1946 | + continue; | |
| 1947 | + } | |
| 1613 | 1948 | |
| 1614 | 1949 | $status = $license['status'] ?? ''; |
| 1615 | 1950 | $expires_at = $license['expires_at'] ?? ''; |
| 1616 | 1951 | $is_expired = false; |
| @@ -1840,27 +2175,20 @@ | ||
| 1840 | 2175 | */ |
| 1841 | 2176 | public function tampered_addon_notice(): void { |
| 1842 | 2177 | if( ! $this->is_notice_page() ) return; |
| 1843 | 2178 | if( ! current_user_can( 'administrator' ) ) return; |
| 2179 | + | |
| 2180 | + $this->prune_missing_addons(); | |
| 2181 | + | |
| 1844 | 2182 | if( LicenseModule::is_development_site() ) return; |
| 1845 | - | |
| 2183 | + | |
| 1846 | 2184 | $tampered = get_option( $this->tampered_option, [] ); |
| 1847 | 2185 | if( empty( $tampered ) ) return; |
| 1848 | - | |
| 1849 | - $changed = false; | |
| 2186 | + | |
| 1850 | 2187 | foreach( $tampered as $slug => $info ) { |
| 1851 | - if( ! is_dir( WP_PLUGIN_DIR . '/' . $slug ) ) { | |
| 1852 | - unset( $tampered[ $slug ] ); | |
| 1853 | - $changed = true; | |
| 1854 | - } | |
| 1855 | - } | |
| 1856 | - if( $changed ) { | |
| 1857 | - update_option( $this->tampered_option, $tampered ); | |
| 1858 | - } | |
| 1859 | - if( empty( $tampered ) ) return; | |
| 1860 | - | |
| 1861 | - foreach( $tampered as $slug => $info ) { | |
| 2188 | + if( ! $this->is_addon_present( $slug ) ) continue; | |
| 1862 | 2189 | if( get_transient( 'gvectors_tampered_dismissed_' . $slug ) ) continue; |
| 2190 | + if( ! self::claim_notice( 'tampered', $slug ) ) continue; | |
| 1863 | 2191 | |
| 1864 | 2192 | $files = $info['files'] ?? []; |
| 1865 | 2193 | $reason = $info['reason'] ?? 'tampered'; |
| 1866 | 2194 | $detected = $info['detected_at'] ?? ''; |
| @@ -1969,19 +2297,24 @@ | ||
| 1969 | 2297 | } |
| 1970 | 2298 | |
| 1971 | 2299 | foreach( $update_plugins->response as $plugin_file => $update_data ) { |
| 1972 | 2300 | $slug = dirname( $plugin_file ); |
| 1973 | - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue; | |
| 2301 | + if( $slug === '.' ) continue; | |
| 1974 | 2302 | |
| 1975 | 2303 | // Only for our addons that have empty package (no active license) |
| 1976 | 2304 | $package = is_object( $update_data ) ? ( $update_data->package ?? '' ) : ''; |
| 1977 | 2305 | if( ! empty( $package ) ) continue; |
| 1978 | 2306 | |
| 1979 | - // Confirm it's a known gVectors addon | |
| 1980 | - if( ! $this->is_known_addon( $slug ) ) continue; | |
| 2307 | + // Confirm it's an addon from the gVectors store list (no HTTP request on page load) | |
| 2308 | + if( ! $this->is_known_addon( $slug, false ) ) continue; | |
| 1981 | 2309 | |
| 1982 | 2310 | // Confirm no active license |
| 1983 | 2311 | if( in_array( $slug, $active_licensed_slugs, true ) ) continue; |
| 2312 | + | |
| 2313 | + // Licensed via / belongs to another host plugin — that host's instance renders the row (and its store link) | |
| 2314 | + if( ! $this->manages_addon( $slug, false ) ) continue; | |
| 2315 | + | |
| 2316 | + if( ! self::claim_notice( 'unlicensed_row', $slug ) ) continue; | |
| 1984 | 2317 | |
| 1985 | 2318 | add_action( "after_plugin_row_$plugin_file", [ $this, 'unlicensed_update_notice_row' ] ); |
| 1986 | 2319 | } |
| 1987 | 2320 | } |
| @@ -2018,13 +2351,17 @@ | ||
| 2018 | 2351 | public function expired_license_notice(): void { |
| 2019 | 2352 | if( ! $this->is_notice_page() ) return; |
| 2020 | 2353 | if( ! current_user_can( 'administrator' ) ) return; |
| 2021 | 2354 | |
| 2355 | + $this->prune_missing_addons(); | |
| 2356 | + | |
| 2022 | 2357 | $expired = get_option( $this->expired_notice_option, [] ); |
| 2023 | 2358 | if( empty( $expired ) ) return; |
| 2024 | - | |
| 2359 | + | |
| 2025 | 2360 | foreach( $expired as $slug => $info ) { |
| 2361 | + if( ! $this->is_addon_present( $slug ) ) continue; | |
| 2026 | 2362 | if( get_transient( 'gvectors_expired_dismissed_' . $slug ) ) continue; |
| 2363 | + if( ! self::claim_notice( 'expired', $slug ) ) continue; | |
| 2027 | 2364 | |
| 2028 | 2365 | $product_name = $info['product_name'] ?? $slug; |
| 2029 | 2366 | $has_update = ! empty( $info['has_update'] ); |
| 2030 | 2367 | $latest = $info['latest_version'] ?? ''; |
| @@ -2074,11 +2411,13 @@ | ||
| 2074 | 2411 | public function legacy_license_notice(): void { |
| 2075 | 2412 | if( ! $this->is_notice_page() ) return; |
| 2076 | 2413 | if( ! current_user_can( 'administrator' ) ) return; |
| 2077 | 2414 | |
| 2415 | + $this->prune_missing_addons(); | |
| 2416 | + | |
| 2078 | 2417 | $notices = get_option( $this->legacy_notice_option, [] ); |
| 2079 | 2418 | if( empty( $notices ) ) return; |
| 2080 | - | |
| 2419 | + | |
| 2081 | 2420 | $addons_page_url = admin_url( $this->config->get_dashboard_addons_store_url() ); |
| 2082 | 2421 | |
| 2083 | 2422 | foreach( $notices as $slug => $info ) { |
| 2084 | 2423 | // Only show notices for expired legacy licenses |
| @@ -2084,11 +2423,12 @@ | ||
| 2084 | 2423 | // Only show notices for expired legacy licenses |
| 2085 | 2424 | if( empty( $info['status'] ) || $info['status'] !== 'expired' ) continue; |
| 2086 | 2425 | |
| 2087 | 2426 | // Verify the addon is still installed |
| 2088 | - if( ! is_dir( WP_PLUGIN_DIR . '/' . $slug ) ) continue; | |
| 2427 | + if( ! $this->is_addon_present( $slug ) ) continue; | |
| 2089 | 2428 | |
| 2090 | 2429 | if( get_transient( 'gvectors_legacy_dismissed_' . $slug ) ) continue; |
| 2430 | + if( ! self::claim_notice( 'legacy', $slug ) ) continue; | |
| 2091 | 2431 | |
| 2092 | 2432 | $plugin_name = $info['plugin_name'] ?? $slug; |
| 2093 | 2433 | |
| 2094 | 2434 | $dismiss_url = wp_nonce_url( |