diff --git a/changelog/update-license-field-improvements b/changelog/update-license-field-improvements new file mode 100644 index 0000000000..7936ee7a88 --- /dev/null +++ b/changelog/update-license-field-improvements @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Warn users when they attempt to enter a Unified license key into the standalone license key field diff --git a/src/Common/Integrations/Harbor/PUE.php b/src/Common/Integrations/Harbor/PUE.php index 02132afb28..6fad004124 100644 --- a/src/Common/Integrations/Harbor/PUE.php +++ b/src/Common/Integrations/Harbor/PUE.php @@ -41,6 +41,9 @@ protected function do_register(): void { add_filter( 'stellarwp/uplink/tec/license_get_key', [ $this, 'filter_stellarwp_uplink_tec_license_get_key' ], 10, 2 ); add_filter( 'tec_common_uplink_auth_url', [ $this, 'filter_stellarwp_uplink_tec_authorize_button_url' ], 10, 2 ); add_filter( 'pue_get_update_url', [ $this, 'filter_pue_get_update_url' ], 10, 2 ); + add_filter( 'tec_common_pue_pre_validate_key', [ $this, 'filter_tec_common_pue_pre_validate_key' ], 10, 3 ); + add_filter( 'tribe_settings_save_field_value', [ $this, 'prevent_storing_unified_license_key' ], 10, 2 ); + add_filter( 'tribe_license_fields', [ $this, 'readonly_and_disable_harbor_managed_license_fields' ], 30 ); } /** @@ -56,6 +59,154 @@ public function unregister(): void { remove_filter( 'stellarwp/uplink/tec/license_get_key', [ $this, 'filter_stellarwp_uplink_tec_license_get_key' ] ); remove_filter( 'tec_common_uplink_auth_url', [ $this, 'filter_stellarwp_uplink_tec_authorize_button_url' ] ); remove_filter( 'pue_get_update_url', [ $this, 'filter_pue_get_update_url' ] ); + remove_filter( 'tec_common_pue_pre_validate_key', [ $this, 'filter_tec_common_pue_pre_validate_key' ] ); + remove_filter( 'tribe_settings_save_field_value', [ $this, 'prevent_storing_unified_license_key' ] ); + remove_filter( 'tribe_license_fields', [ $this, 'readonly_and_disable_harbor_managed_license_fields' ], 30 ); + } + + /** + * Modify the Harbor-managed legacy fields and make them + * readonly and disabled when the product is licensed via Harbor. + * + * @since TBD + * + * @param array $fields The license fields. + * + * @return array + */ + public function readonly_and_disable_harbor_managed_license_fields( array $fields ): array { + foreach ( $fields as $field_id => &$field ) { + if ( ! is_array( $field ) || ! is_string( $field_id ) ) { + continue; + } + + if ( ! str_starts_with( $field_id, 'pue_install_key_' ) ) { + continue; + } + + if ( ( $field['type'] ?? '' ) !== 'license_key' ) { + continue; + } + + $product = str_replace( [ 'pue_install_key_', '_' ], [ '', '-' ], $field_id ); + if ( ! $this->harbor->is_license_field_managed_by_harbor( $product ) ) { + continue; + } + + $field['attributes'] = array_merge( + $field['attributes'] ?? [], + [ + 'disabled' => 'disabled', + 'readonly' => 'readonly', + ] + ); + } + + return $fields; + } + + /** + * Short-circuit legacy PUE key validation for Harbor unified licenses. + * + * Harbor-managed products already use the unified key, so remote validation is + * skipped. Unified keys pasted into non-managed product fields are rejected with + * a link to the LW License Manager. + * + * @since TBD + * + * @param array|null $response Early response, or null to continue. + * @param string $key The license key being validated. + * @param \Tribe__PUE__Checker|null $checker The PUE checker instance. + * + * @return array|null + */ + public function filter_tec_common_pue_pre_validate_key( ?array $response, string $key, $checker ): ?array { + if ( null !== $response ) { + return $response; + } + + if ( ! $checker instanceof \Tribe__PUE__Checker ) { + return $response; + } + + $slug = $checker->get_slug(); + + if ( $this->harbor->is_license_field_managed_by_harbor( $slug ) ) { + return [ + 'status' => 1, + 'message' => sprintf( + /* translators: URL to the Liquid Web License Manager */ + __( 'Licensed via Unified License Manager', 'tribe-common' ), + esc_url( lw_harbor_get_license_page_url() ), + ), + ]; + } + + if ( $this->harbor->is_unified_license_key( $key ) ) { + return [ + 'status' => 0, + 'message' => $this->harbor->get_unified_license_key_entry_error_message(), + ]; + } + + return $response; + } + + /** + * Prevent storing unified license keys in per-product PUE options. + * + * Harbor-managed fields ignore submitted values so the unified key is not written + * into product options. Unified keys pasted into non-managed fields are rejected + * and the previously stored product key is kept. + * + * @since TBD + * + * @param mixed $value The field value about to be saved. + * @param string $field_id The settings field ID. + * + * @return mixed + */ + public function prevent_storing_unified_license_key( $value, $field_id ) { + if ( ! is_string( $field_id ) || ! str_starts_with( $field_id, 'pue_install_key_' ) ) { + return $value; + } + + $product = str_replace( [ 'pue_install_key_', '_' ], [ '', '-' ], $field_id ); + + if ( $this->harbor->is_license_field_managed_by_harbor( $product ) ) { + return $this->get_stored_product_license_key( $field_id ); + } + + if ( is_string( $value ) && $this->harbor->is_unified_license_key( $value ) ) { + return $this->get_stored_product_license_key( $field_id ); + } + + return $value; + } + + /** + * Get the stored product license key without Harbor option overlays. + * + * @since TBD + * + * @param string $option_name The option name. + * + * @return string + */ + private function get_stored_product_license_key( string $option_name ): string { + $had_filter = has_filter( 'pre_option', [ $this, 'filter_pre_get_option' ] ); + + if ( $had_filter ) { + remove_filter( 'pre_option', [ $this, 'filter_pre_get_option' ], 10 ); + } + + $stored = (string) get_option( $option_name, '' ); + + if ( $had_filter ) { + add_filter( 'pre_option', [ $this, 'filter_pre_get_option' ], 10, 3 ); + } + + return $stored; } /** @@ -86,7 +237,7 @@ public function filter_pue_get_update_url( string $update_url, string $slug ): s * * @return ?string */ - public function filter_stellarwp_uplink_tec_license_get_key( ?string $license, Uplink_Resource $uplink_resource ) { + public function filter_stellarwp_uplink_tec_license_get_key( ?string $license, Uplink_Resource $uplink_resource ): ?string { $harbor_slug = $this->harbor->get_harbor_product_slug( $uplink_resource->get_slug() ); if ( ! $this->harbor->is_product_licensed( $harbor_slug ) ) { return $license; @@ -122,13 +273,25 @@ public function filter_pre_get_option( $value, $option, $default_value ) { } /** - * Filter the pre HTTP request. + * Short-circuit PUE license validation HTTP requests for Harbor-licensed products. + * + * Legacy PUE code (e.g. Tribe__PUE__Checker) validates license keys by POSTing to + * `/api/plugins/v2/license/validate` on Stellar's licensing servers. For unified licensed + * sites, the license is managed by Harbor and at the whole site level, so those remote + * calls are unnecessary and may fail or return stale data. + * + * This filter intercepts those license validation requests via `pre_http_request` and returns + * a synthetic HTTP 200 response shaped like the PUE API, built from Harbor's cached + * license and catalog data. + * + * Only requests to the validate endpoint for products reported as licensed by Harbor + * are intercepted. All other HTTP traffic is left unchanged. * * @since 6.11.0 * - * @param false|array|WP_Error $response The response. - * @param array $parsed_args The parsed arguments. - * @param string $url The URL. + * @param false|array|\WP_Error $response The response. + * @param array $parsed_args The parsed arguments. + * @param string $url The URL. * * @return false|array */ diff --git a/src/Common/Libraries/Harbor.php b/src/Common/Libraries/Harbor.php index e55f6efbc5..1bc227007e 100644 --- a/src/Common/Libraries/Harbor.php +++ b/src/Common/Libraries/Harbor.php @@ -29,12 +29,21 @@ * @package TEC\Common\Libraries\Harbor */ class Harbor extends Controller_Contract { + /** + * Prefix for Liquid Web unified license keys. + * + * @since TBD + * + * @var string + */ + private const UNIFIED_LICENSE_KEY_PREFIX = 'LWSW-'; + /** * The TEC product slug to Harbor product slug map. * * @since 6.11.0 * - * @var array + * @var array */ private const TEC_PRODUCT_SLUG_TO_HARBOR_PRODUCT_SLUG_MAP = [ 'the-events-calendar' => 'the-events-calendar', @@ -81,6 +90,9 @@ public function do_register(): void { add_filter( 'lw-harbor/legacy_licenses', [ $this,'register_legacy_licenses' ] ); add_filter( 'lw_harbor/premium_plugin_exists', [ $this, 'register_premium_plugin_exists' ] ); + // Runs even when Harbor does not fully load (no premium plugin), so unified keys + // pasted into free-plugin PUE fields still get a clear guidance message. + add_filter( 'tec_common_pue_pre_validate_key', [ $this, 'filter_tec_common_pue_pre_validate_key' ], 10, 3 ); Harbor_Provider::init(); @@ -105,6 +117,7 @@ public function do_register(): void { public function unregister(): void { remove_filter( 'lw-harbor/legacy_licenses', [ $this,'register_legacy_licenses' ] ); remove_filter( 'lw_harbor/premium_plugin_exists', [ $this, 'register_premium_plugin_exists' ] ); + remove_filter( 'tec_common_pue_pre_validate_key', [ $this, 'filter_tec_common_pue_pre_validate_key' ] ); remove_action( 'init', [ $this, 'decorate_uplinks_auth_url' ] ); } @@ -119,6 +132,48 @@ public function decorate_uplinks_auth_url(): void { $this->container->bind( Auth_Url::class, Auth_URL_Decorator::class ); } + /** + * Reject unified license keys when Harbor is not loaded. + * + * Without a premium plugin, Harbor never fires `lw_harbor/loaded` and the PUE + * Harbor integration is not registered — so this callback is the only guard that + * stops a unified key from being sent through legacy PUE validation. + * + * @since TBD + * + * @param array|null $response Early response, or null to continue. + * @param string $key The license key being validated. + * @param \Tribe__PUE__Checker|null $checker The PUE checker instance. + * + * @return array|null + */ + public function filter_tec_common_pue_pre_validate_key( ?array $response, string $key, $checker ): ?array { + if ( null !== $response ) { + return $response; + } + + // Bail out if harbor is loaded. + if ( did_action( 'lw_harbor/loaded' ) ) { + return $response; + } + + if ( ! $this->is_unified_license_key( $key ) ) { + return $response; + } + + return [ + 'status' => 0, + 'message' => sprintf( + /* translators: %s: My account page link. */ + __( + 'This is a unified license key. To activate it, install The Events Calendar Pro or Event Tickets Pro, then add your license in the Unified License Manager. You can download the plugin from your account.', + 'tribe-common' + ), + esc_url( $this->get_portal_url() ) + ), + ]; + } + /** * Get the premium plugin existence callbacks. * @@ -232,7 +287,7 @@ public function register_legacy_licenses( array $licenses ): array { return array_values( array_filter( $licenses, - static fn( array $license ): bool => ! empty( $license['key'] ) && ! str_starts_with( $license['key'], 'LWSW-' ) + fn( array $license ): bool => ! empty( $license['key'] ) && ! $this->is_unified_license_key( $license['key'] ) ) ); } @@ -265,6 +320,62 @@ public function is_product_licensed( string $product ): bool { return lw_harbor_is_feature_available( $product ); } + /** + * Whether a license key uses the unified Liquid Web format. + * + * @since TBD + * + * @param string $key The license key. + * + * @return bool + */ + public function is_unified_license_key( string $key ): bool { + return str_starts_with( trim( $key ), self::UNIFIED_LICENSE_KEY_PREFIX ); + } + + /** + * Whether a TEC product license field is managed by Harbor. + * + * When true, the unified license key should be shown read-only and should not + * be validated through legacy PUE per-product fields. + * + * Checks that the product's license is actually active/valid for this site, not + * merely that the customer's tier entitles them to it — a customer can be entitled + * to a product without having activated it here, in which case a legacy per-product + * key should still be accepted. + * + * @since TBD + * + * @param string $tec_product_slug The TEC product slug. + * + * @return bool + */ + public function is_license_field_managed_by_harbor( string $tec_product_slug ): bool { + if ( ! lw_harbor_has_unified_license_key() ) { + return false; + } + + return lw_harbor_is_feature_enabled( + $this->get_harbor_product_slug( $tec_product_slug ) + ); + } + + /** + * Error message shown when a unified license key is entered in a per-product field. + * + * @since TBD + * + * @return string + */ + public function get_unified_license_key_entry_error_message(): string { + return sprintf( + /* translators: %1$s: opening anchor tag, %2$s: closing anchor tag. */ + __( 'This is a unified license key. Please %1$sclick here%2$s to enter it in the Unified License Manager.', 'tribe-common' ), + '', + '' + ); + } + /** * Get the unified license key if the feature is enabled. * diff --git a/src/Tribe/PUE/Checker.php b/src/Tribe/PUE/Checker.php index 9184cbccb4..528e22aa81 100755 --- a/src/Tribe/PUE/Checker.php +++ b/src/Tribe/PUE/Checker.php @@ -1238,6 +1238,23 @@ public function validate_key( string $key, bool $network = false ): array { return []; } + /** + * Filter early license key validation before remote PUE checks. + * + * Returning a non-null array short-circuits remote validation and is used as + * the AJAX/validation response. Return null to continue normal validation. + * + * @since TBD + * + * @param array|null $response Early response, or null to continue. + * @param string $key The license key being validated. + * @param Tribe__PUE__Checker $checker The PUE checker instance. + */ + $pre_validate = apply_filters( 'tec_common_pue_pre_validate_key', null, $key, $this ); + if ( is_array( $pre_validate ) ) { + return $pre_validate; + } + $uplink_resource = $this->get_uplink_resource( $this->get_slug() ); if ( $uplink_resource ) { diff --git a/tests/integration/Tribe/Common/Integrations/Harbor/PUE_Test.php b/tests/integration/Tribe/Common/Integrations/Harbor/PUE_Test.php index 1a1d9c200c..a127a7ec08 100644 --- a/tests/integration/Tribe/Common/Integrations/Harbor/PUE_Test.php +++ b/tests/integration/Tribe/Common/Integrations/Harbor/PUE_Test.php @@ -6,6 +6,7 @@ use TEC\Common\Libraries\Harbor; use TEC\Common\StellarWP\Uplink\Resources\Plugin as Uplink_Plugin; use Tribe\Tests\Traits\With_Harbor_State; +use Tribe\Tests\Traits\With_Uopz; /** * Exercises the four PUE filter hooks that the Harbor consolidation introduces @@ -20,6 +21,7 @@ */ class PUE_Test extends WPTestCase { use With_Harbor_State; + use With_Uopz; /** * The priority airplane-mode (a wp-browser test harness plugin) registers its @@ -351,4 +353,179 @@ public function auth_url_decorator_provider(): array { 'non-matching slug leaves URL untouched' => [ 'events-calendar-pro', 'https://example.com/seating-connect/', false ], ]; } + + /** + * @test + */ + public function it_should_skip_remote_validation_for_harbor_managed_product(): void { + $remote_call_count = 0; + // validate_key() → request_info() uses wp_remote_post for remote PUE checks. + $this->set_fn_return( + 'wp_remote_post', + static function () use ( &$remote_call_count ) { + ++$remote_call_count; + + return new \WP_Error( 'unexpected_remote_validation', 'Remote validation should not be performed.' ); + }, + true + ); + + $this->seed_unified_license_key(); + $this->seed_harbor_catalog_for_tec( [ 'events-calendar-pro' ] ); + + $checker = new \Tribe__PUE__Checker( 'deprecated', 'events-calendar-pro', [], 'events-calendar-pro/events-calendar-pro.php' ); + $response = $checker->validate_key( 'any-key-value' ); + + $this->assertSame( 0, $remote_call_count, 'Remote validation should not be performed for Harbor-managed products.' ); + $this->assertSame( 1, $response['status'] ); + $this->assertStringContainsString( 'Unified License Manager', $response['message'] ); + } + + /** + * @test + */ + public function it_should_reject_unified_key_for_non_harbor_managed_product(): void { + $this->seed_unified_license_key(); + $this->seed_harbor_catalog_for_tec( [ 'events-calendar-pro' ] ); + + $checker = new \Tribe__PUE__Checker( 'deprecated', 'tribe-filterbar', [], 'the-events-calendar-filterbar/the-events-calendar-filterbar.php' ); + $response = $checker->validate_key( 'LWSW-PASTED-INTO-WRONG-FIELD' ); + + $this->assertSame( 0, $response['status'] ); + $this->assertStringContainsString( 'unified license key', strtolower( wp_strip_all_tags( $response['message'] ) ) ); + $this->assertStringContainsString( 'assertStringContainsString( 'Unified License Manager', $message ); + } + /** * @test */ diff --git a/tests/wpunit/Common/Admin/Custom_List_Table_Test.php b/tests/wpunit/Common/Admin/Custom_List_Table_Test.php index 2f436a656e..9f735b89c4 100644 --- a/tests/wpunit/Common/Admin/Custom_List_Table_Test.php +++ b/tests/wpunit/Common/Admin/Custom_List_Table_Test.php @@ -3,9 +3,12 @@ namespace TEC\Common\Admin; use Codeception\TestCase\WPTestCase; +use Tribe\Tests\Traits\With_Uopz; use RuntimeException; class Custom_List_Table_Test extends WPTestCase { + use With_Uopz; + private static $back_up; /** @@ -17,6 +20,8 @@ public function prepare() { self::$back_up = $wp_actions; $wp_actions = []; + + $this->set_fn_return( 'tribe_exit', true ); } /**