diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 8eddf391..e837e4d4 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -7,14 +7,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Configure PHP environment uses: shivammathur/setup-php@v2 with: php-version: '7.4' extensions: mbstring, intl coverage: none - - uses: ramsey/composer-install@v2 + - uses: ramsey/composer-install@v3 with: composer-options: "--ignore-platform-reqs --optimize-autoloader" - name: Run PHPStan static analysis diff --git a/README.md b/README.md index 2bcba4f2..9e770892 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ add_action( 'plugins_loaded', function() { }, 0 ); ``` +> 💡 See [multisite license documentation](./docs/features.md#multisite-licenses) to enable +> network licensing features. + ## Translation Package is using `__( 'Invalid request: nonce field is expired. Please try again.', '%TEXTDOMAIN%' )` function for translation. In order to change domain placeholder `'%TEXTDOMAIN%'` to your plugin translation domain run @@ -92,11 +95,12 @@ src/Uplink/Helper.php The file should match the following - keeping the `KEY` constant set to a blank string, or, if you want a default license key, set it to that.: ```php -get( License_Field::class ); +$fields = \StellarWP\Uplink\get_license_field(); // Do one of the following: $fields->render(); // Render the fields, titles, and submit button. @@ -168,7 +172,7 @@ To render a single product's license key, use the following: ```php use StellarWP\Uplink\Config; -$fields = Config::get_container()->get( License_Field::class ); +$fields = \StellarWP\Uplink\get_license_field(); // Do one of the following: $fields->render_single( 'my-plugin' ); // Render the fields, titles, and submit button. @@ -200,7 +204,7 @@ use StellarWP\Uplink\Config; function render_settings_page() { // ... - $fields = Config::get_container()->get( License_Field::class ); + $fields = \StellarWP\Uplink\get_license_field(); $fields->render(); // or $fields->render_single( 'my-plugin' ); to render a single plugin //.... @@ -239,14 +243,14 @@ functionality: ```php // Call the namespaced function with your plugin slug. -\StellarWP\Uplink\render_authorize_button( 'kadence-blocks-pro' ); +\StellarWP\Uplink\render_authorize_button( 'my-plugin-slug' ); ``` You can also pass in a custom license domain, which can be fetched on the Uplink Origin side from the `uplink_domain` query variable: ```php // Call the namespaced function with your plugin slug and license domain. -\StellarWP\Uplink\render_authorize_button( 'kadence-blocks-pro', 'customer-site.com' ); +\StellarWP\Uplink\render_authorize_button( 'my-plugin-slug', \StellarWP\Uplink\get_license_domain() ); ``` > 💡 The button is very customizable with filters, see [Authorize_Button_Controller.php](src/Uplink/Components/Admin/Authorize_Button_Controller.php). @@ -256,15 +260,23 @@ You can also pass in a custom license domain, which can be fetched on the Uplink This connects to the licensing server to check in real time if the license is authorized. Use sparingly. ```php -$container = \StellarWP\Uplink\Config::get_container(); -$token_manager = $container->get( \StellarWP\Uplink\Auth\Token\Contracts\Token_Manager::class ); -$token = $token_manager->get(); +$is_authorized = \StellarWP\Uplink\is_authorized_by_resource( 'my-plugin-slug' ); + +echo $is_authorized ? esc_html__( 'authorized' ) : esc_html__( 'not authorized' ); +``` + +Or, if you have more complex licensing needs, you can provide specific data: + +```php +$token = \StellarWP\Uplink\get_authorization_token( 'my-plugin-slug' ); +$license_key = \StellarWP\Uplink\get_license_key( 'my-plugin-slug' ); +$domain = \StellarWP\Uplink\get_license_domain(); -if ( ! $token ) { - return; +if ( ! $token || ! $license_key || ! $domain ) { + return; // or, log/show errors. } -$is_authorized = \StellarWP\Uplink\is_authorized( 'customer_license_key', $token, 'customer_domain' ); +$is_authorized = \StellarWP\Uplink\is_authorized( $license_key, 'my-plugin-slug', $token, $domain ); echo $is_authorized ? esc_html__( 'authorized' ) : esc_html__( 'not authorized' ); ``` @@ -274,18 +286,11 @@ echo $is_authorized ? esc_html__( 'authorized' ) : esc_html__( 'not authorized' If for some reason you need to fetch your `auth_url` manually, you can do so by: ```php -$container = \StellarWP\Uplink\Config::get_container(); -$auth_url_manager = $container->get( \StellarWP\Uplink\API\V3\Auth\Contracts\Auth_Url::class ); - -// Pass your product or service slug. -$auth_url = $auth_url_manager->get( 'kadence-blocks-pro' ); - -echo $auth_url; +echo esc_url( \StellarWP\Uplink\get_auth_url( 'my-plugin-slug' ) ); ``` > 💡 Auth URL connections are cached for one day using transients. - ### Callback Redirect The Callback Redirect generated by the Origin looks something like this, where `uplinksample.lndo.site` is your @@ -304,7 +309,5 @@ The following Query Variables are available for reference: 1. `uplink_token` - The unique UUIDv4 token generated by StellarWP Licensing. 2. `_uplink_nonce` - The original nonce sent with the callback URL, as part of the "Connect" button. -3. `uplink_license` (optional) - Whether we should also update or set a License Key. -4. `uplink_slug` (optional) - The Product or Service Slug that we're updating the license for. - -> ⚠️ `uplink_slug` MUST be supplied if `uplink_license` is! +3. `uplink_slug` The Product or Service Slug that we're updating the license for. +4. `uplink_license` (optional) - Whether we should also update or set a License Key. diff --git a/docs/features.md b/docs/features.md index 1706d1b6..a7966d34 100644 --- a/docs/features.md +++ b/docs/features.md @@ -19,7 +19,6 @@ The goals of this library are to provide a simple way to register one or more pl Registration of plugins and services should happen programmatically and the intended API is documented in this repository's [README](/README.md). - ### License keys & validation The Uplink library should communicate with the Stellar Licensing system at specific moments. Those moments are: @@ -80,11 +79,6 @@ Uplink should provide a UI for entering license keys. Ideally, Uplink should hav When a license field is rendered, manipulated, or saved, a validation request should be triggered (see the diagram above). -##### Site & Network-level - -When a plugin that includes the Uplink library is activated at the network level, License keys should be able to be entered at both the network and the site level. - - ### Product updates The Uplink library should mark a registered plugin as needing an update when a validation response comes back from the Stellar Licensing system with a version number greater than the version number that is currently installed. You can see a reference implementation in [`tribe-common`](https://github.com/the-events-calendar/tribe-common/blob/master/src/Tribe/PUE/Checker.php#L1537). @@ -92,3 +86,106 @@ The Uplink library should mark a registered plugin as needing an update when a v #### Plugin page On the _Plugins_ page in the WP Dashboard, any plugin that has an update available should display the update similar to how WordPress core does it. If a user click on `Update`, the zip files should be fetched from the Stellar Licensing system and installed if their license key is valid. + +### Multisite Licenses + +Out of the box, Uplink treats all sub-sites as their _own independent site_, storing license keys and authorization tokens +in the options table of that sub-site. + +If your product supports multisite level licenses, you can configure Uplink to allow one license key/auth token for the entire +multisite network across multiple situations. + +> 💡 To operate at the network level, your plugin **must be network activated** and the proper configuration options enabled. + +| Install Type | Network Activated? | Clause | Uplink Config Option (default is false) | License & Token Storage Location | +|----------------------------|--------------------|--------|-----------------------------------------------------|----------------------------------| +| Standard | – | – | – | Site level | +| Multisite (subfolders) | Yes | AND | `Config::set_network_subfolder_license(true)` | Network level | +| Multisite (subfolders) | No | OR | `Config::set_network_subfolder_license(false)` | Site Level | +| Multisite (subdomains) | Yes | AND | `Config::set_network_subdomain_license(true)` | Network level | +| Multisite (subdomains) | No | OR | `Config::set_network_subdomain_license(false)` | Site Level | +| Multisite (domain mapping) | Yes | AND | `Config::set_network_domain_mapping_license(true)` | Network level | +| Multisite (domain mapping) | No | OR | `Config::set_network_domain_mapping_license(false)` | Site Level | + + +#### Examples + +After [library initialization](../README.md#initialize-the-library), the above config options can be set, e.g. + +```php +use StellarWP\Uplink\Uplink; + +// ...other config above + +// Allow a single network license for multisite subfolders. +Config::set_network_subfolder_license( true ); + +// Allow a single network license for multisite using subdomains. +Config::set_network_subdomain_license( true ); + +// Allow a single network license for custom domains/mapped domains. +Config::set_network_domain_mapping_license( true ); + +Uplink::init(); +``` + +> 🚨 Consider moving your plugin UI and the Uplink License Field settings to the WordPress network UI to prevent +> sub-sites being able to control licensing and token authorization for the entire multisite network if you enable +> any of the multisite config options. + +Each config option can also be filtered at runtime, assuming your plugin's slug is registered as `my-plugin-slug`: + +```php +add_action( 'init', static function(): void { + // Replace with a call to your own plugin's config/option to check whether it should be managed in the network. + $network_enabled = true; + + add_filter( 'stellarwp/uplink/my-plugin-slug/allows_network_subfolder_license', + static function () use ( $network_enabled ): bool { + return $network_enabled; + }, 10 ); + + add_filter( 'stellarwp/uplink/my-plugin-slug/allows_network_subdomain_license', + static function () use ( $network_enabled ): bool { + return $network_enabled; + }, 10 ); + + add_filter( 'stellarwp/uplink/my-plugin-slug/allows_network_domain_mapping_license', + static function () use ( $network_enabled ): bool { + return $network_enabled; + }, 10 ); +} ); +``` + +#### Functions + +Uplink provides some handy functions that include automated support for fetching or storing data from +the correct location. + +```php +// Checks if the current WordPress user is authorized to make license/token changes (is_super_admin() out of the box). +\StellarWP\Uplink\is_user_authorized(); + +// Get a stored license key, will fetch from the current site's options or network, depending on your config. +\StellarWP\Uplink\get_license_key( 'my-plugin-slug' ); + +// Store a license key, will store in the current site's options or network, depending on your config. +\StellarWP\Uplink\set_license_key( 'my-plugin-slug', 'new_license_key' ); + +// Get the domain associated with a license. +// Note that if any of the multisite options are enabled, the root multisite domain contains a unique suffix +// so that it doesn't override the license key/token for the entire multisite network if it's converted back +// to single site. +// Depending on your configuration, this may the multisite domain with suffix, e.g. example.com/2626gsg +// or, it could be the sub-site domain with path, e.g. example.com/sub1, example.com/sub2 etc... +\StellarWP\Uplink\get_license_domain(); + +// This will get the license domain without any added suffixes. +\StellarWP\Uplink\get_original_domain(); + +// Gets the local URL to disconnect a token (delete it locally). +\StellarWP\Uplink\get_disconnect_url( 'my-plugin-slug' ); + +// Checks if the current site (e.g. the sub-site on multisite) would support multisite licensing. +\StellarWP\Uplink\allows_multisite_license( 'my-plugin-slug' ); +``` diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 98a5aeaa..dae1837a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -15,7 +15,6 @@ parameters: level: 5 inferPrivatePropertyTypeFromConstructor: true reportUnmatchedIgnoredErrors: false - checkGenericClassInNonGenericObjectType: false # Paths to be analyzed. paths: @@ -26,3 +25,4 @@ parameters: - '#^Function add_query_arg invoked with [123] parameters?, 0 required\.$#' # Uses func_get_args() - '#^Function apply_filters(_ref_array)? invoked with [34567] parameters, 2 required\.$#' + - '#Path in require_once\(\).+is not a file or it does not exist#' diff --git a/src/Uplink/API/Client.php b/src/Uplink/API/Client.php index 980ed6a7..fe178aa7 100644 --- a/src/Uplink/API/Client.php +++ b/src/Uplink/API/Client.php @@ -187,14 +187,16 @@ protected function request( $method, $endpoint, $args ) { * * @since 1.0.0 * - * @param Resource $resource Resource to validate. - * @param string|null $key License key. - * @param string $validation_type Validation type (local or network). - * @param bool $force Force the validation. + * @param Resource $resource Resource to validate. + * @param string|null $key License key. + * @param string $validation_type Validation type (local or network). + * @param bool $force Force the validation. + * + * @throws \RuntimeException * * @return mixed */ - public function validate_license( Resource $resource, string $key = null, string $validation_type = 'local', bool $force = false ) { + public function validate_license( Resource $resource, ?string $key = null, string $validation_type = 'local', bool $force = false ) { /** @var Data */ $site_data = $this->container->get( Data::class ); $args = $resource->get_validation_args(); diff --git a/src/Uplink/API/V3/Auth/Contracts/Token_Authorizer.php b/src/Uplink/API/V3/Auth/Contracts/Token_Authorizer.php index 7de31571..ecd9e0c1 100644 --- a/src/Uplink/API/V3/Auth/Contracts/Token_Authorizer.php +++ b/src/Uplink/API/V3/Auth/Contracts/Token_Authorizer.php @@ -11,11 +11,12 @@ interface Token_Authorizer { * @see \StellarWP\Uplink\API\V3\Auth\Token_Authorizer * * @param string $license The license key. + * @param string $slug The plugin/service slug. * @param string $token The stored token. * @param string $domain The user's domain. * * @return bool */ - public function is_authorized( string $license, string $token, string $domain ): bool; + public function is_authorized( string $license, string $slug, string $token, string $domain ): bool; } diff --git a/src/Uplink/API/V3/Auth/Token_Authorizer.php b/src/Uplink/API/V3/Auth/Token_Authorizer.php index d9b67013..d9b4eb58 100644 --- a/src/Uplink/API/V3/Auth/Token_Authorizer.php +++ b/src/Uplink/API/V3/Auth/Token_Authorizer.php @@ -28,17 +28,19 @@ public function __construct( Client_V3 $client ) { /** * Manually check if a license is authorized. * - * @see is_authorized() - * * @param string $license The license key. - * @param string $token The stored token. - * @param string $domain The user's domain. + * @param string $slug The plugin/service slug. + * @param string $token The stored token. + * @param string $domain The user's domain. * * @return bool + * + * @see is_authorized() */ - public function is_authorized( string $license, string $token, string $domain ): bool { + public function is_authorized( string $license, string $slug, string $token, string $domain ): bool { $response = $this->client->get( 'tokens/auth', [ 'license' => $license, + 'slug' => $slug, 'token' => $token, 'domain' => $domain, ] ); diff --git a/src/Uplink/API/V3/Auth/Token_Authorizer_Cache_Decorator.php b/src/Uplink/API/V3/Auth/Token_Authorizer_Cache_Decorator.php index 76d18240..fc68ae93 100644 --- a/src/Uplink/API/V3/Auth/Token_Authorizer_Cache_Decorator.php +++ b/src/Uplink/API/V3/Auth/Token_Authorizer_Cache_Decorator.php @@ -49,20 +49,21 @@ public function __construct( * @see Token_Authorizer * * @param string $license The license key. + * @param string $slug The plugin/service slug. * @param string $token The stored token. * @param string $domain The user's domain. * * @return bool */ - public function is_authorized( string $license, string $token, string $domain ): bool { - $transient = $this->build_transient( [ $license, $token, $domain ] ); + public function is_authorized( string $license, string $slug, string $token, string $domain ): bool { + $transient = $this->build_transient( [ $token ] ); $is_authorized = get_transient( $transient ); if ( $is_authorized === true ) { return true; } - $is_authorized = $this->authorizer->is_authorized( $license, $token, $domain ); + $is_authorized = $this->authorizer->is_authorized( $license, $slug, $token, $domain ); // Only cache successful responses. if ( $is_authorized ) { @@ -75,12 +76,23 @@ public function is_authorized( string $license, string $token, string $domain ): /** * Build a transient key. * - * @param array ...$args + * @param array $args * * @return string */ - public function build_transient( array ...$args ): string { - return self::TRANSIENT_PREFIX . hash( 'sha256', json_encode( $args ) ); + public function build_transient( array $args ): string { + return self::TRANSIENT_PREFIX . $this->build_transient_no_prefix( $args ); + } + + /** + * Build a transient key without the prefix. + * + * @param array $args + * + * @return string + */ + public function build_transient_no_prefix( array $args ): string { + return hash( 'sha256', json_encode( $args ) ); } } diff --git a/src/Uplink/API/Validation_Response.php b/src/Uplink/API/Validation_Response.php index 3b373678..a9a17a3a 100644 --- a/src/Uplink/API/Validation_Response.php +++ b/src/Uplink/API/Validation_Response.php @@ -1,14 +1,13 @@ -key = $key ?: ''; $this->validation_type = 'network' === $validation_type ? 'network' : 'local'; $this->response = $response; + $this->resource = $resource; if ( isset( $this->response->results ) ) { $this->response = is_array( $this->response->results ) ? reset( $this->response->results ) : $this->response->results; } - $this->resource = $resource; - $this->container = $container ?: Config::get_container(); - $this->parse(); } @@ -212,6 +199,13 @@ public function get_message() { * @return Messages\Message_Abstract */ public function get_network_message() { + $referer = wp_get_referer(); + + // If this request came from the network admin, show regular messaging. + if ( $referer && wp_doing_ajax() && str_contains( $referer, network_admin_url() ) ) { + return $this->get_message(); + } + if ( $this->is_valid() ) { return new Messages\Network_Licensed(); } @@ -230,7 +224,7 @@ public function get_network_message() { * * @return stdClass */ - public function get_raw_response() { + public function get_raw_response(): ?stdClass { return $this->response; } @@ -267,7 +261,7 @@ private function get_success_message() { * * @return stdClass */ - public function get_update_details() { + public function get_update_details(): stdClass { $update = new stdClass; if ( ! empty( $this->response->api_invalid ) ) { @@ -296,7 +290,7 @@ public function get_update_details() { $update->url = $this->response->homepage ?? ''; $update->tested = $this->response->tested ?? ''; $update->requires = $this->response->requires ?? ''; - $update->package = $this->response->download_url ? $this->response->download_url . '&key=' . urlencode( $this->get_key() ) : ''; + $update->package = ! empty( $this->response->download_url ) ? $this->response->download_url . '&key=' . urlencode( $this->get_key() ) : ''; if ( ! empty( $this->response->upgrade_notice ) ) { $update->upgrade_notice = $this->response->upgrade_notice; @@ -415,7 +409,7 @@ public function is_valid() : bool { * * @return void */ - public function set_is_valid( bool $is_valid ) { + public function set_is_valid( bool $is_valid ): void { $this->is_valid = $is_valid; } @@ -426,9 +420,9 @@ public function set_is_valid( bool $is_valid ) { * * @return void */ - private function parse() { + private function parse(): void { $this->current_key = $this->resource->get_license_key( $this->validation_type ); - $this->expiration = isset( $this->response->expiration ) ? $this->response->expiration : __( 'unknown date', '%TEXTDOMAIN%' ); + $this->expiration = $this->response->expiration ?? __( 'unknown date', '%TEXTDOMAIN%' ); if ( ! empty( $this->response->api_inline_invalid_message ) ) { $this->api_response_message = wp_kses( $this->response->api_inline_invalid_message, 'post' ); @@ -478,9 +472,9 @@ private function parse() { /** * Transform plugin info into the format used by the native WordPress.org API * - * @return object + * @return StdClass */ - public function to_wp_format() { + public function to_wp_format(): stdClass { $info = new StdClass; // The custom update API is built so that many fields have the same name and format @@ -511,7 +505,7 @@ public function to_wp_format() { } //Other fields need to be renamed and/or transformed. - $info->download_link = isset( $this->response->download_url ) ? $this->response->download_url : ''; + $info->download_link = $this->response->download_url ?? ''; if ( ! empty( $this->author_homepage ) && ! empty( $this->response->author ) ) { $info->author = sprintf( '%s', esc_url( $this->author_homepage ), $this->response->author ); diff --git a/src/Uplink/Admin/Ajax.php b/src/Uplink/Admin/Ajax.php index 398c3e38..8f30ab25 100644 --- a/src/Uplink/Admin/Ajax.php +++ b/src/Uplink/Admin/Ajax.php @@ -2,20 +2,42 @@ namespace StellarWP\Uplink\Admin; -use StellarWP\ContainerContract\ContainerInterface; -use StellarWP\Uplink\Config; +use StellarWP\Uplink\Auth\License\License_Manager; use StellarWP\Uplink\Resources\Collection; use StellarWP\Uplink\Utils; class Ajax { /** - * @var ContainerInterface + * @var Collection */ - protected $container; + protected $resources; - public function __construct() { - $this->container = Config::get_container(); + /** + * @var License_Field + */ + protected $field; + + /** + * @var License_Manager + */ + protected $license_manager; + + /** + * Constructor. + * + * @param Collection $resources The plugin/services collection. + * @param License_Field $field The license field. + * @param License_Manager $license_manager The license manager. + */ + public function __construct( + Collection $resources, + License_Field $field, + License_Manager $license_manager + ) { + $this->resources = $resources; + $this->field = $field; + $this->license_manager = $license_manager; } /** @@ -29,15 +51,14 @@ public function validate_license(): void { 'key' => Utils\Sanitize::key( wp_unslash( $_POST['key'] ?? '' ) ), ]; - if ( empty( $submission['key'] ) || ! wp_verify_nonce( $submission['_wpnonce'], $this->container->get( License_Field::class )->get_group_name() ) ) { + if ( empty( $submission['key'] ) || ! wp_verify_nonce( $submission['_wpnonce'], $this->field->get_group_name() ) ) { wp_send_json_error( [ 'status' => 0, 'message' => __( 'Invalid request: nonce field is expired. Please try again.', '%TEXTDOMAIN%' ), ] ); } - $collection = $this->container->get( Collection::class ); - $plugin = $collection->offsetGet( $submission['slug'] ); + $plugin = $this->resources->offsetGet( $submission['slug'] ); if ( ! $plugin ) { wp_send_json_error( [ @@ -49,8 +70,9 @@ public function validate_license(): void { ] ); } - $results = $plugin->validate_license( $submission['key'] ); - $message = is_plugin_active_for_network( $plugin->get_path() ) ? $results->get_network_message()->get() : $results->get_message()->get(); + $network_validate = $this->license_manager->allows_multisite_license( $plugin ); + $results = $plugin->validate_license( $submission['key'], $network_validate ); + $message = $network_validate ? $results->get_network_message()->get() : $results->get_message()->get(); wp_send_json( [ 'status' => absint( $results->is_valid() ), diff --git a/src/Uplink/Admin/License_Field.php b/src/Uplink/Admin/License_Field.php index a3e4dfcd..6b8b0d70 100644 --- a/src/Uplink/Admin/License_Field.php +++ b/src/Uplink/Admin/License_Field.php @@ -2,6 +2,7 @@ namespace StellarWP\Uplink\Admin; +use StellarWP\Uplink\Auth\License\License_Manager; use StellarWP\Uplink\Config; use StellarWP\Uplink\Resources\Plugin; use StellarWP\Uplink\Resources\Resource; @@ -17,6 +18,13 @@ class License_Field extends Field { */ protected $path = '/admin-views/fields/settings.php'; + /** + * The license manager. + * + * @var License_Manager + */ + private $license_manager; + /** * The script and style handle when registering assets for this field. * @@ -25,10 +33,15 @@ class License_Field extends Field { private $handle; /** - * Constructor. Initializes handle. + * Constructor. + * + * @param License_Manager $license_manager + * + * @throws \RuntimeException */ - public function __construct() { - $this->handle = sprintf( 'stellarwp-uplink-license-admin-%s', Config::get_hook_prefix() ); + public function __construct( License_Manager $license_manager ) { + $this->license_manager = $license_manager; + $this->handle = sprintf( 'stellarwp-uplink-license-admin-%s', Config::get_hook_prefix() ); } /** @@ -59,6 +72,8 @@ public function register_settings(): void { $resource->get_license_object()->get_key_option_name() ); + $network = $this->license_manager->allows_multisite_license( $resource ); + add_settings_field( $resource->get_license_object()->get_key_option_name(), __( 'License Key', '%TEXTDOMAIN%' ), @@ -70,7 +85,7 @@ public function register_settings(): void { 'label_for' => $resource->get_license_object()->get_key_option_name(), 'type' => 'text', 'path' => $resource->get_path(), - 'value' => $resource->get_license_key(), + 'value' => $resource->get_license_key( $network ? 'network' : 'local' ), 'placeholder' => __( 'License Number', '%TEXTDOMAIN%' ), 'html' => $this->get_field_html( $resource ), 'html_classes' => 'stellarwp-uplink-license-key-field', diff --git a/src/Uplink/Admin/Package_Handler.php b/src/Uplink/Admin/Package_Handler.php index ee34e3b2..2c47802d 100644 --- a/src/Uplink/Admin/Package_Handler.php +++ b/src/Uplink/Admin/Package_Handler.php @@ -5,6 +5,7 @@ use StellarWP\Uplink\API; use StellarWP\Uplink\Config; use StellarWP\Uplink\Resources; +use WP_Error; use WP_Upgrader; class Package_Handler { @@ -17,17 +18,17 @@ class Package_Handler { /** * Filters the package download step to store the downloaded file with a shorter file name. * - * @param bool|\WP_Error $reply Whether to bail without returning the package. - * Default false. - * @param string $package The package file name or URL. - * @param WP_Upgrader $upgrader The WP_Upgrader instance. - * @param array $hook_extra Extra arguments passed to hooked filters. + * @param bool|WP_Error $reply Whether to bail without returning the package. + * Default false. + * @param string|null $package The package file name or URL. + * @param WP_Upgrader $upgrader The WP_Upgrader instance. + * @param array $hook_extra Extra arguments passed to hooked filters. * - * @return mixed + * @return string|bool|WP_Error */ - public function filter_upgrader_pre_download( $reply, string $package, WP_Upgrader $upgrader, $hook_extra ) { + public function filter_upgrader_pre_download( $reply, $package, WP_Upgrader $upgrader, $hook_extra ) { if ( empty( $package ) || 'invalid_license' === $package ) { - return new \WP_Error( + return new WP_Error( 'download_failed', __( 'Failed to update plugin. Check your license details first.', '%TEXTDOMAIN%' ), '' @@ -149,7 +150,7 @@ protected function is_uplink_package_url( string $package, $hook_extra ) : bool * @param string $package The URI of the package. If this is the full path to an * existing local file, it will be returned untouched. * - * @return string|bool|\WP_Error The full path to the downloaded package file, or a WP_Error object. + * @return string|bool|WP_Error The full path to the downloaded package file, or a WP_Error object. */ protected function download( string $package ) { if ( empty( $this->filesystem ) ) { @@ -174,7 +175,7 @@ protected function download( string $package ) { $download_file = download_url( $package ); if ( is_wp_error( $download_file ) ) { - return new \WP_Error( + return new WP_Error( 'download_failed', $this->upgrader->strings['download_failed'], $download_file->get_error_message() diff --git a/src/Uplink/Admin/Provider.php b/src/Uplink/Admin/Provider.php index dd8ae331..c9065246 100644 --- a/src/Uplink/Admin/Provider.php +++ b/src/Uplink/Admin/Provider.php @@ -94,6 +94,10 @@ public function ajax_validate_license(): void { * @return void */ public function admin_init(): void { + if ( wp_doing_ajax() ) { + return; + } + $this->container->get( License_Field::class )->register_settings(); } @@ -152,11 +156,11 @@ public function store_admin_notices( $page ): void { * * @param bool|\WP_Error $reply Whether to bail without returning the package. * Default false. - * @param string $package The package file name or URL. + * @param string|null $package The package file name or URL. * @param \WP_Upgrader $upgrader The WP_Upgrader instance. * @param array $hook_extra Extra arguments passed to hooked filters. * - * @return mixed + * @return string|bool|\WP_Error */ public function filter_upgrader_pre_download( $reply, $package, $upgrader, $hook_extra ) { return $this->container->get( Package_Handler::class )->filter_upgrader_pre_download( $reply, $package, $upgrader, $hook_extra ); diff --git a/src/Uplink/Auth/Action_Manager.php b/src/Uplink/Auth/Action_Manager.php new file mode 100644 index 00000000..80f13842 --- /dev/null +++ b/src/Uplink/Auth/Action_Manager.php @@ -0,0 +1,122 @@ +disconnect_controller = $disconnect_controller; + $this->connect_controller = $connect_controller; + $this->resources = $resources; + } + + /** + * Get the resource's unique hook name. + * + * @param Resource $resource The resource. + * + * @throws \RuntimeException + * + * @return string + */ + public function get_hook_name( Resource $resource ): string { + return sprintf( 'stellarwp/uplink/%s/%s_%s', + Config::get_hook_prefix(), + self::ACTION, + $resource->get_slug() + ); + } + + /** + * Register a unique action for each resource in order to fire off connect/disconnect logic + * uniquely so as one plugin would not interfere with another. + * + * @action admin_init + * + * @throws \RuntimeException + * + * @return void + */ + public function add_actions(): void { + foreach ( $this->resources as $resource ) { + $hook_name = $this->get_hook_name( $resource ); + + add_action( + $hook_name, + [ $this->disconnect_controller, 'maybe_disconnect' ] + ); + + add_action( + $hook_name, + [ $this->connect_controller, 'maybe_store_token_data' ] + ); + } + } + + /** + * When an `uplink_slug` query parameter is available, fire off the appropriate + * resource action. + * + * @action current_screen + * + * @throws \RuntimeException + * + * @return void + */ + public function do_action(): void { + if ( empty( $_REQUEST[ Disconnect_Controller::SLUG ] ) ) { + return; + } + + $action = $_REQUEST[ Disconnect_Controller::SLUG ]; + + /** + * Fires when an 'uplink_slug' request variable is sent. + * + * The dynamic portion of the hook name, `$action`, refers to + * the action derived from the `GET` or `POST` request. + */ + do_action( sprintf( 'stellarwp/uplink/%s/%s_%s', + Config::get_hook_prefix(), + self::ACTION, + $action + ) ); + } + +} diff --git a/src/Uplink/Auth/Admin/Connect_Controller.php b/src/Uplink/Auth/Admin/Connect_Controller.php index f74ebc26..f415ddc2 100644 --- a/src/Uplink/Auth/Admin/Connect_Controller.php +++ b/src/Uplink/Auth/Admin/Connect_Controller.php @@ -2,6 +2,8 @@ namespace StellarWP\Uplink\Auth\Admin; +use StellarWP\Uplink\Auth\Authorizer; +use StellarWP\Uplink\Auth\License\License_Manager; use StellarWP\Uplink\Auth\Nonce; use StellarWP\Uplink\Auth\Token\Connector; use StellarWP\Uplink\Auth\Token\Exceptions\InvalidTokenException; @@ -35,27 +37,42 @@ final class Connect_Controller { */ private $collection; + /** + * @var License_Manager + */ + private $license_manager; - public function __construct( Connector $connector, Notice_Handler $notice, Collection $collection ) { - $this->connector = $connector; - $this->notice = $notice; - $this->collection = $collection; + /** + * @var Authorizer + */ + private $authorizer; + + public function __construct( + Connector $connector, + Notice_Handler $notice, + Collection $collection, + License_Manager $license_manager, + Authorizer $authorizer + ) { + $this->connector = $connector; + $this->notice = $notice; + $this->collection = $collection; + $this->license_manager = $license_manager; + $this->authorizer = $authorizer; } /** * Store the token data passed back from the Origin site. * - * @action admin_init + * @action stellarwp/uplink/{$prefix}/admin_action_{$slug} + * + * @throws \RuntimeException */ public function maybe_store_token_data(): void { if ( ! is_admin() || wp_doing_ajax() ) { return; } - if ( ! is_user_logged_in() ) { - return; - } - $args = array_intersect_key( $_GET, [ self::TOKEN => true, self::NONCE => true, @@ -67,7 +84,31 @@ public function maybe_store_token_data(): void { return; } + if ( ! $this->authorizer->can_auth() ) { + $this->notice->add( new Notice( Notice::ERROR, + __( 'Sorry, you do not have permission to connect a token.', '%TEXTDOMAIN%' ), + true + ) ); + + return; + } + if ( ! Nonce::verify( $args[ self::NONCE ] ?? '' ) ) { + if ( ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + // The Litespeed plugin allows completely disabling transients for some reason... + if ( is_plugin_active( 'litespeed-cache/litespeed-cache.php' ) ) { + $this->notice->add( new Notice( Notice::ERROR, + sprintf( + __( 'The Litespeed plugin was detected, ensure "Store Transients" is set to ON and try again. See the Litespeed documentation for more information.', '%TEXTDOMAIN%' ), + esc_url( 'https://docs.litespeedtech.com/lscache/lscwp/cache/#store-transients' ) + ), + true + ) ); + } + $this->notice->add( new Notice( Notice::ERROR, __( 'Unable to save token data: nonce verification failed.', '%TEXTDOMAIN%' ), true @@ -76,8 +117,20 @@ public function maybe_store_token_data(): void { return; } + $slug = $args[ self::SLUG ] ?? ''; + $plugin = $this->collection->offsetGet( $slug ); + + if ( ! $plugin ) { + $this->notice->add( new Notice( Notice::ERROR, + __( 'Plugin or Service slug not found.', '%TEXTDOMAIN%' ), + true + ) ); + + return; + } + try { - if ( ! $this->connector->connect( $args[ self::TOKEN ] ?? '' ) ) { + if ( ! $this->connector->connect( $args[ self::TOKEN ] ?? '', $plugin ) ) { $this->notice->add( new Notice( Notice::ERROR, __( 'Error storing token.', '%TEXTDOMAIN%' ), true @@ -95,22 +148,22 @@ public function maybe_store_token_data(): void { } $license = $args[ self::LICENSE ] ?? ''; - $slug = $args[ self::SLUG ] ?? ''; // Store or override an existing license. - if ( $license && $slug ) { - if ( ! $this->collection->offsetExists( $slug ) ) { + if ( $license ) { + $network = $this->license_manager->allows_multisite_license( $plugin ); + $response = $plugin->validate_license( $license, $network ); + + if ( ! $response->is_valid() ) { $this->notice->add( new Notice( Notice::ERROR, - __( 'Plugin or Service slug not found.', '%TEXTDOMAIN%' ), + __( 'Provided license key is not valid.', '%TEXTDOMAIN%' ), true ) ); return; } - $plugin = $this->collection->offsetGet( $slug ); - - if ( ! $plugin->set_license_key( $license, 'network' ) ) { + if ( ! $plugin->set_license_key( $license, $network ? 'network' : 'local' ) ) { $this->notice->add( new Notice( Notice::ERROR, __( 'Error storing license key.', '%TEXTDOMAIN%' ), true diff --git a/src/Uplink/Auth/Admin/Disconnect_Controller.php b/src/Uplink/Auth/Admin/Disconnect_Controller.php index 8d61f096..88e66286 100644 --- a/src/Uplink/Auth/Admin/Disconnect_Controller.php +++ b/src/Uplink/Auth/Admin/Disconnect_Controller.php @@ -2,13 +2,24 @@ namespace StellarWP\Uplink\Auth\Admin; +use StellarWP\Uplink\API\V3\Auth\Token_Authorizer_Cache_Decorator; +use StellarWP\Uplink\Auth\Authorizer; use StellarWP\Uplink\Auth\Token\Disconnector; +use StellarWP\Uplink\Auth\Token\Token_Factory; use StellarWP\Uplink\Notice\Notice_Handler; use StellarWP\Uplink\Notice\Notice; +use StellarWP\Uplink\Resources\Resource; final class Disconnect_Controller { - public const ARG = 'uplink_disconnect'; + public const ARG = 'uplink_disconnect'; + public const SLUG = 'uplink_slug'; + public const CACHE_KEY = 'uplink_cache'; + + /** + * @var Authorizer + */ + private $authorizer; /** * @var Disconnector @@ -21,23 +32,70 @@ final class Disconnect_Controller { private $notice; /** - * @param Disconnector $disconnect Disconnects a Token, if the user has the capability. - * @param Notice_Handler $notice Handles storing and displaying notices. + * @var Token_Factory + */ + private $token_factory; + + /** + * @var Token_Authorizer_Cache_Decorator + */ + private $cache; + + /** + * @param Authorizer $authorizer The authorizer. + * @param Disconnector $disconnect Disconnects a Token, if the user has the capability. + * @param Notice_Handler $notice Handles storing and displaying notices. + * @param Token_Factory $token_factory The token factory. + * @param Token_Authorizer_Cache_Decorator $cache The token cache. + */ + public function __construct( + Authorizer $authorizer, + Disconnector $disconnect, + Notice_Handler $notice, + Token_Factory $token_factory, + Token_Authorizer_Cache_Decorator $cache + ) { + $this->authorizer = $authorizer; + $this->disconnect = $disconnect; + $this->notice = $notice; + $this->token_factory = $token_factory; + $this->cache = $cache; + } + + /** + * Get the disconnect URL to render. + * + * @param Resource $plugin The plugin/service. + * + * @return string */ - public function __construct( Disconnector $disconnect, Notice_Handler $notice ) { - $this->disconnect = $disconnect; - $this->notice = $notice; + public function get_url( Resource $plugin ): string { + $token = $this->token_factory->make( $plugin )->get(); + + if ( ! $token ) { + return ''; + } + + $cache_key = $this->cache->build_transient_no_prefix( [ $token ] ); + + return wp_nonce_url( add_query_arg( [ + self::ARG => true, + self::SLUG => $plugin->get_slug(), + self::CACHE_KEY => $cache_key, + ], get_admin_url( get_current_blog_id() ) ), self::ARG ); } /** * Disconnect (delete) a token if the user is allowed to. * - * @action admin_init + * @action stellarwp/uplink/{$prefix}/admin_action_{$slug} + * + * @throws \RuntimeException * * @return void */ public function maybe_disconnect(): void { - if ( empty( $_GET[ self::ARG ] ) || empty( $_GET['_wpnonce'] ) ) { + if ( empty( $_GET[ self::ARG ] ) || empty( $_GET['_wpnonce'] ) || empty( $_GET[ self::SLUG ] ) || empty( $_GET[ self::CACHE_KEY ] ) ) { return; } @@ -46,7 +104,7 @@ public function maybe_disconnect(): void { } if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), self::ARG ) ) { - if ( $this->disconnect->disconnect() ) { + if ( $this->authorizer->can_auth() && $this->disconnect->disconnect( $_GET[ self::SLUG ], $_GET[ self::CACHE_KEY ] ) ) { $this->notice->add( new Notice( Notice::SUCCESS, __( 'Token disconnected.', '%TEXTDOMAIN%' ), diff --git a/src/Uplink/Auth/Auth_Pipes/Multisite_Subfolder_Check.php b/src/Uplink/Auth/Auth_Pipes/Multisite_Subfolder_Check.php deleted file mode 100644 index b480571b..00000000 --- a/src/Uplink/Auth/Auth_Pipes/Multisite_Subfolder_Check.php +++ /dev/null @@ -1,44 +0,0 @@ -token_manager = $token_manager; - } - - /** - * Checks if a sub-site already has a network token. - * - * @param bool $can_auth - * @param Closure $next - * - * @return bool - */ - public function __invoke( bool $can_auth, Closure $next ): bool { - if ( ! is_multisite() ) { - return $next( $can_auth ); - } - - if ( is_main_site() ) { - return $next( $can_auth ); - } - - // Token already exists at the network level, don't authorize for this sub-site. - if ( $this->token_manager->get() ) { - return false; - } - - return $next( $can_auth ); - } - -} diff --git a/src/Uplink/Auth/Auth_Pipes/User_Check.php b/src/Uplink/Auth/Auth_Pipes/User_Check.php deleted file mode 100644 index de5b9efa..00000000 --- a/src/Uplink/Auth/Auth_Pipes/User_Check.php +++ /dev/null @@ -1,36 +0,0 @@ -pipeline = $pipeline; - } - - /** - * Runs the pipeline which executes a series of checks to determine if - * the user can use the authorize button on the current site. + * Checks if the current user can perform an action. * - * @see Provider::register_authorizer() + * @throws \RuntimeException * * @return bool */ public function can_auth(): bool { - return $this->pipeline->send( true )->thenReturn(); + return (bool) apply_filters( + 'stellarwp/uplink/' . Config::get_hook_prefix() . '/auth/user_check', + is_super_admin() + ); } } diff --git a/src/Uplink/Auth/License/License_Manager.php b/src/Uplink/Auth/License/License_Manager.php new file mode 100644 index 00000000..5e9acb63 --- /dev/null +++ b/src/Uplink/Auth/License/License_Manager.php @@ -0,0 +1,62 @@ + + */ + private $cache; + + /** + * @param Pipeline $pipeline + */ + public function __construct( Pipeline $pipeline ) { + $this->pipeline = $pipeline; + } + + /** + * Check if the current multisite and Uplink configuration allows a multisite + * license for the current subsite. + * + * Out of the box, sub-sites act independently of the network. + * + * @see Config::set_network_subfolder_license() + * @see Config::set_network_subdomain_license() + * @see Config::set_network_domain_mapping_license() + * + * @param Resource $resource The current resource to check against. + * + * @return bool + */ + public function allows_multisite_license( Resource $resource ): bool { + $key = $resource->get_slug(); + $cache = $this->cache[ $key ] ?? null; + + if ( $cache !== null ) { + return $cache; + } + + // We're on single site or, the plugin isn't network activated. + if ( ! is_multisite() || ! $resource->is_network_activated() ) { + return $this->cache[ $key ] = false; + } + + return $this->cache[ $key ] = $this->pipeline->send( false )->thenReturn(); + } + +} diff --git a/src/Uplink/Auth/License/Pipeline/Processors/Multisite_Domain_Mapping.php b/src/Uplink/Auth/License/Pipeline/Processors/Multisite_Domain_Mapping.php new file mode 100644 index 00000000..2740c47c --- /dev/null +++ b/src/Uplink/Auth/License/Pipeline/Processors/Multisite_Domain_Mapping.php @@ -0,0 +1,37 @@ +is_unique_domain() ) { + return Config::allows_network_domain_mapping_license(); + } + } catch ( Throwable $e ) { + return false; + } + + return $next( $is_multisite_license ); + } +} diff --git a/src/Uplink/Auth/License/Pipeline/Processors/Multisite_Main_Site.php b/src/Uplink/Auth/License/Pipeline/Processors/Multisite_Main_Site.php new file mode 100644 index 00000000..d15d750a --- /dev/null +++ b/src/Uplink/Auth/License/Pipeline/Processors/Multisite_Main_Site.php @@ -0,0 +1,29 @@ +is_subdomain() ) { + return Config::allows_network_subdomain_license(); + } + } catch ( Throwable $e ) { + return false; + } + + return $next( $is_multisite_license ); + } + +} diff --git a/src/Uplink/Auth/License/Pipeline/Processors/Multisite_Subfolder.php b/src/Uplink/Auth/License/Pipeline/Processors/Multisite_Subfolder.php new file mode 100644 index 00000000..3f8e4f31 --- /dev/null +++ b/src/Uplink/Auth/License/Pipeline/Processors/Multisite_Subfolder.php @@ -0,0 +1,33 @@ +is_subfolder_install() ) { + return Config::allows_network_subfolder_license(); + } + + return $next( $is_multisite_license ); + } + +} diff --git a/src/Uplink/Auth/License/Pipeline/Traits/Multisite_Trait.php b/src/Uplink/Auth/License/Pipeline/Traits/Multisite_Trait.php new file mode 100644 index 00000000..8c9586d1 --- /dev/null +++ b/src/Uplink/Auth/License/Pipeline/Traits/Multisite_Trait.php @@ -0,0 +1,83 @@ +get_main_site_url(), PHP_URL_HOST ); + + if ( ! $result ) { + throw new RuntimeException( 'Unable to determine the main site hostname' ); + } + + return $result; + } + + /** + * Get the current site's hostname. + * + * @throws RuntimeException + * + * @return string + */ + private function get_current_site_hostname(): string { + $result = wp_parse_url( $this->get_current_site_url(), PHP_URL_HOST ); + + if ( ! $result ) { + throw new RuntimeException( 'Unable to determine the current site hostname' ); + } + + return $result; + } + + /** + * Checks if we're running multisite in subfolder mode. + * + * @return bool + */ + private function is_subfolder_install(): bool { + return Checks::str_starts_with( $this->get_current_site_url(), $this->get_main_site_url() ); + } + + /** + * Check if the current site is a subdomain of the main site. + * + * @throws RuntimeException + * + * @return bool + */ + private function is_subdomain(): bool { + return Checks::str_ends_with( $this->get_current_site_hostname(), $this->get_main_site_hostname() ); + } + + /** + * Checks if the current site is a different domain from the main site. + * + * @throws RuntimeException + * + * @return bool + */ + private function is_unique_domain(): bool { + return ! str_contains( $this->get_current_site_hostname(), $this->get_main_site_hostname() ); + } + +} diff --git a/src/Uplink/Auth/Provider.php b/src/Uplink/Auth/Provider.php index 1d213d4d..c567d4f0 100644 --- a/src/Uplink/Auth/Provider.php +++ b/src/Uplink/Auth/Provider.php @@ -2,12 +2,15 @@ namespace StellarWP\Uplink\Auth; -use StellarWP\Uplink\Auth\Admin\Disconnect_Controller; use StellarWP\Uplink\Auth\Admin\Connect_Controller; -use StellarWP\Uplink\Auth\Auth_Pipes\Multisite_Subfolder_Check; -use StellarWP\Uplink\Auth\Auth_Pipes\Network_Token_Check; -use StellarWP\Uplink\Auth\Auth_Pipes\User_Check; -use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; +use StellarWP\Uplink\Auth\Admin\Disconnect_Controller; +use StellarWP\Uplink\Auth\License\License_Manager; +use StellarWP\Uplink\Auth\License\Pipeline\Processors\Multisite_Domain_Mapping; +use StellarWP\Uplink\Auth\License\Pipeline\Processors\Multisite_Main_Site; +use StellarWP\Uplink\Auth\License\Pipeline\Processors\Multisite_Subdomain; +use StellarWP\Uplink\Auth\License\Pipeline\Processors\Multisite_Subfolder; +use StellarWP\Uplink\Auth\Token\Managers\Network_Token_Manager; +use StellarWP\Uplink\Auth\Token\Managers\Token_Manager; use StellarWP\Uplink\Config; use StellarWP\Uplink\Contracts\Abstract_Provider; use StellarWP\Uplink\Pipeline\Pipeline; @@ -22,17 +25,23 @@ public function register() { return; } - $this->container->bind( + $this->container->singleton( Token_Manager::class, static function ( $c ) { - return new Token\Token_Manager( $c->get( Config::TOKEN_OPTION_NAME ) ); + return new Token_Manager( $c->get( Config::TOKEN_OPTION_NAME ) ); + } + ); + + $this->container->singleton( + Network_Token_Manager::class, + static function ( $c ) { + return new Network_Token_Manager( $c->get( Config::TOKEN_OPTION_NAME ) ); } ); $this->register_nonce(); - $this->register_authorizer(); - $this->register_auth_disconnect(); - $this->register_auth_connect(); + $this->register_license_manager(); + $this->register_auth_connect_disconnect(); } /** @@ -57,51 +66,44 @@ private function register_nonce(): void { } /** - * Registers the Authorizer and the steps in order for the pipeline - * processing. + * Register the license manager and its pipeline to detect different + * mulitsite licenses. + * + * @return void */ - private function register_authorizer(): void { - $this->container->singleton( - Network_Token_Check::class, - static function ( $c ) { - return new Network_Token_Check( $c->get( Token_Manager::class ) ); - } - ); - + private function register_license_manager(): void { $pipeline = ( new Pipeline( $this->container ) )->through( [ - User_Check::class, - Multisite_Subfolder_Check::class, - Network_Token_Check::class, + Multisite_Main_Site::class, + Multisite_Subfolder::class, + Multisite_Subdomain::class, + Multisite_Domain_Mapping::class, ] ); $this->container->singleton( - Authorizer::class, + License_Manager::class, static function () use ( $pipeline ) { - return new Authorizer( $pipeline ); + return new License_Manager( $pipeline ); } ); } /** - * Register auth disconnection definitions and hooks. + * Register token auth connection/disconnection definitions and hooks. * * @return void */ - private function register_auth_disconnect(): void { + private function register_auth_connect_disconnect(): void { $this->container->singleton( Disconnect_Controller::class, Disconnect_Controller::class ); + $this->container->singleton( Connect_Controller::class, Connect_Controller::class ); + $this->container->singleton( Action_Manager::class, Action_Manager::class ); - add_action( 'admin_init', [ $this->container->get( Disconnect_Controller::class ), 'maybe_disconnect' ], 9, 0 ); - } + $action_manager = $this->container->get( Action_Manager::class ); - /** - * Register auth connection definitions and hooks. - * - * @return void - */ - private function register_auth_connect(): void { - $this->container->singleton( Connect_Controller::class, Connect_Controller::class ); + // Register a unique action for each resource slug. + add_action( 'admin_init', [ $action_manager, 'add_actions' ] ); - add_action( 'admin_init', [ $this->container->get( Connect_Controller::class ), 'maybe_store_token_data'], 9, 0 ); + // Execute the above actions when an uplink_slug query variable and the current_screen hook is fired (which is run after admin_init). + add_action( 'current_screen', [ $action_manager, 'do_action' ], 10, 0 ); } } diff --git a/src/Uplink/Auth/Token/Connector.php b/src/Uplink/Auth/Token/Connector.php index b4ac265e..debc14fc 100644 --- a/src/Uplink/Auth/Token/Connector.php +++ b/src/Uplink/Auth/Token/Connector.php @@ -2,32 +2,21 @@ namespace StellarWP\Uplink\Auth\Token; -use StellarWP\Uplink\Auth\Authorizer; -use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Auth\Token\Exceptions\InvalidTokenException; +use StellarWP\Uplink\Resources\Resource; final class Connector { /** - * @var Authorizer + * @var Token_Factory */ - private $authorizer; + private $token_manager_factory; /** - * @var Token_Manager + * @param Token_Factory $token_manager_factory The Token Manager Factory. */ - private $token_manager; - - /** - * @param Authorizer $authorizer Determines if the current user can perform actions. - * @param Token_Manager $token_manager The Token Manager. - */ - public function __construct( - Authorizer $authorizer, - Token_Manager $token_manager - ) { - $this->authorizer = $authorizer; - $this->token_manager = $token_manager; + public function __construct( Token_Factory $token_manager_factory ) { + $this->token_manager_factory = $token_manager_factory; } /** @@ -35,16 +24,14 @@ public function __construct( * * @throws InvalidTokenException */ - public function connect( string $token ): bool { - if ( ! $this->authorizer->can_auth() ) { - return false; - } + public function connect( string $token, Resource $resource ): bool { + $token_manager = $this->token_manager_factory->make( $resource ); - if ( ! $this->token_manager->validate( $token ) ) { + if ( ! $token_manager->validate( $token ) ) { throw new InvalidTokenException( 'Invalid token format' ); } - return $this->token_manager->store( $token ); + return $token_manager->store( $token ); } } diff --git a/src/Uplink/Auth/Token/Disconnector.php b/src/Uplink/Auth/Token/Disconnector.php index 09e97026..55183a70 100644 --- a/src/Uplink/Auth/Token/Disconnector.php +++ b/src/Uplink/Auth/Token/Disconnector.php @@ -2,42 +2,56 @@ namespace StellarWP\Uplink\Auth\Token; -use StellarWP\Uplink\Auth\Authorizer; -use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; +use StellarWP\Uplink\API\V3\Auth\Token_Authorizer_Cache_Decorator; +use StellarWP\Uplink\Resources\Collection; final class Disconnector { /** - * @var Authorizer + * @var Token_Factory */ - private $authorizer; + private $token_manager_factory; /** - * @var Token_Manager + * @var Collection */ - private $token_manager; + private $resources; /** - * @param Authorizer $authorizer Determines if the current user can perform actions. - * @param Token_Manager $token_manager The Token Manager. + * @param Token_Factory $token_manager_factory The Token Manager Factory. + * @param Collection $resources The resources collection. */ public function __construct( - Authorizer $authorizer, - Token_Manager $token_manager + Token_Factory $token_manager_factory, + Collection $resources ) { - $this->authorizer = $authorizer; - $this->token_manager = $token_manager; + $this->token_manager_factory = $token_manager_factory; + $this->resources = $resources; } /** * Delete a token if the current user is allowed to. + * + * @param string $slug The plugin or service slug. + * @param string $cache_key The token cache key. + * + * @return bool */ - public function disconnect(): bool { - if ( ! $this->authorizer->can_auth() ) { + public function disconnect( string $slug, string $cache_key ): bool { + $plugin = $this->resources->offsetGet( $slug ); + + if ( ! $plugin ) { return false; } - return $this->token_manager->delete(); + $result = $this->token_manager_factory->make( $plugin )->delete(); + + if ( $result ) { + // Delete the authorization cache. + delete_transient( Token_Authorizer_Cache_Decorator::TRANSIENT_PREFIX . $cache_key ); + } + + return $result; } } diff --git a/src/Uplink/Auth/Token/Managers/Network_Token_Manager.php b/src/Uplink/Auth/Token/Managers/Network_Token_Manager.php new file mode 100644 index 00000000..45ef56a5 --- /dev/null +++ b/src/Uplink/Auth/Token/Managers/Network_Token_Manager.php @@ -0,0 +1,84 @@ +multisite_check(); + + if ( ! $token ) { + return false; + } + + // WordPress would otherwise return false if the items match. + if ( $token === $this->get() ) { + return true; + } + + return update_network_option( get_current_network_id(), $this->option_name, $token ); + } + + /** + * Get the token. + * + * @throws RuntimeException + * + * @return string|null + */ + public function get(): ?string { + $this->multisite_check(); + + return get_network_option( get_current_network_id(), $this->option_name, null ); + } + + /** + * Revoke the token. + * + * @throws RuntimeException + * + * @return bool + */ + public function delete(): bool { + $this->multisite_check(); + + // Already doesn't exist, WordPress would normally return false. + if ( $this->get() === null ) { + return true; + } + + return delete_network_option( get_current_network_id(), $this->option_name ); + } + + /** + * Check if multisite is enabled. + * + * @throws RuntimeException + * + * @return void + */ + private function multisite_check(): void { + if ( ! is_multisite() ) { + throw new RuntimeException( 'Multisite is not enabled' ); + } + } + +} diff --git a/src/Uplink/Auth/Token/Token_Manager.php b/src/Uplink/Auth/Token/Managers/Token_Manager.php similarity index 76% rename from src/Uplink/Auth/Token/Token_Manager.php rename to src/Uplink/Auth/Token/Managers/Token_Manager.php index a75d861b..c0bfc3f5 100644 --- a/src/Uplink/Auth/Token/Token_Manager.php +++ b/src/Uplink/Auth/Token/Managers/Token_Manager.php @@ -1,16 +1,16 @@ option_name, $token ); + return update_option( $this->option_name, $token ); } /** @@ -82,7 +82,7 @@ public function store( string $token ): bool { * @return string|null */ public function get(): ?string { - return get_network_option( get_current_network_id(), $this->option_name, null ); + return get_option( $this->option_name, null ); } /** @@ -96,7 +96,7 @@ public function delete(): bool { return true; } - return delete_network_option( get_current_network_id(), $this->option_name ); + return delete_option( $this->option_name ); } } diff --git a/src/Uplink/Auth/Token/Token_Factory.php b/src/Uplink/Auth/Token/Token_Factory.php new file mode 100644 index 00000000..4843dc92 --- /dev/null +++ b/src/Uplink/Auth/Token/Token_Factory.php @@ -0,0 +1,45 @@ +license_manager = $license_manager; + $this->container = $container; + } + + /** + * Makes Network or Single Site Token Manager instance. + * + * @param Resource $resource The resource to check against. + * + * @return Token_Manager + */ + public function make( Resource $resource ): Token_Manager { + $network_license = $this->license_manager->allows_multisite_license( $resource ); + + return $this->container->get( $network_license ? Network_Token_Manager::class : Managers\Token_Manager::class ); + } + +} diff --git a/src/Uplink/Components/Admin/Authorize_Button_Controller.php b/src/Uplink/Components/Admin/Authorize_Button_Controller.php index 4af91b2c..9aa39817 100644 --- a/src/Uplink/Components/Admin/Authorize_Button_Controller.php +++ b/src/Uplink/Components/Admin/Authorize_Button_Controller.php @@ -6,9 +6,10 @@ use StellarWP\Uplink\Auth\Admin\Disconnect_Controller; use StellarWP\Uplink\Auth\Auth_Url_Builder; use StellarWP\Uplink\Auth\Authorizer; -use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; +use StellarWP\Uplink\Auth\Token\Token_Factory; use StellarWP\Uplink\Components\Controller; use StellarWP\Uplink\Config; +use StellarWP\Uplink\Resources\Collection; use StellarWP\Uplink\View\Contracts\View; final class Authorize_Button_Controller extends Controller { @@ -24,32 +25,48 @@ final class Authorize_Button_Controller extends Controller { private $authorizer; /** - * @var Token_Manager + * @var Token_Factory */ - private $token_manager; + private $token_manager_factory; /** * @var Auth_Url_Builder */ private $url_builder; + /** + * @var Collection + */ + private $resources; + + /** + * @var Disconnect_Controller + */ + private $disconnect_controller; + /** * @param View $view The View Engine to render views. * @param Authorizer $authorizer Determines if the current user can perform actions. - * @param Token_Manager $token_manager The Token Manager. + * @param Token_Factory $token_manager_factory The Token Manager Factory. * @param Auth_Url_Builder $url_builder The Auth URL Builder. + * @param Collection $resources The resources collection. + * @param Disconnect_Controller $disconnect_controller The disconnect controller. */ public function __construct( View $view, Authorizer $authorizer, - Token_Manager $token_manager, - Auth_Url_Builder $url_builder + Token_Factory $token_manager_factory, + Auth_Url_Builder $url_builder, + Collection $resources, + Disconnect_Controller $disconnect_controller ) { parent::__construct( $view ); - $this->authorizer = $authorizer; - $this->token_manager = $token_manager; - $this->url_builder = $url_builder; + $this->authorizer = $authorizer; + $this->token_manager_factory = $token_manager_factory; + $this->url_builder = $url_builder; + $this->resources = $resources; + $this->disconnect_controller = $disconnect_controller; } /** @@ -77,6 +94,12 @@ public function render( array $args = [] ): void { return; } + $plugin = $this->resources->offsetGet( $slug ); + + if ( ! $plugin ) { + return; + } + $authenticated = false; $target = '_blank'; $link_text = __( 'Connect', '%TEXTDOMAIN%' ); @@ -89,11 +112,11 @@ public function render( array $args = [] ): void { $target = '_self'; $link_text = __( 'Contact your network administrator to connect', '%TEXTDOMAIN%' ); $url = get_admin_url( get_current_blog_id(), 'network/' ); - } elseif ( $this->token_manager->get() ) { + } elseif ( $this->token_manager_factory->make( $plugin )->get() ) { $authenticated = true; $target = '_self'; $link_text = __( 'Disconnect', '%TEXTDOMAIN%' ); - $url = wp_nonce_url( add_query_arg( [ Disconnect_Controller::ARG => true ], get_admin_url( get_current_blog_id() ) ), Disconnect_Controller::ARG ); + $url = $this->disconnect_controller->get_url( $plugin ); $classes[1] = 'authorized'; } diff --git a/src/Uplink/Config.php b/src/Uplink/Config.php index a06563b6..676ca3d2 100644 --- a/src/Uplink/Config.php +++ b/src/Uplink/Config.php @@ -10,13 +10,20 @@ class Config { - public const TOKEN_OPTION_NAME = 'uplink.token_prefix'; + public const TOKEN_OPTION_NAME = 'uplink.token_prefix'; /** * The default authorization cache time in seconds (6 hours). */ public const DEFAULT_AUTH_CACHE = 21600; + /** + * The default state for all multisite licensing options. + * + * @var bool + */ + public const DEFAULT_MULTISITE_STATE = false; + /** * Container object. * @@ -43,6 +50,27 @@ class Config { */ protected static $auth_cache_expiration = self::DEFAULT_AUTH_CACHE; + /** + * Whether your plugin allows multisite network subfolder licenses. + * + * @var bool + */ + protected static $network_subfolder_license = self::DEFAULT_MULTISITE_STATE; + + /** + * Whether your plugin allows multisite subdomain licenses. + * + * @var bool + */ + protected static $network_subdomain_license = self::DEFAULT_MULTISITE_STATE; + + /** + * Whether your plugin allows multisite domain mapping licenses. + * + * @var bool + */ + protected static $network_domain_mapping_license = self::DEFAULT_MULTISITE_STATE; + /** * Get the container. * @@ -115,8 +143,11 @@ public static function has_container(): bool { * @return void */ public static function reset(): void { - static::$hook_prefix = ''; - static::$auth_cache_expiration = self::DEFAULT_AUTH_CACHE; + static::$hook_prefix = ''; + static::$auth_cache_expiration = self::DEFAULT_AUTH_CACHE; + static::$network_subfolder_license = self::DEFAULT_MULTISITE_STATE; + static::$network_subdomain_license = self::DEFAULT_MULTISITE_STATE; + static::$network_domain_mapping_license = self::DEFAULT_MULTISITE_STATE; if ( self::has_container() ) { self::$container->singleton( self::TOKEN_OPTION_NAME, null ); @@ -208,4 +239,96 @@ public static function get_auth_cache_expiration(): int { return static::$auth_cache_expiration; } + /** + * Allow or disallow multisite subfolder licenses at the network level. + * + * @param bool $allowed + * + * @return void + */ + public static function set_network_subfolder_license( bool $allowed ): void { + self::$network_subfolder_license = $allowed; + } + + /** + * Whether your plugin allows multisite network subfolder licenses. + * + * @throws RuntimeException + * + * @return bool + */ + public static function allows_network_subfolder_license(): bool { + return (bool) apply_filters( + 'stellarwp/uplink/' . Config::get_hook_prefix() . '/allows_network_subfolder_license', + self::$network_subfolder_license + ); + } + + /** + * Allow or disallow multisite subdomain licenses at the network level. + * + * @param bool $allowed + * + * @return void + */ + public static function set_network_subdomain_license( bool $allowed ): void { + self::$network_subdomain_license = $allowed; + } + + /** + * Whether your plugin allows multisite network subdomain licenses. + * + * @throws RuntimeException + * + * @return bool + */ + public static function allows_network_subdomain_license(): bool { + return (bool) apply_filters( + 'stellarwp/uplink/' . Config::get_hook_prefix() . '/allows_network_subdomain_license', + self::$network_subdomain_license + ); + } + + /** + * Allow or disallow multisite domain mapping licenses at the network level. + * + * @param bool $allowed + * + * @return void + */ + public static function set_network_domain_mapping_license( bool $allowed ): void { + self::$network_domain_mapping_license = $allowed; + } + + /** + * Whether your plugin allows multisite network domain mapping licenses. + * + * @throws RuntimeException + * + * @return bool + */ + public static function allows_network_domain_mapping_license(): bool { + return (bool) apply_filters( + 'stellarwp/uplink/' . Config::get_hook_prefix() . '/allows_network_domain_mapping_license', + self::$network_domain_mapping_license + ); + } + + /** + * Check if any of the network license options are enabled. + * + * @throws RuntimeException + * + * @return bool + */ + public static function allows_network_licenses(): bool { + $config = [ + self::allows_network_subfolder_license(), + self::allows_network_subdomain_license(), + self::allows_network_domain_mapping_license(), + ]; + + return in_array( true, $config, true ); + } + } diff --git a/src/Uplink/Messages/Expired_Key.php b/src/Uplink/Messages/Expired_Key.php index 40fdf869..4bbff63d 100644 --- a/src/Uplink/Messages/Expired_Key.php +++ b/src/Uplink/Messages/Expired_Key.php @@ -1,20 +1,81 @@

