PluginProbe
wpForo Forum / 3.2.1
wpForo Forum v3.2.1
3.2.1 3.2.0 3.1.7 3.1.6 3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 All 141 releases
← All changes | admin/pages/license/src/Services/AddonsService.php +353 -71 3.2.0 → 3.2.1 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,41 @@
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 + 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 +
114 150 $install_result = $this->install( $product_id );
115 151 if( empty( $install_result['success'] ) ) return $install_result;
116 152
117 153 $plugin_file = $install_result['plugin_file'];
@@ -131,13 +167,89 @@
131 167 ];
132 168 }
133 169
134 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 + /**
135 243 * Download and install an addon from the proxy server
136 244 */
137 245 public function install( string $product_id ): array {
138 - if( ! current_user_can( 'install_plugins' ) ) {
139 - 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 + ];
140 252 }
141 253
142 254 $license = $this->licenseService->get( $product_id );
143 255 if( empty( $license ) || empty( $license['license_key'] ) ) {
@@ -156,22 +268,13 @@
156 268 ];
157 269 }
158 270
159 271 // 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 - }
272 + $download = $this->request_download( $product_id );
273 + if( empty( $download['success'] ) ) return $download;
169 274
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 );
275 + $download_url = $download['download_url'];
276 + $plugin_slug = $download['plugin_slug'];
174 277
175 278 // Use WordPress built-in plugin installer
176 279 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
177 280 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
@@ -188,12 +291,13 @@
188 291 } else {
189 292 $result = $upgrader->install( $download_url );
190 293 }
191 294
295 + // Upgrader failures are mostly filesystem problems — offer the manual (ZIP + FTP) install path
192 296 if( is_wp_error( $result ) ) {
193 297 error_log( '[gVectors Addon] WP_Error from upgrader: ' . $result->get_error_message() );
194 298
195 - return [ 'success' => false, 'error' => $result->get_error_message() ];
299 + return [ 'success' => false, 'error' => $result->get_error_message(), 'manual_install' => true ];
196 300 }
197 301
198 302 if( $result === false ) {
199 303 $errors = $skin->get_errors();
@@ -200,9 +304,9 @@
200 304 $error = is_wp_error( $errors ) ? $errors->get_error_message() : __( 'Installation failed', 'gvectors' );
201 305 $skin_feedback = method_exists( $skin, 'get_upgrade_messages' ) ? $skin->get_upgrade_messages() : [];
202 306 error_log( '[gVectors Addon] Install result=false. Error: ' . $error . ' | Feedback: ' . print_r( $skin_feedback, true ) );
203 307
204 - return [ 'success' => false, 'error' => $error ];
308 + return [ 'success' => false, 'error' => $error, 'manual_install' => true ];
205 309 }
206 310
207 311 error_log( '[gVectors Addon] Install result: ' . print_r( $result, true ) );
208 312 error_log( '[gVectors Addon] Skin messages: ' . print_r( $skin->get_upgrade_messages(), true ) );
@@ -334,23 +438,138 @@
334 438
335 439 /**
336 440 * Fetch all addon info from the proxy server, keyed by slug.
337 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.
338 443 */
339 444 private function get_proxy_addons_map(): array {
340 445 $response = $this->licenseService->apiService->get_all_addons();
341 - if( empty( $response['success'] ) || empty( $response['data']['addons'] ) ) {
446 + if( empty( $response['success'] ) || empty( $response['data']['addons'] ) || ! is_array( $response['data']['addons'] ) ) {
342 447 return [];
343 448 }
344 449 $map = [];
345 450 foreach( $response['data']['addons'] as $addon ) {
346 - if( ! empty( $addon['slug'] ) ) {
451 + if( ! empty( $addon['slug'] ) && is_string( $addon['slug'] ) && ! $this->is_host_plugin( $addon['slug'] ) ) {
347 452 $map[ $addon['slug'] ] = $addon;
348 453 }
349 454 }
350 -
455 + $this->remember_store_addons( $map );
456 +
351 457 return $map;
352 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 + }
353 572
354 573 /**
355 574 * Verify signatures of a single addon by its slug.
356 575 * Checks: manifest existence, file hashes, domain signature, PHP header signatures.
@@ -887,14 +1106,13 @@
887 1106 continue;
888 1107 }
889 1108
890 1109 $plugin_slug = $license['plugin_slug'] ?? '';
891 - if( empty( $plugin_slug ) ) continue;
1110 + if( empty( $plugin_slug ) || $this->is_host_plugin( $plugin_slug ) ) continue;
892 1111
893 1112 $plugin_file = $this->get_installed_plugin_file( $plugin_slug );
894 1113 if( ! $plugin_file ) continue;
895 1114
896 -
897 1115 $current_version = $transient->checked[ $plugin_file ] ?? '0.0.0';
898 1116
899 1117 // Use proxy server version (from addon file header) instead of local options
900 1118 $proxy_info = $proxy_addons[ $plugin_slug ] ?? [];
@@ -946,8 +1164,11 @@
946 1164 if( in_array( $plugin_file, $licensed_plugin_files, true ) ) continue;
947 1165
948 1166 // Only process known gVectors addons from the proxy
949 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;
950 1171
951 1172 // Migrate legacy license to new system eagerly — even without a pending update.
952 1173 // On success, save() stores the license in gvectors_licenses so the Paddle loop
953 1174 // handles this slug on the next check_for_updates() call.
@@ -1006,10 +1227,11 @@
1006 1227 $uncached_slugs = [];
1007 1228 foreach( $all_plugins as $_pf => $_pd ) {
1008 1229 if( in_array( $_pf, $licensed_plugin_files, true ) ) continue;
1009 1230 $_slug = dirname( $_pf );
1010 - if( $_slug === '.' || $_slug === $this->config->get_core_plugin_slug() ) continue;
1011 - 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;
1012 1234 if( ! isset( $all_legacy[ $_slug ] ) ) $uncached_slugs[] = $_slug;
1013 1235 }
1014 1236 if( ! empty( $uncached_slugs ) ) {
1015 1237 $this->check_legacy_licenses_batch( array_unique( $uncached_slugs ) );
@@ -1063,12 +1285,14 @@
1063 1285 // Skip if already handled by licensed update above
1064 1286 if( in_array( $plugin_file, $licensed_plugin_files, true ) ) continue;
1065 1287
1066 1288 $slug = dirname( $plugin_file );
1067 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue;
1068 1289
1069 - // Only process known gVectors addons from the proxy
1070 - 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;
1071 1295
1072 1296 $proxy_info = $proxy_addons[ $slug ];
1073 1297 $latest_version = ! empty( $proxy_info['version'] ) ? $proxy_info['version'] : '';
1074 1298 $current_version = $transient->checked[ $plugin_file ] ?? '0.0.0';
@@ -1089,8 +1313,21 @@
1089 1313 $transient->response[ $plugin_file ] = $update;
1090 1314 }
1091 1315 }
1092 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 +
1093 1330 return $transient;
1094 1331 }
1095 1332
1096 1333 /**
@@ -1183,8 +1420,42 @@
1183 1420 }
1184 1421 }
1185 1422
1186 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 + /**
1187 1458 * Intercept WordPress updater package downloads to block tampered addons with a visible error.
1188 1459 * This hooks into 'upgrader_pre_download' so the user sees a clear message in the update UI.
1189 1460 * The actual download is handled by the proxy server's addon/wp-download endpoint (302 redirect).
1190 1461 */
@@ -1256,19 +1527,21 @@
1256 1527 * Block activation with wp_die() if any check fails.
1257 1528 */
1258 1529 public function validate_on_activation( string $plugin_file ): void {
1259 1530 $slug = dirname( $plugin_file );
1260 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) return;
1531 + if( $slug === '.' || $this->is_host_plugin( $slug ) ) return;
1261 1532
1262 1533 // Skip all checks on development/local/staging environments
1263 1534 if( LicenseModule::is_development_site() ) return;
1264 1535
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 -
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 +
1269 1542 $reasons = [];
1270 -
1543 +
1271 1544 // 1) License check — new Paddle license OR legacy gVectors license
1272 1545 $has_new_license = $this->addon_has_license( $slug );
1273 1546 $has_legacy_license = false;
1274 1547 if( ! $has_new_license ) {
@@ -1280,16 +1553,9 @@
1280 1553
1281 1554 // 2) Signature & integrity checks
1282 1555 $sig_result = $this->verify_addon_signatures( $slug );
1283 1556 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' );
1557 + $reasons[] = self::signature_failure_reason( $sig_result );
1292 1558 }
1293 1559
1294 1560 if( ! empty( $reasons ) ) {
1295 1561 // Store a transient so we can show an admin notice on redirect back
@@ -1306,30 +1572,33 @@
1306 1572 }
1307 1573 }
1308 1574
1309 1575 /**
1310 - * Fetch all known addon slugs from the proxy server.
1311 - * Returns array of slug strings.
1576 + * Human-readable reason for a failed verify_addon_signatures() result
1312 1577 */
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 - }
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 + ];
1318 1586
1319 - return array_column( $response['data']['addons'], 'slug' );
1587 + return $labels[ $sig_result ] ?? __( 'Addon verification failed.', 'gvectors' );
1320 1588 }
1321 1589
1322 1590 /**
1323 - * 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)
1324 1596 */
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 - }
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;
1329 1599
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 );
1600 + return isset( $this->get_store_addons( $allow_remote )[ $plugin_slug ] );
1332 1601 }
1333 1602
1334 1603 /**
1335 1604 * Check if an addon slug has an associated license (local) or is a known addon from proxy.
@@ -1365,11 +1634,8 @@
1365 1634
1366 1635 // Skip all checks on development/local/staging environments
1367 1636 if( LicenseModule::is_development_site() ) return;
1368 1637
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 1638 // Collect locally licensed plugin slugs
1373 1639 $licenses = $this->licenseService->get_all();
1374 1640 $licensed_slugs = [];
1375 1641 foreach( $licenses as $product_id => $license ) {
@@ -1377,11 +1643,11 @@
1377 1643 if( ! empty( $slug ) ) $licensed_slugs[] = $slug;
1378 1644 }
1379 1645
1380 1646 // Scan for installed addons that are known to the proxy but have no license
1381 - $this->scan_unlicensed_addons( $licensed_slugs, $all_addon_slugs );
1647 + $this->scan_unlicensed_addons( $licensed_slugs );
1382 1648
1383 - // 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
1384 1650 if( ! function_exists( 'get_plugins' ) ) {
1385 1651 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1386 1652 }
1387 1653 $all_plugins = get_plugins();
@@ -1387,13 +1653,20 @@
1387 1653 $all_plugins = get_plugins();
1388 1654
1389 1655 foreach( $all_plugins as $file => $data ) {
1390 1656 $slug = dirname( $file );
1391 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue;
1657 + if( $slug === '.' ) continue;
1392 1658
1393 - // Check against proxy's known addon list
1394 - 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;
1395 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 + }
1396 1669
1397 1670 $result = $this->verify_addon_signatures( $slug );
1398 1671 if( $result !== 'valid' && $result !== 'legacy_valid' ) {
1399 1672 $this->maybe_deactivate_tampered( $slug );
@@ -1405,9 +1678,9 @@
1405 1678 * Scan for installed plugins that are known gVectors addons (from the proxy list) but have no license.
1406 1679 * These could be pirated copies installed manually, OR legacy-licensed installations.
1407 1680 * Checks legacy license before flagging as tampered.
1408 1681 */
1409 - private function scan_unlicensed_addons( array $licensed_slugs, array $all_addon_slugs = [] ): void {
1682 + private function scan_unlicensed_addons( array $licensed_slugs ): void {
1410 1683 if( ! function_exists( 'get_plugins' ) ) {
1411 1684 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1412 1685 }
1413 1686
@@ -1421,11 +1694,12 @@
1421 1694 $needs_legacy_check = [];
1422 1695 $needs_legacy_refresh = [];
1423 1696 foreach( $all_plugins as $file => $data ) {
1424 1697 $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;
1698 + if( $slug === '.' ) continue;
1699 + if( ! $this->is_known_addon( $slug ) ) continue;
1427 1700 if( in_array( $slug, $licensed_slugs, true ) ) continue;
1701 + if( ! $this->manages_addon( $slug ) ) continue;
1428 1702 if( ! is_plugin_active( $file ) ) continue;
1429 1703
1430 1704 $manifest_file = WP_PLUGIN_DIR . '/' . $slug . '/.addon-signatures.json';
1431 1705 if( ! file_exists( $manifest_file ) ) {
@@ -1912,8 +2186,9 @@
1912 2186
1913 2187 foreach( $tampered as $slug => $info ) {
1914 2188 if( ! $this->is_addon_present( $slug ) ) continue;
1915 2189 if( get_transient( 'gvectors_tampered_dismissed_' . $slug ) ) continue;
2190 + if( ! self::claim_notice( 'tampered', $slug ) ) continue;
1916 2191
1917 2192 $files = $info['files'] ?? [];
1918 2193 $reason = $info['reason'] ?? 'tampered';
1919 2194 $detected = $info['detected_at'] ?? '';
@@ -2022,19 +2297,24 @@
2022 2297 }
2023 2298
2024 2299 foreach( $update_plugins->response as $plugin_file => $update_data ) {
2025 2300 $slug = dirname( $plugin_file );
2026 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue;
2301 + if( $slug === '.' ) continue;
2027 2302
2028 2303 // Only for our addons that have empty package (no active license)
2029 2304 $package = is_object( $update_data ) ? ( $update_data->package ?? '' ) : '';
2030 2305 if( ! empty( $package ) ) continue;
2031 2306
2032 - // Confirm it's a known gVectors addon
2033 - 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;
2034 2309
2035 2310 // Confirm no active license
2036 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;
2037 2317
2038 2318 add_action( "after_plugin_row_$plugin_file", [ $this, 'unlicensed_update_notice_row' ] );
2039 2319 }
2040 2320 }
@@ -2079,8 +2359,9 @@
2079 2359
2080 2360 foreach( $expired as $slug => $info ) {
2081 2361 if( ! $this->is_addon_present( $slug ) ) continue;
2082 2362 if( get_transient( 'gvectors_expired_dismissed_' . $slug ) ) continue;
2363 + if( ! self::claim_notice( 'expired', $slug ) ) continue;
2083 2364
2084 2365 $product_name = $info['product_name'] ?? $slug;
2085 2366 $has_update = ! empty( $info['has_update'] );
2086 2367 $latest = $info['latest_version'] ?? '';
@@ -2145,8 +2426,9 @@
2145 2426 // Verify the addon is still installed
2146 2427 if( ! $this->is_addon_present( $slug ) ) continue;
2147 2428
2148 2429 if( get_transient( 'gvectors_legacy_dismissed_' . $slug ) ) continue;
2430 + if( ! self::claim_notice( 'legacy', $slug ) ) continue;
2149 2431
2150 2432 $plugin_name = $info['plugin_name'] ?? $slug;
2151 2433
2152 2434 $dismiss_url = wp_nonce_url(