PluginProbe
wpForo Forum / 3.2.2
wpForo Forum v3.2.2
3.2.2 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 All 142 releases
← All changes | admin/pages/license/src/Services/AddonsService.php +624 -128 3.2.0 → 3.2.2 View file →
@@ -38,8 +38,11 @@
38 38 private $expired_notice_option;
39 39 private $tamper_dismissed_option;
40 40 private $legacy_licenses_option;
41 41 private $legacy_notice_option;
42 + /** Last successfully fetched store addon list (slug => parent host slugs) — used while the store server is unreachable */
43 + private $store_addons_option;
44 + private $store_addons = null;
42 45 /** Shared transient (not slug-prefixed) so one dismissing covers all plugin instances */
43 46 private static $shared_dev_env_transient = 'gvectors_dev_env_notice_dismissed';
44 47 private static $shared_dev_licenses_transient = 'gvectors_dev_licenses_notice_dismissed';
45 48
@@ -46,8 +49,13 @@
46 49 /** Static collectors for cross-instance notice deduplication */
47 50 private static $dev_env_notice_shown = false;
48 51 private static $dev_licenses_collected = [];
49 52 private static $dev_licenses_registered = false;
53 + /** Per-request "already rendered" markers so several host plugins never duplicate addon notices/rows */
54 + private static $notices_shown = [];
55 + /** Shared (not slug-prefixed) queue + single-event hook for "updates can't be installed" notices — one email for all hosts */
56 + private const BLOCKED_UPDATES_OPTION = 'gvectors_blocked_updates_queue';
57 + private const BLOCKED_UPDATES_HOOK = 'gvectors_blocked_updates_notify';
50 58
51 59 public function __construct( Config $config, LicenseService $licenseService ) {
52 60 $this->config = $config;
53 61 $this->licenseService = $licenseService;
@@ -58,8 +66,9 @@
58 66 $this->expired_notice_option = $this->licenseService->expired_notice_option;
59 67 $this->tamper_dismissed_option = $this->config->get_core_plugin_slug() . '_gvectors_tamper_notice_seen';
60 68 $this->legacy_licenses_option = $this->config->get_core_plugin_slug() . '_gvectors_legacy_addon_licenses';
61 69 $this->legacy_notice_option = $this->config->get_core_plugin_slug() . '_gvectors_legacy_license_notices';
70 + $this->store_addons_option = $this->config->get_core_plugin_slug() . '_gvectors_store_addons';
62 71 $this->init_hooks();
63 72 }
64 73
65 74 private function init_hooks() {
@@ -104,14 +113,49 @@
104 113 add_filter( 'upgrader_pre_download', [ $this, 'block_tampered_update_download' ], 10, 2 );
105 114
106 115 // Clear tamper flag when a plugin is deleted
107 116 add_action( 'deleted_plugin', [ $this, 'on_plugin_deleted' ], 10, 2 );
117 +
118 + // Entitled updates the site can't install → email the admins (news module), from cron
119 + add_action( self::BLOCKED_UPDATES_HOOK, [ self::class, 'notify_blocked_updates' ] );
108 120 }
109 121
110 122 /**
111 - * Install and activate an addon in one step
123 + * Install and activate an addon in one step.
124 + * An addon already on disk (installed but inactive, or uploaded manually via FTP) is only activated —
125 + * re-running the installer would attempt an update, which fails when none is pending or WordPress can't write plugins.
112 126 */
113 127 public function install_and_activate( string $product_id ): array {
128 + $license = $this->licenseService->get( $product_id );
129 + $plugin_slug = self::sanitize_slug( $license['plugin_slug'] ?? '' );
130 + $plugin_file = ! empty( $license['license_key'] ) ? $this->get_installed_plugin_file( $plugin_slug ) : '';
131 +
132 + if( $plugin_file ) {
133 + // Verify here so a bad manual upload gets a clear JSON error instead of the activation gate's wp_die()
134 + $sig_result = $this->verify_addon_signatures( $plugin_slug );
135 + if( ! in_array( $sig_result, [ 'valid', 'legacy_valid' ], true ) ) {
136 + // Sites that can install get a clean copy the normal way; only blocked sites are sent to the ZIP
137 + if( $this->can_install_addons() ) {
138 + return [
139 + 'success' => false,
140 + 'error' => self::signature_failure_reason( $sig_result ) . ' ' . __( 'Click "Reinstall Addon" for this addon here to replace its files with a clean copy.', 'gvectors' ),
141 + ];
142 + }
143 +
144 + return [
145 + 'success' => false,
146 + 'error' => self::signature_failure_reason( $sig_result ) . ' ' . sprintf(
147 + /* translators: %s: addon folder name */
148 + __( 'Please install a clean copy with the steps below. When uploading the ZIP in WordPress, choose "Replace current with uploaded"; when using FTP/SFTP, delete the "%s" folder from wp-content/plugins first.', 'gvectors' ),
149 + $plugin_slug
150 + ),
151 + 'manual_install' => true,
152 + ];
153 + }
154 +
155 + return $this->activate( $plugin_file );
156 + }
157 +
114 158 $install_result = $this->install( $product_id );
115 159 if( empty( $install_result['success'] ) ) return $install_result;
116 160
117 161 $plugin_file = $install_result['plugin_file'];
@@ -131,13 +175,103 @@
131 175 ];
132 176 }
133 177
134 178 /**
179 + * Whether WordPress can install addons on this site from an AJAX request.
180 + * False when file modifications are disabled (DISALLOW_FILE_MODS strips install_plugins), or when the
181 + * plugins folder isn't directly writable and no FTP/SSH credentials are predefined (AJAX can't prompt for them).
182 + * Such sites get the addon ZIP for a manual upload instead.
183 + */
184 + public function can_install_addons(): bool {
185 + return current_user_can( 'install_plugins' ) && self::site_can_install_plugins();
186 + }
187 +
188 + /**
189 + * Site-level half of can_install_addons(), without any user context (safe in cron): file
190 + * modifications allowed (DISALLOW_FILE_MODS / the file_mod_allowed filter) and a filesystem
191 + * WordPress can write plugins to — direct access for the context the upgrader's fs_connect()
192 + * uses plus a writable plugins folder, or predefined FTP/SSH credentials. Static per request.
193 + */
194 + public static function site_can_install_plugins(): bool {
195 + static $can_install = null;
196 + if( $can_install !== null ) return $can_install;
197 +
198 + if( ! wp_is_file_mod_allowed( 'gvectors_addon_install' ) ) return $can_install = false;
199 +
200 + require_once ABSPATH . 'wp-admin/includes/file.php';
201 + if( get_filesystem_method( [], WP_CONTENT_DIR ) === 'direct' ) return $can_install = wp_is_writable( WP_PLUGIN_DIR );
202 +
203 + return $can_install = defined( 'FTP_HOST' ) && defined( 'FTP_USER' ) && ( defined( 'FTP_PASS' ) || defined( 'FTP_PRIKEY' ) );
204 + }
205 +
206 + /**
207 + * Whether an upgrader error means WordPress couldn't write the addon (filesystem access / permissions) —
208 + * the only failures that offer the manual ZIP download. Codes from WP_Upgrader::fs_connect(),
209 + * install_package(), unzip_file() and copy_dir().
210 + */
211 + private static function is_filesystem_error( $error ): bool {
212 + if( ! is_wp_error( $error ) ) return false;
213 + foreach( $error->get_error_codes() as $code ) {
214 + if( preg_match( '/^(fs_|mkdir_failed|copy_failed|files?_not_writable|unable_to_write|remove_old_failed|source_read_failed|new_source_read_failed|dirlist_failed|destination_not_deleted)/', (string) $code ) ) return true;
215 + }
216 +
217 + return false;
218 + }
219 +
220 + /**
221 + * Signed, one-time addon ZIP URL for the admin's browser — the manual install path (FTP upload)
222 + * for sites where WordPress can't write plugins. Needs an active license only, not install_plugins,
223 + * and is allowed for tampered addons too: a clean copy is how those get fixed.
224 + */
225 + public function get_download_link( string $product_id ): array {
226 + if( ! $this->licenseService->is_active( $product_id ) ) {
227 + return [ 'success' => false, 'error' => __( 'No active license for this product', 'gvectors' ) ];
228 + }
229 +
230 + $download = $this->request_download( $product_id );
231 + if( empty( $download['success'] ) ) return $download;
232 +
233 + $download['file_name'] = ( $download['plugin_slug'] ?: 'addon' ) . '.zip';
234 +
235 + return $download;
236 + }
237 +
238 + /**
239 + * Request a signed, one-time download URL for a licensed addon from the proxy server
240 + */
241 + private function request_download( string $product_id ): array {
242 + $license = $this->licenseService->get( $product_id );
243 + if( empty( $license ) || empty( $license['license_key'] ) ) {
244 + return [ 'success' => false, 'error' => __( 'No active license for this product', 'gvectors' ) ];
245 + }
246 +
247 + $response = $this->licenseService->apiService->get_addon_download_url( $product_id, $license['license_key'] );
248 + error_log( '[gVectors Addon] download-url response: ' . print_r( $response, true ) );
249 + if( empty( $response['success'] ) || empty( $response['data']['download_url'] ) ) {
250 + $error = $response['error'] ?? __( 'Failed to get download URL', 'gvectors' );
251 + if( isset( $response['data']['error'] ) ) $error = $response['data']['error'];
252 + error_log( '[gVectors Addon] Failed to get download URL: ' . $error );
253 +
254 + return [ 'success' => false, 'error' => $error ];
255 + }
256 +
257 + $download_url = add_query_arg( 'site_domain', rawurlencode( LicenseModule::get_site_domain() ), $response['data']['download_url'] );
258 + $plugin_slug = self::sanitize_slug( $response['data']['plugin_slug'] ?? '' );
259 + error_log( '[gVectors Addon] download_url: ' . $download_url . ' | plugin_slug: ' . $plugin_slug );
260 +
261 + return [ 'success' => true, 'download_url' => $download_url, 'plugin_slug' => $plugin_slug ];
262 + }
263 +
264 + /**
135 265 * Download and install an addon from the proxy server
136 266 */
137 267 public function install( string $product_id ): array {
138 - if( ! current_user_can( 'install_plugins' ) ) {
139 - return [ 'success' => false, 'error' => __( 'Permission denied', 'gvectors' ) ];
268 + if( ! $this->can_install_addons() ) {
269 + return [
270 + 'success' => false,
271 + 'error' => __( 'WordPress is not allowed to install plugins on this site (file modifications are disabled or the plugins folder is not writable). Download the addon ZIP and upload it manually.', 'gvectors' ),
272 + 'manual_install' => true,
273 + ];
140 274 }
141 275
142 276 $license = $this->licenseService->get( $product_id );
143 277 if( empty( $license ) || empty( $license['license_key'] ) ) {
@@ -149,9 +283,9 @@
149 283 if( $plugin_slug && $this->is_addon_tampered( $plugin_slug ) ) {
150 284 return [
151 285 'success' => false,
152 286 'error' => __(
153 - 'This addon cannot be updated because its files have been modified or are not original. To resolve this, please: 1) Go to Plugins and deactivate, then delete this addon. 2) Visit the gVectors Store Addons page and make sure your license is active. 3) Re-install the addon from the gVectors Store Addons page. Once re-installed, everything will work normally again.',
287 + 'This addon cannot be updated because its files have been modified or are not original. To resolve this, open the gVectors Addons page and click "Reinstall Addon" for this addon (your license must be active): its files are replaced with a clean copy and everything works normally again.',
154 288 'gvectors'
155 289 ),
156 290 ];
157 291 }
@@ -156,22 +290,13 @@
156 290 ];
157 291 }
158 292
159 293 // Get signed download URL from proxy
160 - $response = $this->licenseService->apiService->get_addon_download_url( $product_id, $license['license_key'] );
161 - error_log( '[gVectors Addon] download-url response: ' . print_r( $response, true ) );
162 - if( empty( $response['success'] ) || empty( $response['data']['download_url'] ) ) {
163 - $error = $response['error'] ?? __( 'Failed to get download URL', 'gvectors' );
164 - if( isset( $response['data']['error'] ) ) $error = $response['data']['error'];
165 - error_log( '[gVectors Addon] Failed to get download URL: ' . $error );
166 -
167 - return [ 'success' => false, 'error' => $error ];
168 - }
294 + $download = $this->request_download( $product_id );
295 + if( empty( $download['success'] ) ) return $download;
169 296
170 - $download_url = $response['data']['download_url'];
171 - $download_url = add_query_arg( 'site_domain', rawurlencode( LicenseModule::get_site_domain() ), $download_url );
172 - $plugin_slug = $response['data']['plugin_slug'] ?? '';
173 - error_log( '[gVectors Addon] download_url: ' . $download_url . ' | plugin_slug: ' . $plugin_slug );
297 + $download_url = $download['download_url'];
298 + $plugin_slug = $download['plugin_slug'];
174 299
175 300 // Use WordPress built-in plugin installer
176 301 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
177 302 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
@@ -188,21 +313,24 @@
188 313 } else {
189 314 $result = $upgrader->install( $download_url );
190 315 }
191 316
317 + // Only filesystem/permission failures offer the manual (ZIP + FTP) install path; others (download, archive...) just report the error
192 318 if( is_wp_error( $result ) ) {
193 319 error_log( '[gVectors Addon] WP_Error from upgrader: ' . $result->get_error_message() );
194 320
195 - return [ 'success' => false, 'error' => $result->get_error_message() ];
321 + return [ 'success' => false, 'error' => $result->get_error_message(), 'manual_install' => self::is_filesystem_error( $result ) ];
196 322 }
197 323
198 324 if( $result === false ) {
199 325 $errors = $skin->get_errors();
200 - $error = is_wp_error( $errors ) ? $errors->get_error_message() : __( 'Installation failed', 'gvectors' );
326 + $has_errors = is_wp_error( $errors ) && $errors->has_errors();
327 + $error = $has_errors ? $errors->get_error_message() : __( 'Installation failed', 'gvectors' );
201 328 $skin_feedback = method_exists( $skin, 'get_upgrade_messages' ) ? $skin->get_upgrade_messages() : [];
202 329 error_log( '[gVectors Addon] Install result=false. Error: ' . $error . ' | Feedback: ' . print_r( $skin_feedback, true ) );
203 330
204 - return [ 'success' => false, 'error' => $error ];
331 + // false without errors: WP_Upgrader::fs_connect() needed filesystem credentials, which an AJAX request can't ask for
332 + return [ 'success' => false, 'error' => $error, 'manual_install' => ! $has_errors || self::is_filesystem_error( $errors ) ];
205 333 }
206 334
207 335 error_log( '[gVectors Addon] Install result: ' . print_r( $result, true ) );
208 336 error_log( '[gVectors Addon] Skin messages: ' . print_r( $skin->get_upgrade_messages(), true ) );
@@ -253,8 +381,95 @@
253 381 ];
254 382 }
255 383
256 384 /**
385 + * Is the installed copy unverified for this site — no signature manifest (e.g. installed from the old
386 + * gVectors store) or flagged by the integrity check? Side-effect free (no verification run, no API call).
387 + * Always false on development sites, where signatures aren't checked.
388 + */
389 + public function needs_verified_copy( string $plugin_slug ): bool {
390 + $plugin_slug = self::sanitize_slug( $plugin_slug );
391 + if( $plugin_slug === '' || LicenseModule::is_development_site() || ! $this->is_installed( $plugin_slug ) ) return false;
392 +
393 + return ! file_exists( WP_PLUGIN_DIR . '/' . $plugin_slug . '/.addon-signatures.json' ) || $this->is_addon_tampered( $plugin_slug );
394 + }
395 +
396 + /**
397 + * "Reinstall Addon": replace an installed licensed addon with a fresh copy from the store, signed for this
398 + * site — WordPress's "Replace current with uploaded". Settings and data (database) are kept; the addon keeps
399 + * its active state. Allowed for flagged addons too: a clean copy is how those get fixed.
400 + */
401 + public function install_verified_copy( string $product_id ): array {
402 + if( ! $this->licenseService->is_active( $product_id ) ) {
403 + return [ 'success' => false, 'error' => __( 'Your license isn\'t active. Renew it to reinstall the addon — it keeps working in the meantime.', 'gvectors' ) ];
404 + }
405 +
406 + $license = $this->licenseService->get( $product_id );
407 + $plugin_slug = self::sanitize_slug( $license['plugin_slug'] ?? '' );
408 + if( ! $plugin_slug || ! $this->is_installed( $plugin_slug ) ) {
409 + return [ 'success' => false, 'error' => __( 'This addon is not installed on this site.', 'gvectors' ) ];
410 + }
411 +
412 + if( ! $this->can_install_addons() ) {
413 + return [
414 + 'success' => false,
415 + 'error' => __( 'WordPress is not allowed to replace plugin files on this site. Download the addon ZIP and upload it manually — when uploading the ZIP in WordPress, choose "Replace current with uploaded".', 'gvectors' ),
416 + 'manual_install' => true,
417 + ];
418 + }
419 +
420 + $download = $this->request_download( $product_id );
421 + if( empty( $download['success'] ) ) return $download;
422 +
423 + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
424 + require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
425 + require_once ABSPATH . 'wp-admin/includes/file.php';
426 + require_once ABSPATH . 'wp-admin/includes/misc.php';
427 +
428 + $skin = new WP_Ajax_Upgrader_Skin();
429 + $upgrader = new Plugin_Upgrader( $skin );
430 + // install() + overwrite, not upgrade(): works without a pending update and replaces the folder in place
431 + $result = $upgrader->install( $download['download_url'], [ 'overwrite_package' => true ] );
432 +
433 + if( is_wp_error( $result ) ) {
434 + return [ 'success' => false, 'error' => $result->get_error_message(), 'manual_install' => self::is_filesystem_error( $result ) ];
435 + }
436 + if( $result === false ) {
437 + $errors = $skin->get_errors();
438 + $has_errors = is_wp_error( $errors ) && $errors->has_errors();
439 +
440 + return [
441 + 'success' => false,
442 + 'error' => $has_errors ? $errors->get_error_message() : __( 'Installation failed', 'gvectors' ),
443 + 'manual_install' => ! $has_errors || self::is_filesystem_error( $errors ),
444 + ];
445 + }
446 +
447 + wp_cache_delete( 'plugins', 'plugins' );
448 +
449 + // A fresh signed copy must verify — this also clears a tamper flag and re-activates an addon the check had deactivated
450 + if( $this->verify_addon_signatures( $plugin_slug ) !== 'valid' ) {
451 + return [
452 + 'success' => false,
453 + 'error' => __( 'Addon installed but signature verification failed. The download may have been corrupted. Please try again.', 'gvectors' ),
454 + ];
455 + }
456 + $this->clear_legacy_cache( $plugin_slug );
457 +
458 + $plugin_file = $this->get_installed_plugin_file( $plugin_slug );
459 + $plugin_data = $plugin_file ? get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, false, false ) : [];
460 +
461 + return [
462 + 'success' => true,
463 + 'message' => sprintf(
464 + /* translators: %s: addon name */
465 + __( '%s was reinstalled and is now verified for this site.', 'gvectors' ),
466 + ! empty( $plugin_data['Name'] ) ? $plugin_data['Name'] : $plugin_slug
467 + ),
468 + ];
469 + }
470 +
471 + /**
257 472 * Check if an addon is flagged as tampered/unauthorized
258 473 */
259 474 public function is_addon_tampered( string $plugin_slug ): bool {
260 475 $tampered = get_option( $this->tampered_option, [] );
@@ -334,23 +549,138 @@
334 549
335 550 /**
336 551 * Fetch all addon info from the proxy server, keyed by slug.
337 552 * Returns associative array: slug => [ name, version, description, author, requires, tested, requires_php, plugin_uri, ... ]
553 + * Host plugins (wpForo, wpDiscuz, ...) are never part of the map — they update from wordpress.org.
338 554 */
339 555 private function get_proxy_addons_map(): array {
340 556 $response = $this->licenseService->apiService->get_all_addons();
341 - if( empty( $response['success'] ) || empty( $response['data']['addons'] ) ) {
557 + if( empty( $response['success'] ) || empty( $response['data']['addons'] ) || ! is_array( $response['data']['addons'] ) ) {
342 558 return [];
343 559 }
344 560 $map = [];
345 561 foreach( $response['data']['addons'] as $addon ) {
346 - if( ! empty( $addon['slug'] ) ) {
562 + if( ! empty( $addon['slug'] ) && is_string( $addon['slug'] ) && ! $this->is_host_plugin( $addon['slug'] ) ) {
347 563 $map[ $addon['slug'] ] = $addon;
348 564 }
349 565 }
350 -
566 + $this->remember_store_addons( $map );
567 +
351 568 return $map;
352 569 }
570 +
571 + /**
572 + * All addons sold in the gVectors store: slug => host plugin slugs the addon belongs to
573 + * (from the products' Paddle `parent_slug`; [] = belongs to every host, e.g. wpForo AND wpDiscuz).
574 + * This list is the ONLY way an installed plugin is recognized as one of our addons — plugin/folder
575 + * names are never used. Falls back to the last successfully fetched list while the store is unreachable.
576 + *
577 + * @param bool $allow_remote false = never make an HTTP request (for page-load paths like admin_init)
578 + */
579 + private function get_store_addons( bool $allow_remote = true ): array {
580 + if( $this->store_addons !== null ) return $this->store_addons;
581 +
582 + if( $allow_remote ) {
583 + $map = $this->get_proxy_addons_map();
584 + if( ! empty( $map ) ) return $this->store_addons = self::extract_parent_slugs( $map );
585 + }
586 +
587 + $known = get_option( $this->store_addons_option, [] );
588 + if( ! is_array( $known ) ) return [];
589 +
590 + $addons = [];
591 + foreach( $known as $slug => $parents ) {
592 + if( is_string( $slug ) && $slug !== '' && ! $this->is_host_plugin( $slug ) ) {
593 + $addons[ $slug ] = is_array( $parents ) ? $parents : [];
594 + }
595 + }
596 +
597 + return $addons;
598 + }
599 +
600 + /**
601 + * slug => sanitized host plugin slugs from the store's `parent_slugs` ([] or missing = all hosts).
602 + */
603 + private static function extract_parent_slugs( array $proxy_addons ): array {
604 + $addons = [];
605 + foreach( $proxy_addons as $slug => $addon ) {
606 + $parents = isset( $addon['parent_slugs'] ) && is_array( $addon['parent_slugs'] ) ? $addon['parent_slugs'] : [];
607 + $parents = array_values( array_unique( array_filter( $parents, function( $parent ) {
608 + return is_string( $parent ) && $parent !== '';
609 + } ) ) );
610 + sort( $parents );
611 + $addons[ (string) $slug ] = $parents;
612 + }
613 + ksort( $addons );
614 +
615 + return $addons;
616 + }
617 +
618 + /**
619 + * Persist the store addon list (not autoloaded) so addon checks keep working during store outages.
620 + */
621 + private function remember_store_addons( array $proxy_addons ): void {
622 + if( empty( $proxy_addons ) ) return;
623 + $addons = self::extract_parent_slugs( $proxy_addons );
624 + if( get_option( $this->store_addons_option ) !== $addons ) {
625 + update_option( $this->store_addons_option, $addons, false );
626 + }
627 + }
628 +
629 + /**
630 + * Does the addon belong to this host plugin? Products with an empty/missing Paddle `parent_slug`
631 + * belong to every host; otherwise only to the listed host(s).
632 + */
633 + private function addon_belongs_to_host( string $plugin_slug, bool $allow_remote = true ): bool {
634 + $parents = $this->get_store_addons( $allow_remote )[ $plugin_slug ] ?? [];
635 +
636 + return empty( $parents ) || in_array( $this->config->get_core_plugin_slug(), $parents, true );
637 + }
638 +
639 + /**
640 + * Should this host instance handle the addon (updates without own license, activation gate,
641 + * integrity scan, notices)? Yes when this host holds a license for it; otherwise only when no
642 + * other host holds a license and the addon belongs to this host (or to all hosts).
643 + */
644 + private function manages_addon( string $plugin_slug, bool $allow_remote = true ): bool {
645 + if( $this->addon_has_license( $plugin_slug ) ) return true;
646 + if( $this->is_licensed_by_other_host( $plugin_slug ) ) return false;
647 +
648 + return $this->addon_belongs_to_host( $plugin_slug, $allow_remote );
649 + }
650 +
651 + /**
652 + * Host plugins running this module (wpForo, wpDiscuz, ...) are distributed via wordpress.org.
653 + * They must never be treated as store addons, so their core updates are never touched.
654 + */
655 + private function is_host_plugin( string $plugin_slug ): bool {
656 + return $plugin_slug === $this->config->get_core_plugin_slug() || in_array( $plugin_slug, LicenseModule::get_host_slugs(), true );
657 + }
658 +
659 + /**
660 + * Does another host plugin on this site (e.g. wpDiscuz when this instance is wpForo) hold a license for the addon?
661 + * That host's instance then owns the addon's updates, activation gate and integrity checks.
662 + */
663 + private function is_licensed_by_other_host( string $plugin_slug ): bool {
664 + foreach( LicenseModule::get_host_slugs() as $host ) {
665 + if( $host === $this->config->get_core_plugin_slug() ) continue;
666 + $actions = LicenseModule::getActionsService( $host );
667 + if( $actions && $actions->addonsService->addon_has_license( $plugin_slug ) ) return true;
668 + }
669 +
670 + return false;
671 + }
672 +
673 + /**
674 + * Claim the right to render a per-addon notice/row once per request across all host plugin instances.
675 + */
676 + private static function claim_notice( string $type, string $plugin_slug ): bool {
677 + $key = $type . ':' . $plugin_slug;
678 + if( isset( self::$notices_shown[ $key ] ) ) return false;
679 + self::$notices_shown[ $key ] = true;
680 +
681 + return true;
682 + }
353 683
354 684 /**
355 685 * Verify signatures of a single addon by its slug.
356 686 * Checks: manifest existence, file hashes, domain signature, PHP header signatures.
@@ -373,13 +703,21 @@
373 703 if( $patch_check !== 'valid' ) {
374 704 return $patch_check;
375 705 }
376 706
707 + // Licensed on this site (a purchase, or an old-store key linked on the Addons page) —
708 + // a copy installed before the signature system is not piracy
709 + if( $this->addon_has_license( $plugin_slug ) ) {
710 + $this->clear_tamper_flag( $plugin_slug, true );
711 +
712 + return 'legacy_valid';
713 + }
714 +
377 715 // Check if this addon has a legacy license from the old gVectors system
378 716 $legacy = $this->check_legacy_license( $plugin_slug );
379 717 if( ! empty( $legacy['has_license'] ) ) {
380 718 // Legacy licensed addon — clear any previous tamper flags
381 - $this->clear_tamper_flag( $plugin_slug );
719 + $this->clear_tamper_flag( $plugin_slug, true );
382 720 // Track expired legacy licenses for admin notice
383 721 $this->update_legacy_notice( $plugin_slug, $legacy );
384 722
385 723 return 'legacy_valid';
@@ -384,8 +722,11 @@
384 722
385 723 return 'legacy_valid';
386 724 }
387 725
726 + // Store unreachable and nothing known about this addon yet — never flag on missing data
727 + if( ! empty( $legacy['unknown'] ) ) return 'legacy_valid';
728 +
388 729 // No legacy license either — this is an unauthorized copy
389 730 $this->mark_addon_tampered( $plugin_slug, [ 'Missing signature manifest' ], 'no_manifest' );
390 731
391 732 return 'no_manifest';
@@ -517,9 +858,9 @@
517 858 return $patch_check;
518 859 }
519 860
520 861 // All checks passed - clear any previous tamper flags
521 - $this->clear_tamper_flag( $plugin_slug );
862 + $this->clear_tamper_flag( $plugin_slug, true );
522 863
523 864 return 'valid';
524 865 }
525 866
@@ -635,36 +976,58 @@
635 976
636 977 $response = $this->licenseService->apiService->check_legacy_license( $plugin_slug );
637 978
638 979 if( ! empty( $response['success'] ) && ! empty( $response['data'] ) ) {
639 - $data = $response['data'];
640 - $legacy_data = [
641 - 'has_license' => ! empty( $data['has_legacy_license'] ),
642 - 'status' => $data['status'] ?? '',
643 - 'expired' => ! empty( $data['expired'] ),
644 - 'expired_time' => isset( $data['expired_time'] ) ? (int) $data['expired_time'] : 0,
645 - 'last_checked' => time(),
646 - ];
647 - $this->save_cached_legacy_license( $plugin_slug, $legacy_data );
648 -
649 - return $legacy_data;
980 + return $this->save_cached_legacy_license( $plugin_slug, self::legacy_result_from_api( $response['data'] ) );
650 981 }
651 982
652 - // API call failed — cache a negative result with a shorter TTL (1 hour)
653 - // so we retry sooner, but don't hammer the server on every cron run
654 - $negative = [
983 + // API call failed — keep the last known answer, or record "unknown" (callers never flag on it).
984 + // Either way retry in an hour instead of hammering the server on every check.
985 + $all_legacy = get_option( $this->legacy_licenses_option, [] );
986 + $known = $all_legacy[ $plugin_slug ] ?? [
655 987 'has_license' => false,
988 + 'unknown' => true,
656 989 'status' => '',
657 990 'expired' => false,
658 991 'expired_time' => 0,
659 - 'last_checked' => time() - $this->config->get_legacy_check_period() + HOUR_IN_SECONDS,
660 992 ];
661 - $this->save_cached_legacy_license( $plugin_slug, $negative );
993 + $known['last_checked'] = time() - $this->config->get_legacy_check_period() + HOUR_IN_SECONDS;
994 + $all_legacy[ $plugin_slug ] = $known;
995 + update_option( $this->legacy_licenses_option, $all_legacy );
662 996
663 - return $negative;
997 + return $known;
664 998 }
665 999
666 1000 /**
1001 + * Normalize a proxy legacy-check result (single or batch entry) into the local cache format.
1002 + */
1003 + private static function legacy_result_from_api( array $data ): array {
1004 + return [
1005 + 'has_license' => ! empty( $data['has_legacy_license'] ),
1006 + 'status' => $data['status'] ?? '',
1007 + 'expired' => ! empty( $data['expired'] ),
1008 + 'expired_time' => isset( $data['expired_time'] ) ? (int) $data['expired_time'] : 0,
1009 + 'last_checked' => time(),
1010 + ];
1011 + }
1012 +
1013 + /**
1014 + * Merge a fresh legacy-check result into the cached one. Legitimacy is sticky: once a legacy license
1015 + * was confirmed for this site, a later negative answer never revokes it — the legacy database is a
1016 + * frozen snapshot, so a negative can only come from a server-side problem. forget_addon() (addon
1017 + * deleted) is the only way to drop it.
1018 + */
1019 + private static function merge_legacy_result( array $previous, array $fresh ): array {
1020 + if( empty( $fresh['has_license'] ) && ! empty( $previous['has_license'] ) ) {
1021 + $previous['last_checked'] = $fresh['last_checked'] ?? time();
1022 +
1023 + return $previous;
1024 + }
1025 +
1026 + return $fresh;
1027 + }
1028 +
1029 + /**
667 1030 * Get cached legacy license data for a slug.
668 1031 * Returns the cached array or false if not cached or stale.
669 1032 */
670 1033 private function get_cached_legacy_license( string $plugin_slug ) {
@@ -684,14 +1047,16 @@
684 1047 // Activation Gate
685 1048 // ==========================================
686 1049
687 1050 /**
688 - * Save legacy license check result to the persistent cache.
1051 + * Save a legacy license check result to the persistent cache (see merge_legacy_result()) and return what was stored.
689 1052 */
690 - private function save_cached_legacy_license( string $plugin_slug, array $data ): void {
1053 + private function save_cached_legacy_license( string $plugin_slug, array $data ): array {
691 1054 $all_legacy = get_option( $this->legacy_licenses_option, [] );
692 - $all_legacy[ $plugin_slug ] = $data;
1055 + $all_legacy[ $plugin_slug ] = self::merge_legacy_result( $all_legacy[ $plugin_slug ] ?? [], $data );
693 1056 update_option( $this->legacy_licenses_option, $all_legacy );
1057 +
1058 + return $all_legacy[ $plugin_slug ];
694 1059 }
695 1060
696 1061 // ==========================================
697 1062 // Signature & Piracy Verification
@@ -698,14 +1063,19 @@
698 1063 // ==========================================
699 1064
700 1065 /**
701 1066 * Clear tamper flag for an addon
1067 + *
1068 + * @param bool $restore The addon now verifies: re-activate it if the tamper check had deactivated it
1069 + * (e.g. an old-store license that wasn't recognized before)
702 1070 */
703 - private function clear_tamper_flag( string $plugin_slug ): void {
1071 + private function clear_tamper_flag( string $plugin_slug, bool $restore = false ): void {
704 1072 $tampered = get_option( $this->tampered_option, [] );
705 1073 if( isset( $tampered[ $plugin_slug ] ) ) {
1074 + $deactivated = ! empty( $tampered[ $plugin_slug ]['deactivated_at'] );
706 1075 unset( $tampered[ $plugin_slug ] );
707 1076 update_option( $this->tampered_option, $tampered );
1077 + if( $restore && $deactivated ) $this->reactivate_addon( $plugin_slug );
708 1078 }
709 1079
710 1080 // Also clear the seen flag
711 1081 $seen = get_option( $this->tamper_dismissed_option, [] );
@@ -715,8 +1085,29 @@
715 1085 }
716 1086 }
717 1087
718 1088 /**
1089 + * Re-activate an addon the tamper check had deactivated but that now verifies.
1090 + * Activated silently (the activation gate would wp_die() in cron on its own checks), then its own
1091 + * activation hook runs — deactivating it ran the deactivation hook. Skipped while a plugin
1092 + * activation is in progress: WordPress is activating it right now (nesting would duplicate it).
1093 + */
1094 + private function reactivate_addon( string $plugin_slug ): void {
1095 + if( doing_action( 'activate_plugin' ) ) return;
1096 + if( ! function_exists( 'activate_plugin' ) ) {
1097 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
1098 + }
1099 +
1100 + $plugin_file = $this->get_installed_plugin_file( $plugin_slug );
1101 + if( ! $plugin_file || is_plugin_active( $plugin_file ) ) return;
1102 +
1103 + // A WP_Error for unexpected output still leaves the plugin active — check the result, not the return value
1104 + activate_plugin( $plugin_file, '', false, true );
1105 + if( ! is_plugin_active( $plugin_file ) ) return;
1106 + do_action( 'activate_' . $plugin_file, false );
1107 + }
1108 +
1109 + /**
719 1110 * Track legacy-licensed addons that have expired licenses for admin notice.
720 1111 */
721 1112 private function update_legacy_notice( string $plugin_slug, array $legacy_data ): void {
722 1113 $notices = get_option( $this->legacy_notice_option, [] );
@@ -887,14 +1278,13 @@
887 1278 continue;
888 1279 }
889 1280
890 1281 $plugin_slug = $license['plugin_slug'] ?? '';
891 - if( empty( $plugin_slug ) ) continue;
1282 + if( empty( $plugin_slug ) || $this->is_host_plugin( $plugin_slug ) ) continue;
892 1283
893 1284 $plugin_file = $this->get_installed_plugin_file( $plugin_slug );
894 1285 if( ! $plugin_file ) continue;
895 1286
896 -
897 1287 $current_version = $transient->checked[ $plugin_file ] ?? '0.0.0';
898 1288
899 1289 // Use proxy server version (from addon file header) instead of local options
900 1290 $proxy_info = $proxy_addons[ $plugin_slug ] ?? [];
@@ -946,8 +1336,11 @@
946 1336 if( in_array( $plugin_file, $licensed_plugin_files, true ) ) continue;
947 1337
948 1338 // Only process known gVectors addons from the proxy
949 1339 if( ! isset( $proxy_addons[ $plugin_slug ] ) ) continue;
1340 +
1341 + // Licensed via / belongs to another host plugin — its instance builds this addon's update entry
1342 + if( ! $this->manages_addon( $plugin_slug ) ) continue;
950 1343
951 1344 // Migrate legacy license to new system eagerly — even without a pending update.
952 1345 // On success, save() stores the license in gvectors_licenses so the Paddle loop
953 1346 // handles this slug on the next check_for_updates() call.
@@ -1006,10 +1399,11 @@
1006 1399 $uncached_slugs = [];
1007 1400 foreach( $all_plugins as $_pf => $_pd ) {
1008 1401 if( in_array( $_pf, $licensed_plugin_files, true ) ) continue;
1009 1402 $_slug = dirname( $_pf );
1010 - if( $_slug === '.' || $_slug === $this->config->get_core_plugin_slug() ) continue;
1011 - if( ! isset( $proxy_addons[ $_slug ] ) ) continue;
1403 + // Store addons only (host plugins are never in the proxy map)
1404 + if( $_slug === '.' || ! isset( $proxy_addons[ $_slug ] ) ) continue;
1405 + if( ! $this->manages_addon( $_slug ) ) continue;
1012 1406 if( ! isset( $all_legacy[ $_slug ] ) ) $uncached_slugs[] = $_slug;
1013 1407 }
1014 1408 if( ! empty( $uncached_slugs ) ) {
1015 1409 $this->check_legacy_licenses_batch( array_unique( $uncached_slugs ) );
@@ -1063,12 +1457,14 @@
1063 1457 // Skip if already handled by licensed update above
1064 1458 if( in_array( $plugin_file, $licensed_plugin_files, true ) ) continue;
1065 1459
1066 1460 $slug = dirname( $plugin_file );
1067 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue;
1068 1461
1069 - // Only process known gVectors addons from the proxy
1070 - if( ! isset( $proxy_addons[ $slug ] ) ) continue;
1462 + // Only process known gVectors addons from the proxy (host plugins are never in the map)
1463 + if( $slug === '.' || ! isset( $proxy_addons[ $slug ] ) ) continue;
1464 +
1465 + // Licensed via / belongs to another host plugin — don't overwrite that host's update entry
1466 + if( ! $this->manages_addon( $slug ) ) continue;
1071 1467
1072 1468 $proxy_info = $proxy_addons[ $slug ];
1073 1469 $latest_version = ! empty( $proxy_info['version'] ) ? $proxy_info['version'] : '';
1074 1470 $current_version = $transient->checked[ $plugin_file ] ?? '0.0.0';
@@ -1089,8 +1485,21 @@
1089 1485 $transient->response[ $plugin_file ] = $update;
1090 1486 }
1091 1487 }
1092 1488
1489 + // Entitled updates (licensed / legacy-licensed, with a download package) this site can't install
1490 + $entitled = [];
1491 + foreach( array_unique( $licensed_plugin_files ) as $plugin_file ) {
1492 + $update = $transient->response[ $plugin_file ] ?? null;
1493 + if( ! $update || empty( $update->package ) ) continue;
1494 + $entitled[ $update->slug ] = [
1495 + 'name' => ! empty( $all_plugins[ $plugin_file ]['Name'] ) ? $all_plugins[ $plugin_file ]['Name'] : ( $proxy_addons[ $update->slug ]['name'] ?? $update->slug ),
1496 + 'current_version' => (string) ( $transient->checked[ $plugin_file ] ?? '' ),
1497 + 'new_version' => (string) $update->new_version,
1498 + ];
1499 + }
1500 + $this->queue_blocked_updates( $entitled );
1501 +
1093 1502 return $transient;
1094 1503 }
1095 1504
1096 1505 /**
@@ -1148,9 +1557,10 @@
1148 1557 }
1149 1558
1150 1559 /**
1151 1560 * Batch-check legacy licenses for multiple addon slugs.
1152 - * Populates the local cache for all slugs in one API call.
1561 + * Populates the local cache for all slugs in one API call (slugs the server didn't return count as negative).
1562 + * When the store can't be reached the cache is left untouched.
1153 1563 */
1154 1564 private function check_legacy_licenses_batch( array $plugin_slugs ): void {
1155 1565 if( empty( $plugin_slugs ) ) return;
1156 1566
@@ -1155,30 +1565,13 @@
1155 1565 if( empty( $plugin_slugs ) ) return;
1156 1566
1157 1567 $response = $this->licenseService->apiService->check_legacy_licenses_batch( $plugin_slugs );
1158 1568
1159 - if( ! empty( $response['success'] ) && ! empty( $response['data']['addons'] ) ) {
1569 + if( ! empty( $response['success'] ) && ! empty( $response['data']['addons'] ) && is_array( $response['data']['addons'] ) ) {
1160 1570 $all_legacy = get_option( $this->legacy_licenses_option, [] );
1161 - foreach( $response['data']['addons'] as $slug => $data ) {
1162 - $all_legacy[ $slug ] = [
1163 - 'has_license' => ! empty( $data['has_legacy_license'] ),
1164 - 'status' => $data['status'] ?? '',
1165 - 'expired' => ! empty( $data['expired'] ),
1166 - 'expired_time' => isset( $data['expired_time'] ) ? (int) $data['expired_time'] : 0,
1167 - 'last_checked' => time(),
1168 - ];
1169 - }
1170 - // Also cache negative results for slugs not returned by the server
1171 1571 foreach( $plugin_slugs as $slug ) {
1172 - if( ! isset( $all_legacy[ $slug ] ) || $all_legacy[ $slug ]['last_checked'] < time() - 60 ) {
1173 - $all_legacy[ $slug ] = [
1174 - 'has_license' => false,
1175 - 'status' => '',
1176 - 'expired' => false,
1177 - 'expired_time' => 0,
1178 - 'last_checked' => time(),
1179 - ];
1180 - }
1572 + $data = $response['data']['addons'][ $slug ] ?? [];
1573 + $all_legacy[ $slug ] = self::merge_legacy_result( $all_legacy[ $slug ] ?? [], self::legacy_result_from_api( is_array( $data ) ? $data : [] ) );
1181 1574 }
1182 1575 update_option( $this->legacy_licenses_option, $all_legacy );
1183 1576 }
1184 1577 }
@@ -1183,8 +1576,42 @@
1183 1576 }
1184 1577 }
1185 1578
1186 1579 /**
1580 + * WordPress's update check (twice-daily wp_update_plugins cron, or the Updates/Plugins screens)
1581 + * found new versions of licensed addons, but this site blocks plugin installs — so neither the
1582 + * auto-updater (disabled outright by DISALLOW_FILE_MODS) nor the Updates screen can install them.
1583 + * The versions are merged into a shared queue and a single cron event hands them to the news
1584 + * module (`gvectors_blocked_updates` → one email per admin, deduped per addon version). Never
1585 + * sends from here: the update check can run during a page load.
1586 + */
1587 + private function queue_blocked_updates( array $updates ): void {
1588 + if( ! $updates || self::site_can_install_plugins() ) return;
1589 +
1590 + $queued = get_option( self::BLOCKED_UPDATES_OPTION, [] );
1591 + $queued = is_array( $queued ) ? $queued : [];
1592 + $merged = array_merge( $queued, $updates );
1593 + if( $merged !== $queued ) {
1594 + update_option( self::BLOCKED_UPDATES_OPTION, $merged, false );
1595 + }
1596 + if( ! wp_next_scheduled( self::BLOCKED_UPDATES_HOOK ) ) {
1597 + wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::BLOCKED_UPDATES_HOOK );
1598 + }
1599 + }
1600 +
1601 + /**
1602 + * Cron: hand the queued blocked updates to the news module (sends the admin emails via wp_mail).
1603 + * Skipped when the site can install plugins again by now — WordPress will just update normally.
1604 + */
1605 + public static function notify_blocked_updates(): void {
1606 + $updates = get_option( self::BLOCKED_UPDATES_OPTION, [] );
1607 + delete_option( self::BLOCKED_UPDATES_OPTION );
1608 + if( ! is_array( $updates ) || ! $updates || self::site_can_install_plugins() ) return;
1609 +
1610 + do_action( 'gvectors_blocked_updates', $updates );
1611 + }
1612 +
1613 + /**
1187 1614 * Intercept WordPress updater package downloads to block tampered addons with a visible error.
1188 1615 * This hooks into 'upgrader_pre_download' so the user sees a clear message in the update UI.
1189 1616 * The actual download is handled by the proxy server's addon/wp-download endpoint (302 redirect).
1190 1617 */
@@ -1213,9 +1640,9 @@
1213 1640 if( $plugin_slug && $this->is_addon_tampered( $plugin_slug ) ) {
1214 1641 return new WP_Error(
1215 1642 'tampered_addon',
1216 1643 __(
1217 - 'This addon cannot be updated because its files have been modified or are not original. To resolve this, please: 1) Go to Plugins and deactivate, then delete this addon. 2) Visit the gVectors Store Addons page and make sure your license is active. 3) Re-install the addon from the gVectors Store Addons page. Once re-installed, everything will work normally again.',
1644 + 'This addon cannot be updated because its files have been modified or are not original. To resolve this, open the gVectors Addons page and click "Reinstall Addon" for this addon (your license must be active): its files are replaced with a clean copy and everything works normally again.',
1218 1645 'gvectors'
1219 1646 )
1220 1647 );
1221 1648 }
@@ -1256,19 +1683,21 @@
1256 1683 * Block activation with wp_die() if any check fails.
1257 1684 */
1258 1685 public function validate_on_activation( string $plugin_file ): void {
1259 1686 $slug = dirname( $plugin_file );
1260 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) return;
1687 + if( $slug === '.' || $this->is_host_plugin( $slug ) ) return;
1261 1688
1262 1689 // Skip all checks on development/local/staging environments
1263 1690 if( LicenseModule::is_development_site() ) return;
1264 1691
1265 - // Check if this is a known gVectors addon
1266 - $all_addon_slugs = $this->get_all_addon_slugs_from_proxy();
1267 - if( ! $this->is_known_addon( $slug, $all_addon_slugs ) ) return;
1268 -
1692 + // Check if this is an addon from the gVectors store list
1693 + if( ! $this->is_known_addon( $slug ) ) return;
1694 +
1695 + // Licensed via / belongs to another host plugin (e.g. wpDiscuz) — that host's activation gate validates it
1696 + if( ! $this->manages_addon( $slug ) ) return;
1697 +
1269 1698 $reasons = [];
1270 -
1699 +
1271 1700 // 1) License check — new Paddle license OR legacy gVectors license
1272 1701 $has_new_license = $this->addon_has_license( $slug );
1273 1702 $has_legacy_license = false;
1274 1703 if( ! $has_new_license ) {
@@ -1280,16 +1709,9 @@
1280 1709
1281 1710 // 2) Signature & integrity checks
1282 1711 $sig_result = $this->verify_addon_signatures( $slug );
1283 1712 if( $sig_result !== 'valid' && $sig_result !== 'legacy_valid' ) {
1284 - $labels = [
1285 - 'no_manifest' => __( 'Missing signature manifest — addon was not installed through the official channel.', 'gvectors' ),
1286 - 'tampered' => __( 'File integrity check failed — one or more addon files have been modified.', 'gvectors' ),
1287 - 'domain_mismatch' => __( 'Domain mismatch — this addon copy is signed for a different website.', 'gvectors' ),
1288 - 'no_signatures' => __( 'Missing PHP header signatures — addon files lack required security headers.', 'gvectors' ),
1289 - 'patched' => __( 'Nulled/patched code detected — this addon appears to be a pirated copy.', 'gvectors' ),
1290 - ];
1291 - $reasons[] = $labels[ $sig_result ] ?? __( 'Addon verification failed.', 'gvectors' );
1713 + $reasons[] = self::signature_failure_reason( $sig_result );
1292 1714 }
1293 1715
1294 1716 if( ! empty( $reasons ) ) {
1295 1717 // Store a transient so we can show an admin notice on redirect back
@@ -1306,30 +1728,33 @@
1306 1728 }
1307 1729 }
1308 1730
1309 1731 /**
1310 - * Fetch all known addon slugs from the proxy server.
1311 - * Returns array of slug strings.
1732 + * Human-readable reason for a failed verify_addon_signatures() result
1312 1733 */
1313 - private function get_all_addon_slugs_from_proxy(): array {
1314 - $response = $this->licenseService->apiService->get_all_addons();
1315 - if( empty( $response['success'] ) || empty( $response['data']['addons'] ) ) {
1316 - return [];
1317 - }
1734 + private static function signature_failure_reason( string $sig_result ): string {
1735 + $labels = [
1736 + 'no_manifest' => __( 'No license was found for this copy on this site. If you bought it on our old gVectors store, enter your old license key on the Addons page to link it to this site.', 'gvectors' ),
1737 + 'tampered' => __( 'File integrity check failed — one or more addon files have been modified.', 'gvectors' ),
1738 + 'domain_mismatch' => __( 'Domain mismatch — this addon copy is signed for a different website.', 'gvectors' ),
1739 + 'no_signatures' => __( 'Missing PHP header signatures — addon files lack required security headers.', 'gvectors' ),
1740 + 'patched' => __( 'Nulled/patched code detected — this addon appears to be a pirated copy.', 'gvectors' ),
1741 + ];
1318 1742
1319 - return array_column( $response['data']['addons'], 'slug' );
1743 + return $labels[ $sig_result ] ?? __( 'Addon verification failed.', 'gvectors' );
1320 1744 }
1321 1745
1322 1746 /**
1323 - * Check if a plugin slug is a known gVectors addon by querying the proxy's full addon list.
1747 + * Check if a plugin slug is a gVectors store addon — decided only by the store server's addon list,
1748 + * never by the plugin's name (e.g. "forums-censure-pro" is recognized just like "wpforo-polls").
1749 + * When no store list has ever been fetched, nothing is treated as an addon (fail open).
1750 + *
1751 + * @param bool $allow_remote false = never make an HTTP request (for page-load paths like admin_init)
1324 1752 */
1325 - private function is_known_addon( string $plugin_slug, array $all_addon_slugs = [] ): bool {
1326 - if( ! empty( $all_addon_slugs ) ) {
1327 - return in_array( $plugin_slug, $all_addon_slugs, true );
1328 - }
1753 + private function is_known_addon( string $plugin_slug, bool $allow_remote = true ): bool {
1754 + if( $plugin_slug === '' || $this->is_host_plugin( $plugin_slug ) ) return false;
1329 1755
1330 - // Fallback: check by naming convention
1331 - return ( strpos( $plugin_slug, $this->config->get_core_plugin_slug() . '-' ) === 0 || strpos( $plugin_slug, $this->config->get_core_plugin_slug() . '_' ) === 0 );
1756 + return isset( $this->get_store_addons( $allow_remote )[ $plugin_slug ] );
1332 1757 }
1333 1758
1334 1759 /**
1335 1760 * Check if an addon slug has an associated license (local) or is a known addon from proxy.
@@ -1345,15 +1770,27 @@
1345 1770 return false;
1346 1771 }
1347 1772
1348 1773 /**
1774 + * Does this host hold an active (or trial, not expired) license for the addon?
1775 + */
1776 + private function has_active_license( string $plugin_slug ): bool {
1777 + foreach( $this->licenseService->get_all() as $product_id => $license ) {
1778 + if( ( $license['plugin_slug'] ?? '' ) === $plugin_slug && $this->licenseService->is_active( (string) $product_id ) ) return true;
1779 + }
1780 +
1781 + return false;
1782 + }
1783 +
1784 + /**
1349 1785 * Quick check: does this addon have a legacy license (from cache)?
1350 - * Returns true if the cached legacy license exists and is valid.
1786 + * Returns true if the cached legacy license exists and is valid, or when the store couldn't be
1787 + * asked yet (unknown — fail open, never block a customer on missing data).
1351 1788 */
1352 1789 private function has_legacy_license( string $plugin_slug ): bool {
1353 1790 $legacy = $this->check_legacy_license( $plugin_slug );
1354 1791
1355 - return ! empty( $legacy['has_license'] );
1792 + return ! empty( $legacy['has_license'] ) || ! empty( $legacy['unknown'] );
1356 1793 }
1357 1794
1358 1795 /**
1359 1796 * Verify all installed addons — uses the proxy's full addon list (not just local licenses).
@@ -1365,11 +1802,8 @@
1365 1802
1366 1803 // Skip all checks on development/local/staging environments
1367 1804 if( LicenseModule::is_development_site() ) return;
1368 1805
1369 - // Get the full list of known addon slugs from the proxy server
1370 - $all_addon_slugs = $this->get_all_addon_slugs_from_proxy();
1371 -
1372 1806 // Collect locally licensed plugin slugs
1373 1807 $licenses = $this->licenseService->get_all();
1374 1808 $licensed_slugs = [];
1375 1809 foreach( $licenses as $product_id => $license ) {
@@ -1377,11 +1811,11 @@
1377 1811 if( ! empty( $slug ) ) $licensed_slugs[] = $slug;
1378 1812 }
1379 1813
1380 1814 // Scan for installed addons that are known to the proxy but have no license
1381 - $this->scan_unlicensed_addons( $licensed_slugs, $all_addon_slugs );
1815 + $this->scan_unlicensed_addons( $licensed_slugs );
1382 1816
1383 - // Verify signatures for all installed plugins that match known addon slugs
1817 + // Verify signatures for all installed plugins that are in the store addon list
1384 1818 if( ! function_exists( 'get_plugins' ) ) {
1385 1819 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1386 1820 }
1387 1821 $all_plugins = get_plugins();
@@ -1387,13 +1821,20 @@
1387 1821 $all_plugins = get_plugins();
1388 1822
1389 1823 foreach( $all_plugins as $file => $data ) {
1390 1824 $slug = dirname( $file );
1391 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue;
1825 + if( $slug === '.' ) continue;
1392 1826
1393 - // Check against proxy's known addon list
1394 - if( ! $this->is_known_addon( $slug, $all_addon_slugs ) ) continue;
1827 + // Check against the store's addon list (host plugins excluded)
1828 + if( ! $this->is_known_addon( $slug ) ) continue;
1395 1829 if( ! $this->is_installed( $slug ) ) continue;
1830 +
1831 + // Licensed via / belongs to another host plugin — that host verifies it; drop this host's stale tracking
1832 + if( ! $this->manages_addon( $slug ) ) {
1833 + $this->clear_tamper_flag( $slug );
1834 + $this->clear_legacy_cache( $slug );
1835 + continue;
1836 + }
1396 1837
1397 1838 $result = $this->verify_addon_signatures( $slug );
1398 1839 if( $result !== 'valid' && $result !== 'legacy_valid' ) {
1399 1840 $this->maybe_deactivate_tampered( $slug );
@@ -1401,13 +1842,32 @@
1401 1842 }
1402 1843 }
1403 1844
1404 1845 /**
1846 + * Right after licenses were activated on the Addons page: re-verify this host's licensed addons that
1847 + * are flagged, so an addon flagged only for lacking a license (e.g. installed from the old store and
1848 + * now linked by its old key) is cleared — and re-activated if the tamper check deactivated it —
1849 + * at once instead of on the next cron run.
1850 + */
1851 + public function reverify_licensed_addons(): void {
1852 + if( LicenseModule::is_development_site() ) return;
1853 +
1854 + $tampered = get_option( $this->tampered_option, [] );
1855 + if( empty( $tampered ) ) return;
1856 +
1857 + foreach( $this->licenseService->get_all() as $license ) {
1858 + $slug = self::sanitize_slug( (string) ( $license['plugin_slug'] ?? '' ) );
1859 + if( $slug === '' || ! isset( $tampered[ $slug ] ) || ! $this->is_installed( $slug ) ) continue;
1860 + $this->verify_addon_signatures( $slug );
1861 + }
1862 + }
1863 +
1864 + /**
1405 1865 * Scan for installed plugins that are known gVectors addons (from the proxy list) but have no license.
1406 1866 * These could be pirated copies installed manually, OR legacy-licensed installations.
1407 1867 * Checks legacy license before flagging as tampered.
1408 1868 */
1409 - private function scan_unlicensed_addons( array $licensed_slugs, array $all_addon_slugs = [] ): void {
1869 + private function scan_unlicensed_addons( array $licensed_slugs ): void {
1410 1870 if( ! function_exists( 'get_plugins' ) ) {
1411 1871 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1412 1872 }
1413 1873
@@ -1421,11 +1881,12 @@
1421 1881 $needs_legacy_check = [];
1422 1882 $needs_legacy_refresh = [];
1423 1883 foreach( $all_plugins as $file => $data ) {
1424 1884 $slug = dirname( $file );
1425 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue;
1426 - if( ! $this->is_known_addon( $slug, $all_addon_slugs ) ) continue;
1885 + if( $slug === '.' ) continue;
1886 + if( ! $this->is_known_addon( $slug ) ) continue;
1427 1887 if( in_array( $slug, $licensed_slugs, true ) ) continue;
1888 + if( ! $this->manages_addon( $slug ) ) continue;
1428 1889 if( ! is_plugin_active( $file ) ) continue;
1429 1890
1430 1891 $manifest_file = WP_PLUGIN_DIR . '/' . $slug . '/.addon-signatures.json';
1431 1892 if( ! file_exists( $manifest_file ) ) {
@@ -1445,9 +1906,9 @@
1445 1906 foreach( $needs_legacy_check as $slug ) {
1446 1907 $legacy = $this->get_cached_legacy_license( $slug );
1447 1908 if( $legacy !== false && ! empty( $legacy['has_license'] ) ) {
1448 1909 // Legacy licensed — not piracy. Track for admin notice if expired.
1449 - $this->clear_tamper_flag( $slug );
1910 + $this->clear_tamper_flag( $slug, true );
1450 1911 $this->update_legacy_notice( $slug, $legacy );
1451 1912
1452 1913 // Proactively migrate active legacy licenses to the new system.
1453 1914 // Once migrated, the slug enters $licensed_slugs and exits this scan
@@ -1458,8 +1919,11 @@
1458 1919
1459 1920 continue;
1460 1921 }
1461 1922
1923 + // The store couldn't be asked (no fresh answer) — never flag on missing data, retry next run
1924 + if( $legacy === false || ! empty( $legacy['unknown'] ) ) continue;
1925 +
1462 1926 // No legacy license — suspicious, flag as tampered
1463 1927 $this->mark_addon_tampered( $slug, [
1464 1928 'Active gVectors addon without a valid license or signature manifest',
1465 1929 ], 'no_manifest' );
@@ -1912,8 +2376,9 @@
1912 2376
1913 2377 foreach( $tampered as $slug => $info ) {
1914 2378 if( ! $this->is_addon_present( $slug ) ) continue;
1915 2379 if( get_transient( 'gvectors_tampered_dismissed_' . $slug ) ) continue;
2380 + if( ! self::claim_notice( 'tampered', $slug ) ) continue;
1916 2381
1917 2382 $files = $info['files'] ?? [];
1918 2383 $reason = $info['reason'] ?? 'tampered';
1919 2384 $detected = $info['detected_at'] ?? '';
@@ -1918,11 +2383,13 @@
1918 2383 $reason = $info['reason'] ?? 'tampered';
1919 2384 $detected = $info['detected_at'] ?? '';
1920 2385 $deactivated = $info['deactivated_at'] ?? '';
1921 2386
2387 + // No license found (often a copy from the old gVectors store) — not proof of piracy: gentler text + how to link the old key
2388 + $unlicensed = $reason === 'no_manifest';
1922 2389 $reason_labels = [
1923 2390 'tampered' => __( 'File integrity check failed — files have been modified.', 'gvectors' ),
1924 - 'no_manifest' => __( 'Missing signature manifest — this copy was not obtained through an authorized license.', 'gvectors' ),
2391 + 'no_manifest' => __( 'We couldn\'t find a license for this addon on this domain.', 'gvectors' ),
1925 2392 'domain_mismatch' => __( 'Domain signature mismatch — this addon was licensed for a different website.', 'gvectors' ),
1926 2393 'no_signatures' => __( 'Missing file header signatures — files have been stripped of authorization data.', 'gvectors' ),
1927 2394 'patched' => __( 'Suspicious code patterns detected — this appears to be a nulled or patched version.', 'gvectors' ),
1928 2395 ];
@@ -1964,10 +2431,33 @@
1964 2431 add_query_arg( [ 'gvectors_dismiss_addon_notice' => 'tampered', 'gvectors_notice_slug' => $slug ] ),
1965 2432 'gvectors_dismiss_tampered_' . $slug
1966 2433 );
1967 2434
2435 + $store_link = '<a href="' . admin_url( $this->config->get_dashboard_addons_store_url() ) . '">Addons Store</a>';
2436 + // A paying customer's copy that fails verification: one click on the Addons page replaces it with a clean copy
2437 + $licensed = ! $unlicensed && $this->has_active_license( $slug );
2438 + if( $licensed ) {
2439 + $title = esc_html__( 'gVectors Security Alert — Addon Files Not Verified', 'gvectors' );
2440 + $action_text = sprintf(
2441 + /* translators: %s: Addons Store page link */
2442 + esc_html__( 'Your license is active: open the %s page and click "Reinstall Addon" for this addon to replace its files with a clean copy.', 'gvectors' ),
2443 + $store_link
2444 + );
2445 + } elseif( $unlicensed ) {
2446 + $title = esc_html__( 'gVectors — License Not Found for This Site', 'gvectors' );
2447 + $action_text = sprintf(
2448 + esc_html__( 'If you bought this addon on our old gVectors store, enter your old license key in the license field of the %s page: it will be linked to this site and this notice disappears. Otherwise, please purchase a license there.', 'gvectors' ),
2449 + $store_link
2450 + );
2451 + } else {
2452 + $title = esc_html__( 'gVectors Security Alert — Unauthorized Addon Detected', 'gvectors' );
2453 + $action_text = sprintf(
2454 + esc_html__( 'Please purchase a valid license at %s or remove the unauthorized addon.', 'gvectors' ),
2455 + $store_link
2456 + );
2457 + }
1968 2458 printf(
1969 - '<div class="notice notice-error" style="border-left-color:#dc3232;border-left-width:4px;">'
2459 + '<div class="notice %s" style="border-left-width:4px;%s">'
1970 2460 . '<p><strong style="font-size:14px;">⚠️ %s</strong> %s</p>'
1971 2461 . '<p>%s</p>'
1972 2462 . '<p>%s</p>'
1973 2463 . '<p>%s</p>'
@@ -1972,16 +2462,15 @@
1972 2462 . '<p>%s</p>'
1973 2463 . '<p>%s</p>'
1974 2464 . '<p><a href="%s">%s</a></p>'
1975 2465 . '</div>',
1976 - esc_html__( 'gVectors Security Alert — Unauthorized Addon Detected', 'gvectors' ),
2466 + $unlicensed ? 'notice-warning' : 'notice-error',
2467 + $unlicensed ? '' : 'border-left-color:#dc3232;',
2468 + $title,
1977 2469 '<code>' . esc_html( $slug ) . '</code>',
1978 2470 esc_html( $reason_text ),
1979 2471 $status_text,
1980 - sprintf(
1981 - esc_html__( 'Please purchase a valid license at %s or remove the unauthorized addon.', 'gvectors' ),
1982 - '<a href="' . admin_url( $this->config->get_dashboard_addons_store_url() ) . '">Addons Store</a>'
1983 - ),
2472 + $action_text,
1984 2473 esc_url( $dismiss_url ),
1985 2474 esc_html__( 'Dismiss for 5 days', 'gvectors' )
1986 2475 );
1987 2476 }
@@ -2022,19 +2511,24 @@
2022 2511 }
2023 2512
2024 2513 foreach( $update_plugins->response as $plugin_file => $update_data ) {
2025 2514 $slug = dirname( $plugin_file );
2026 - if( $slug === '.' || $slug === $this->config->get_core_plugin_slug() ) continue;
2515 + if( $slug === '.' ) continue;
2027 2516
2028 2517 // Only for our addons that have empty package (no active license)
2029 2518 $package = is_object( $update_data ) ? ( $update_data->package ?? '' ) : '';
2030 2519 if( ! empty( $package ) ) continue;
2031 2520
2032 - // Confirm it's a known gVectors addon
2033 - if( ! $this->is_known_addon( $slug ) ) continue;
2521 + // Confirm it's an addon from the gVectors store list (no HTTP request on page load)
2522 + if( ! $this->is_known_addon( $slug, false ) ) continue;
2034 2523
2035 2524 // Confirm no active license
2036 2525 if( in_array( $slug, $active_licensed_slugs, true ) ) continue;
2526 +
2527 + // Licensed via / belongs to another host plugin — that host's instance renders the row (and its store link)
2528 + if( ! $this->manages_addon( $slug, false ) ) continue;
2529 +
2530 + if( ! self::claim_notice( 'unlicensed_row', $slug ) ) continue;
2037 2531
2038 2532 add_action( "after_plugin_row_$plugin_file", [ $this, 'unlicensed_update_notice_row' ] );
2039 2533 }
2040 2534 }
@@ -2079,8 +2573,9 @@
2079 2573
2080 2574 foreach( $expired as $slug => $info ) {
2081 2575 if( ! $this->is_addon_present( $slug ) ) continue;
2082 2576 if( get_transient( 'gvectors_expired_dismissed_' . $slug ) ) continue;
2577 + if( ! self::claim_notice( 'expired', $slug ) ) continue;
2083 2578
2084 2579 $product_name = $info['product_name'] ?? $slug;
2085 2580 $has_update = ! empty( $info['has_update'] );
2086 2581 $latest = $info['latest_version'] ?? '';
@@ -2145,8 +2640,9 @@
2145 2640 // Verify the addon is still installed
2146 2641 if( ! $this->is_addon_present( $slug ) ) continue;
2147 2642
2148 2643 if( get_transient( 'gvectors_legacy_dismissed_' . $slug ) ) continue;
2644 + if( ! self::claim_notice( 'legacy', $slug ) ) continue;
2149 2645
2150 2646 $plugin_name = $info['plugin_name'] ?? $slug;
2151 2647
2152 2648 $dismiss_url = wp_nonce_url(