'; - $message .= __( 'Your license is expired', '%TEXTDOMAIN%' ); - $message .= '' . - __( 'Renew Your License Now', '%TEXTDOMAIN%' ) . - '' . - __( ' (opens in a new window)', '%TEXTDOMAIN%' ) . - ''; - $message .= '

'; + // TEC only default link for backwards compatibility. + $default_link = in_array( + Config::get_hook_prefix(), + [ + 'the-events-calendar', + 'events-calendar-pro', + 'event-tickets', + 'event-tickets-plus', + 'tribe-filterbar', + 'events-virtual', + 'events-community', + 'events-community-tickets', + 'event-aggregator', + 'events-elasticsearch', + 'image-widget-plus', + 'advanced-post-manager', + 'tribe-eventbrite', + 'event-automator', + 'tec-seating', + ], + true ) ? 'https://evnt.is/195y' : ''; + + $message_link = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/messages/expired_key_link', $default_link ); + $renew_label = __( 'Renew Your License Now', '%TEXTDOMAIN%' ); + $opens_in_new_window = __( '(opens in a new window)', '%TEXTDOMAIN%' ); + $notice_text = __( 'Your license is expired', '%TEXTDOMAIN%' ); + + if ( ! empty( $message_link ) ) { + $message_content = sprintf( + '

