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 +437 -97 3.1.6 → 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(