%s %s %s

', + esc_html( $notice_text ), + esc_url( $message_link ), + esc_html( $renew_label ), + esc_html( $opens_in_new_window ) + ); + } else { + $message_content = sprintf( + '

%s

', + esc_html( $notice_text ) + ); + } + + $message_content = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/messages/expired_key', $message_content ); + + $allowed_html = [ + 'a' => [ + 'href' => [], + 'title' => [], + 'target' => [], + 'class' => [] + ], + 'br' => [], + 'em' => [], + 'strong' => [], + 'div' => [ + 'class' => [] + ], + 'p' => [ + 'class' => [] + ], + 'span' => [ + 'class' => [] + ], + ]; + + $message = '
'; + $message .= wp_kses( $message_content, $allowed_html ); + $message .= '
'; return $message; } diff --git a/src/Uplink/Notice/Notice_Controller.php b/src/Uplink/Notice/Notice_Controller.php index 4d745217..9bca9ece 100644 --- a/src/Uplink/Notice/Notice_Controller.php +++ b/src/Uplink/Notice/Notice_Controller.php @@ -3,6 +3,7 @@ namespace StellarWP\Uplink\Notice; use StellarWP\Uplink\Components\Controller; +use StellarWP\Uplink\View\Exceptions\FileNotFoundException; /** * Renders a notice. @@ -22,6 +23,8 @@ final class Notice_Controller extends Controller { * * @param array{type?: string, message?: string, dismissible?: bool, alt?: bool, large?: bool} $args The notice. * + * @throws FileNotFoundException If the view is not found. + * * @return void */ public function render( array $args = [] ): void { @@ -34,8 +37,27 @@ public function render( array $args = [] ): void { ]; echo $this->view->render( self::VIEW, [ - 'message' => $args['message'], - 'classes' => $this->classes( $classes ) + 'message' => $args['message'], + 'classes' => $this->classes( $classes ), + 'allowed_tags' => [ + 'a' => [ + 'href' => [], + 'title' => [], + 'target' => [], + 'rel' => [], + ], + 'br' => [], + 'code' => [], + 'em' => [], + 'pre' => [], + 'span' => [], + 'strong' => [], + ], + 'allowed_protocols' => [ + 'http', + 'https', + 'mailto', + ], ] ); } diff --git a/src/Uplink/Notice/Notice_Handler.php b/src/Uplink/Notice/Notice_Handler.php index 3d8b122b..a8393e1d 100644 --- a/src/Uplink/Notice/Notice_Handler.php +++ b/src/Uplink/Notice/Notice_Handler.php @@ -74,7 +74,7 @@ private function all(): array { * @return bool */ private function save(): bool { - return set_transient( self::TRANSIENT, $this->notices, 300 ); + return (bool) set_transient( self::TRANSIENT, $this->notices, 300 ); } /** @@ -83,7 +83,7 @@ private function save(): bool { * @return bool */ private function clear(): bool { - return delete_transient( self::TRANSIENT ); + return (bool) delete_transient( self::TRANSIENT ); } } diff --git a/src/Uplink/Resources/License.php b/src/Uplink/Resources/License.php index ec14f4eb..b2d760ff 100644 --- a/src/Uplink/Resources/License.php +++ b/src/Uplink/Resources/License.php @@ -3,6 +3,7 @@ namespace StellarWP\Uplink\Resources; use StellarWP\ContainerContract\ContainerInterface; +use StellarWP\Uplink\Auth\License\License_Manager; use StellarWP\Uplink\Config; use StellarWP\Uplink\Site\Data; use StellarWP\Uplink\Utils; @@ -262,13 +263,21 @@ public function get_key_origin_code(): string { * * @return string|null */ - protected function get_key_status() { + protected function get_key_status(): ?string { + $network = $this->container->get( License_Manager::class )->allows_multisite_license( $this->resource ); + $func = 'get_option'; + + if ( $network ) { + $func = 'get_site_option'; + } + /** @var string|null */ - $status = get_option( $this->get_key_status_option_name(), 'invalid' ); + $status = $func( $this->get_key_status_option_name(), 'invalid' ); + $key = $this->get_key( $network ? 'network' : 'local' ); - if ( null === $status && $this->get_key() ) { - $this->resource->validate_license( $this->get_key(), $this->resource->is_network_licensed() ); - $status = get_option( $this->get_key_status_option_name(), 'invalid' ); + if ( null === $status && $key ) { + $this->resource->validate_license( $key, $network ); + $status = $func( $this->get_key_status_option_name(), 'invalid' ); } return $status; @@ -360,7 +369,7 @@ public function set_key( string $key, string $type = 'local' ): bool { $this->key = $key; - if ( 'network' === $type && is_multisite() ) { + if ( 'network' === $type && is_multisite() && $this->resource->is_network_activated() ) { // WordPress would otherwise return false if the keys already match. if ( $this->get_key_from_network_option() === $key ) { return true; @@ -386,10 +395,18 @@ public function set_key( string $key, string $type = 'local' ): bool { * * @return void */ - public function set_key_status( $valid ) { - $status = Utils\Checks::is_truthy( $valid ) ? 'valid' : 'invalid'; - update_option( $this->get_key_status_option_name(), $status ); - update_option( $this->get_key_status_option_name() . '_timeout', $this->check_period * HOUR_IN_SECONDS ); + public function set_key_status( $valid ): void { + $status = Utils\Checks::is_truthy( $valid ) ? 'valid' : 'invalid'; + $network = $this->container->get( License_Manager::class )->allows_multisite_license( $this->resource ); + $timeout = $this->check_period * HOUR_IN_SECONDS; + $func = 'update_option'; + + if ( $network ) { + $func = 'update_site_option'; + } + + $func( $this->get_key_status_option_name(), $status ); + $func( $this->get_key_status_option_name() . '_timeout', $timeout ); } /** diff --git a/src/Uplink/Resources/Resource.php b/src/Uplink/Resources/Resource.php index a68e0dd3..b3289fda 100644 --- a/src/Uplink/Resources/Resource.php +++ b/src/Uplink/Resources/Resource.php @@ -2,6 +2,7 @@ namespace StellarWP\Uplink\Resources; +use stdClass; use StellarWP\ContainerContract\ContainerInterface; use StellarWP\Uplink\API; use StellarWP\Uplink\Config; @@ -173,7 +174,7 @@ public function get_download_args() { $args['plugin'] = sanitize_text_field( $this->get_slug() ); $args['installed_version'] = sanitize_text_field( $this->get_installed_version() ?: '' ); - $args['domain'] = sanitize_text_field( $data->get_domain() ); + $args['domain'] = sanitize_text_field( $data->get_domain( true ) ); // get general stats /** @var array> */ @@ -495,18 +496,18 @@ public function should_validate(): bool { * * @return API\Validation_Response */ - public function validate_license( $key = null, $do_network_validate = false ) { + public function validate_license( ?string $key = null, bool $do_network_validate = false ) { /** @var API\Client */ $api = $this->container->get( API\Client::class ); + $validation_type = $do_network_validate ? 'network' : 'local'; + if ( empty( $key ) ) { - $key = $this->get_license_key(); + $key = $this->get_license_key( $validation_type ); } - $validation_type = $do_network_validate ? 'network' : 'local'; - if ( empty( $key ) ) { - $results = new API\Validation_Response( null, $validation_type, new \stdClass(), $this ); + $results = new API\Validation_Response( null, $validation_type, new stdClass(), $this ); $results->set_is_valid( false ); return $results; } @@ -520,7 +521,7 @@ public function validate_license( $key = null, $do_network_validate = false ) { $result_type === 'new' || $has_replacement_key ) { - $this->get_license_object()->set_key( $results_key ); + $this->get_license_object()->set_key( $results_key, $validation_type ); } $this->get_license_object()->set_key_status( $results->is_valid() ); diff --git a/src/Uplink/Site/Data.php b/src/Uplink/Site/Data.php index 6143bbfb..38a5a046 100644 --- a/src/Uplink/Site/Data.php +++ b/src/Uplink/Site/Data.php @@ -99,14 +99,38 @@ public function get_db_version(): string { * * @since 1.0.0 * + * @param bool $original Return the original domain without any added hashes. + * + * @throws \RuntimeException + * * @return string */ - public function get_domain(): string { - $cache_key = 'stellarwp_uplink_domain'; - $domain = $this->container->has( $cache_key ) ? $this->container->get( $cache_key ) : null; + public function get_domain( bool $original = false ): string { + $cache_key = 'stellarwp_uplink_domain'; + $domain = $this->container->has( $cache_key ) ? $this->container->get( $cache_key ) : null; + $allows_network_subfolder_license = Config::allows_network_subfolder_license(); + + if ( is_multisite() ) { + // Ensure subsite also contains its path and don't cache. + if ( ! $allows_network_subfolder_license ) { + $domain = $this->get_domain_multisite_subsite_subfolder(); + } elseif ( $domain === null ) { + $domain = $this->get_domain_multisite_option(); + $this->container->bind( $cache_key, function() use ( $domain ) { return $domain; } ); + } - if ( null === $domain ) { - $domain = is_multisite() ? $this->get_domain_multisite_option() : $this->get_site_domain(); + /* + * Append a hash to the end of the main site domain when on multisite and network licenses + * are allowed. + * + * This prevents the main site from refreshing the token on the Licensing server + * when multisite is off or network licenses aren't enabled. + */ + if ( ! $original && Config::allows_network_licenses() ) { + $domain .= '/' . hash( 'crc32c', $domain ); + } + } elseif ( $domain === null ) { + $domain = $this->get_site_domain(); $this->container->bind( $cache_key, function() use ( $domain ) { return $domain; } ); } @@ -115,9 +139,10 @@ public function get_domain(): string { * * @since 1.0.0 * - * @param string $domain Domain. + * @param string $domain Domain. + * @param bool $allows_network_subfolder_license Whether network subfolder licenses are allowed. */ - $domain = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix(). '/get_domain', $domain ); + $domain = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix(). '/get_domain', $domain, $allows_network_subfolder_license ); return sanitize_text_field( $domain ); } @@ -129,7 +154,7 @@ public function get_domain(): string { */ protected function get_domain_multisite_option(): string { /** @var string */ - $site_url = get_site_option( 'siteurl', '' ); + $site_url = get_site_option( 'siteurl', '' ) ?: get_site_url(); // Fallback to get_site_url function if the network site url is empty which is possible. /** @var array */ $site_url = wp_parse_url( $site_url ); @@ -140,6 +165,35 @@ protected function get_domain_multisite_option(): string { return strtolower( $site_url['host'] ); } + /** + * When network licensing for multisite subfolders isn't active, + * each subsite domain needs to be unique, so we'll include the + * subfolder path. + * + * @return string + */ + protected function get_domain_multisite_subsite_subfolder(): string { + if ( is_main_site() ) { + return $this->get_domain_multisite_option(); + } + + $url = get_site_url(); + $url = wp_parse_url( $url ); + + if ( ! $url || ! isset( $url['host'] ) ) { + return ''; + } + + $domain = $url['host']; + + // Append the multisite subfolder path. + if ( ! empty( $url['path'] ) && $url['path'] !== '/' ) { + $domain .= $url['path']; + } + + return strtolower( $domain ); + } + /** * Gets multi-site active site count. * diff --git a/src/Uplink/Utils/Checks.php b/src/Uplink/Utils/Checks.php index e03a5eda..3befe4ce 100644 --- a/src/Uplink/Utils/Checks.php +++ b/src/Uplink/Utils/Checks.php @@ -70,4 +70,30 @@ public static function str_starts_with( string $haystack, string $needle ): bool return 0 === strncmp( $haystack, $needle, strlen( $needle ) ); } + /** + * String Ends With PHP80 polyfill. + * + * @param string $haystack The string to search in. + * @param string $needle The substring to search for in the haystack. + * + * @return bool Returns true if haystack ends with needle, false otherwise. + */ + public static function str_ends_with(string $haystack, string $needle): bool { + if ( function_exists( 'str_ends_with' ) ) { + return str_ends_with( $haystack, $needle ); + } + + if ( '' === $needle || $needle === $haystack ) { + return true; + } + + if ( '' === $haystack ) { + return false; + } + + $needleLength = strlen( $needle ); + + return $needleLength <= strlen( $haystack ) && 0 === substr_compare( $haystack, $needle, - $needleLength ); + } + } diff --git a/src/Uplink/functions.php b/src/Uplink/functions.php index e2544006..3894108f 100644 --- a/src/Uplink/functions.php +++ b/src/Uplink/functions.php @@ -3,11 +3,34 @@ namespace StellarWP\Uplink; use StellarWP\Uplink\API\V3\Auth\Contracts\Token_Authorizer; +use StellarWP\ContainerContract\ContainerInterface; +use StellarWP\Uplink\Admin\License_Field; +use StellarWP\Uplink\API\V3\Auth\Contracts\Auth_Url; +use StellarWP\Uplink\API\Validation_Response; +use StellarWP\Uplink\Auth\Admin\Disconnect_Controller; use StellarWP\Uplink\Auth\Auth_Url_Builder; -use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; +use StellarWP\Uplink\Auth\Authorizer; +use StellarWP\Uplink\Auth\License\License_Manager; +use StellarWP\Uplink\Auth\Token\Token_Factory; use StellarWP\Uplink\Components\Admin\Authorize_Button_Controller; +use StellarWP\Uplink\Resources\Collection; +use StellarWP\Uplink\Resources\Plugin; +use StellarWP\Uplink\Resources\Resource; +use StellarWP\Uplink\Resources\Service; +use StellarWP\Uplink\Site\Data; use Throwable; +/** + * Get the uplink container. + * + * @throws \RuntimeException + * + * @return ContainerInterface + */ +function get_container(): ContainerInterface { + return Config::get_container(); +} + /** * Displays the token authorization button, which allows admins to * authorize their product through your origin server and clear the @@ -18,7 +41,7 @@ */ function render_authorize_button( string $slug, string $domain = '' ): void { try { - Config::get_container()->get( Authorize_Button_Controller::class ) + get_container()->get( Authorize_Button_Controller::class ) ->render( [ 'slug' => $slug, 'domain' => $domain, @@ -31,20 +54,22 @@ function render_authorize_button( string $slug, string $domain = '' ): void { } /** - * Get the stored authorization token. + * Get the stored authorization token, automatically detects multisite. + * + * @param string $slug The plugin/service slug to use to determine if we use network/single site token storage. + * + * @throws \RuntimeException * * @return string|null */ -function get_authorization_token(): ?string { - try { - return Config::get_container()->get( Token_Manager::class )->get(); - } catch ( Throwable $e ) { - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - error_log( "Error occurred when fetching token: {$e->getMessage()} {$e->getFile()}:{$e->getLine()} {$e->getTraceAsString()}" ); - } +function get_authorization_token( string $slug ): ?string { + $resource = get_resource( $slug ); + if ( ! $resource ) { return null; } + + return get_container()->get( Token_Factory::class )->make( $resource )->get(); } /** @@ -53,16 +78,17 @@ function get_authorization_token(): ?string { * @note This response may be cached. * * @param string $license The license key. - * @param string $token The stored token. - * @param string $domain The user's domain. + * @param string $slug The plugin/service slug. + * @param string $token The stored token. + * @param string $domain The user's license domain. * * @return bool */ -function is_authorized( string $license, string $token, string $domain ): bool { +function is_authorized( string $license, string $slug, string $token, string $domain ): bool { try { - return Config::get_container() - ->get( Token_Authorizer::class ) - ->is_authorized( $license, $token, $domain ); + return get_container() + ->get( Token_Authorizer::class ) + ->is_authorized( $license, $slug, $token, $domain ); } catch ( Throwable $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( "An Authorization error occurred: {$e->getMessage()} {$e->getFile()}:{$e->getLine()} {$e->getTraceAsString()}" ); @@ -72,6 +98,47 @@ function is_authorized( string $license, string $token, string $domain ): bool { } } +/** + * Manually check if a license is authorized by fetching required + * data automatically. + * + * @param string $slug The plugin/service slug. + * + * @return bool + */ +function is_authorized_by_resource( string $slug ): bool { + try { + $license = get_license_key( $slug ); + $token = get_authorization_token( $slug ); + $domain = get_license_domain(); + + if ( ! $license || ! $token || ! $domain ) { + return false; + } + + return is_authorized( $license, $slug, $token, $domain ); + } catch ( Throwable $e ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( "An Authorization error occurred: {$e->getMessage()} {$e->getFile()}:{$e->getLine()} {$e->getTraceAsString()}" ); + } + + return false; + } +} + +/** + * If the current user is allowed to perform token authorization. + * + * Without being filtered, this just runs a is_super_admin() check. + * + * @throws \RuntimeException + * + * @return bool + */ +function is_user_authorized(): bool { + return get_container()->get( Authorizer::class )->can_auth(); +} + /** * Build a brand's authorization URL, with the uplink_callback base64 query variable. * @@ -82,7 +149,7 @@ function is_authorized( string $license, string $token, string $domain ): bool { */ function build_auth_url( string $slug, string $domain = '' ): string { try { - return Config::get_container()->get( Auth_Url_Builder::class ) + return get_container()->get( Auth_Url_Builder::class ) ->build( $slug, $domain ); } catch ( Throwable $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { @@ -92,3 +159,174 @@ function build_auth_url( string $slug, string $domain = '' ): string { return ''; } } + +/** + * Get a resource (plugin/service) from the collection. + * + * @param string $slug The resource slug to find. + * + * @throws \RuntimeException + * + * @return Resource|Plugin|Service|null + */ +function get_resource( string $slug ) { + return get_container()->get( Collection::class )->offsetGet( $slug ); +} + +/** + * Compares the Uplink configuration to the current site this function is called on, + * e.g. a sub-site to determine if the product supports multisite licenses. + * + * Not to be confused with Config::allows_network_licenses(). + * + * @param string|Resource|Plugin|Service $slug_or_resource The product/service slug or a Resource object. + * + * @throws \RuntimeException + * + * @return bool + */ +function allows_multisite_license( $slug_or_resource ): bool { + $resource = $slug_or_resource instanceof Resource ? $slug_or_resource : get_resource( $slug_or_resource ); + + if ( ! $resource ) { + return false; + } + + return get_container()->get( License_Manager::class )->allows_multisite_license( $resource ); +} + +/** + * A multisite aware license validation check. + * + * @param string $slug The plugin/service slug to validate against. + * @param string $license An optional license key, otherwise we'll fetch it automatically. + * + * @throws \RuntimeException + * + * @return Validation_Response|null + */ +function validate_license( string $slug, string $license = '' ): ?Validation_Response { + $resource = get_resource( $slug ); + + if ( ! $resource ) { + return null; + } + + $license = $license ?: get_license_key( $slug ); + $network = allows_multisite_license( $resource ); + + return $resource->validate_license( $license, $network ); +} + +/** + * A multisite license aware way to get a resource's license key automatically + * from the network or local site level. + * + * @param string $slug The plugin/service slug. + * + * @throws \RuntimeException + * + * @return string + */ +function get_license_key( string $slug ): string { + $resource = get_resource( $slug ); + + if ( ! $resource ) { + return ''; + } + + $network = allows_multisite_license( $resource ); + + return $resource->get_license_key( $network ? 'network' : 'local' ); +} + +/** + * A multisite license aware way to set a resource's license key automatically + * from the network or local site level. + * + * @param string $slug The plugin/service slug. + * @param string $license The license key to store. + * + * @throws \RuntimeException + * + * @return bool + */ +function set_license_key( string $slug, string $license ): bool { + $resource = get_resource( $slug ); + + if ( ! $resource ) { + return false; + } + + $network = allows_multisite_license( $resource ); + $result = $resource->set_license_key( $license, $network ? 'network' : 'local' ); + + // Force update the key status. + $resource->validate_license( $license, $network ); + + return $result; +} + +/** + * Get the current site's license domain without any hash suffix. + * + * @throws \RuntimeException + * + * @return string + */ +function get_original_domain(): string { + return get_container()->get( Data::class )->get_domain( true ); +} + +/** + * Get the current site's license domain, multisite friendly. + * + * @throws \RuntimeException + * @return string + */ +function get_license_domain(): string { + return get_container()->get( Data::class )->get_domain(); +} + +/** + * Get the disconnect token URL. + * + * @param string $slug The plugin/service slug. + * + * @throws \RuntimeException + * + * @return string + */ +function get_disconnect_url( string $slug ): string { + $resource = get_resource( $slug ); + + if ( ! $resource ) { + return ''; + } + + return get_container()->get( Disconnect_Controller::class )->get_url( $resource ); +} + +/** + * Get the License Field Object to render license key fields. + * + * @throws \RuntimeException + * + * @return License_Field + */ +function get_license_field(): License_Field { + return get_container()->get( License_Field::class ); +} + +/** + * Retrieve an Origin's auth url, if it exists. + * + * @param string $slug The product/service slug. + * + * @throws \RuntimeException + * + * @return string + */ +function get_auth_url( string $slug ): string { + return get_container()->get( Auth_Url::class )->get( $slug ); +} diff --git a/src/admin-views/fields/settings.php b/src/admin-views/fields/settings.php index 5e379ad4..0faa006c 100644 --- a/src/admin-views/fields/settings.php +++ b/src/admin-views/fields/settings.php @@ -16,6 +16,7 @@ $field = Config::get_container()->get( License_Field::class ); $group = $field->get_group_name( sanitize_title( $plugin->get_slug() ) ); $action_postfix = Config::get_hook_prefix_underscored(); +$form_action = is_multisite() && is_network_admin() ? network_admin_url( 'settings.php' ) : 'options.php'; ?> @@ -25,7 +26,7 @@