From ab45b8c3bacbeb1c4d36f89492737237c8905095 Mon Sep 17 00:00:00 2001 From: Joel James Date: Tue, 2 Jun 2026 21:04:20 +0530 Subject: [PATCH 1/6] Improve: Refactor to PSR-4 --- README.md | 154 ++++++++-- composer.json | 6 +- src/Api/ApiFactory.php | 137 +++++++++ src/Api/Client.php | 305 +++++++++++++++++++ src/Api/RequestSigner.php | 108 +++++++ src/Api/SignedClient.php | 93 ++++++ src/Contracts/ApiClientInterface.php | 79 +++++ src/Contracts/CacheInterface.php | 101 +++++++ src/Contracts/ServiceInterface.php | 64 ++++ src/Data/Activation.php | 288 ++++++++++++++++++ src/Data/ApiKeys.php | 106 +++++++ src/Data/Plugin.php | 214 ++++++++++++++ src/Exceptions/FreemiusException.php | 34 +++ src/Services/AbstractService.php | 76 +++++ src/Services/Addon.php | 181 +++++++++++ src/Services/License.php | 261 ++++++++++++++++ src/Services/Update.php | 386 ++++++++++++++++++++++++ src/Storage/ActivationRepository.php | 94 ++++++ src/Storage/TransientCache.php | 123 ++++++++ src/Support/SiteIdentity.php | 55 ++++ src/api/class-api.php | 428 --------------------------- src/data/class-plugin.php | 189 ------------ src/freemius.php | 150 ++++++++-- src/services/class-addon.php | 127 -------- src/services/class-license.php | 214 -------------- src/services/class-service.php | 194 ------------ src/services/class-update.php | 314 -------------------- 27 files changed, 2964 insertions(+), 1517 deletions(-) create mode 100644 src/Api/ApiFactory.php create mode 100644 src/Api/Client.php create mode 100644 src/Api/RequestSigner.php create mode 100644 src/Api/SignedClient.php create mode 100644 src/Contracts/ApiClientInterface.php create mode 100644 src/Contracts/CacheInterface.php create mode 100644 src/Contracts/ServiceInterface.php create mode 100644 src/Data/Activation.php create mode 100644 src/Data/ApiKeys.php create mode 100644 src/Data/Plugin.php create mode 100644 src/Exceptions/FreemiusException.php create mode 100644 src/Services/AbstractService.php create mode 100644 src/Services/Addon.php create mode 100644 src/Services/License.php create mode 100644 src/Services/Update.php create mode 100644 src/Storage/ActivationRepository.php create mode 100644 src/Storage/TransientCache.php create mode 100644 src/Support/SiteIdentity.php delete mode 100644 src/api/class-api.php delete mode 100644 src/data/class-plugin.php delete mode 100644 src/services/class-addon.php delete mode 100644 src/services/class-license.php delete mode 100644 src/services/class-service.php delete mode 100644 src/services/class-update.php diff --git a/README.md b/README.md index b3eaf89..d407f08 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,174 @@ # Freemius Plugin Licensing -This is a lite version of the main Freemius SDK, specifically developed for use in Duck Dev WordPress plugins. This -library focuses exclusively on managing plugin license activation, deactivation, and updates. It does not provide any -user interface, so your plugin will need to create its own UI and use this library to handle the logic. +A lite, UI-free Freemius SDK for Duck Dev WordPress plugins. The library handles license activation, deactivation, +update delivery, and addon listing by talking to the Freemius API directly. It deliberately ships no admin screens — +host plugins build their own UI and call into this library for the underlying logic. ## Requirements -* PHP version 7.4 or higher. +* PHP 7.4 or higher * WordPress 5.0+ +* Composer ## Installation -This library should be installed and included in your WordPress plugin using Composer. - ```console composer require duckdev/freemius-plugin-licensing ``` +The library autoloads under the `DuckDev\Freemius\` namespace via PSR-4. + +## Architecture + +The library is organised as a small dependency-injection container wired up by the entry class +`DuckDev\Freemius\Freemius`. The folder layout mirrors the namespace: + +``` +src/ +├── Freemius.php # Container + entry point +├── Api/ +│ ├── Client.php # Unsigned HTTP client over wp_remote_request +│ ├── SignedClient.php # Adds FS / FSP signed auth headers +│ ├── RequestSigner.php # Pure header-signing logic +│ └── ApiFactory.php # Builds fresh clients per call +├── Contracts/ +│ ├── ServiceInterface.php +│ ├── ApiClientInterface.php +│ └── CacheInterface.php +├── Data/ +│ ├── Plugin.php # Immutable host plugin info +│ ├── Activation.php # Value object around the persisted activation +│ └── ApiKeys.php # Public / secret key pair +├── Storage/ +│ ├── ActivationRepository.php # Reads / writes the activation option +│ └── TransientCache.php # Per-plugin transient cache + throttle +├── Services/ +│ ├── AbstractService.php +│ ├── License.php # activate() / deactivate() +│ ├── Update.php # WP update hooks +│ └── Addon.php # Addon listing +├── Support/ +│ └── SiteIdentity.php # Deterministic site UID +└── Exceptions/ + └── FreemiusException.php +``` + +Each service receives its collaborators by constructor injection, so they can be unit-tested without WordPress in the +loop. Hook registration happens inside `boot()` (called once by the container), so simply instantiating the container +has no side effects. + ## Usage ### Initialization -Initialize the Freemius SDK by calling the static `DuckDev\Freemius\Freemius::get_instance()` method with your plugin's -details. +Initialise the container by calling `Freemius::get_instance()` with your Freemius product ID and an arguments array: ```php // Assuming Composer's autoload.php has been included. -$freemius = DuckDev\Freemius\Freemius::get_instance( +$freemius = \DuckDev\Freemius\Freemius::get_instance( 12345, // Your Freemius product ID. array( - 'slug' => 'loggedin', // Your plugin's unique Freemius slug. - 'main_file' => LOGGEDIN_FILE, // The path to your plugin's main file. - 'public_key' => 'pk_XXXXXXXXXXXXXXXXX', // Your plugin's public key. + 'slug' => 'loggedin', // Your plugin's unique Freemius slug. + 'main_file' => LOGGEDIN_FILE, // Absolute path to the plugin's main file. + 'public_key' => 'pk_XXXXXXXXXXXXXXXXX', // Plugin public key. + 'is_premium' => true, // Whether this build is the premium edition. + 'has_addons' => false, // Whether the product has addons to list. ) ); ``` -### License Activation +The supported arguments are: + +| Key | Type | Description | +|---------------|----------|---------------------------------------------------------------------------------------------------| +| `slug` | `string` | Unique Freemius slug for the plugin. | +| `main_file` | `string` | Absolute path to the plugin's main file (used for `plugin_basename()` and `get_plugin_data()`). | +| `public_key` | `string` | Freemius public key (`pk_…`). Required for plugin-scoped endpoints (addons, info). | +| `is_premium` | `bool` | Whether this build is the premium edition. Update hooks only register when `true`. Default false. | +| `has_addons` | `bool` | Whether the product has addons to list. Default false. | -To activate a license, call the `activate()` method on the `license()` object with the user's license key. +The first call to `get_instance()` creates the container and registers WordPress hooks. Subsequent calls for the same +plugin ID return the existing instance (the second argument is ignored after the first call). + +### License Activation ```php -$freemius->license()->activate( 'XXXX-XXXX-XXXX' ); +$result = $freemius->license()->activate( 'XXXX-XXXX-XXXX' ); + +if ( is_wp_error( $result ) ) { + // $result->get_error_message() — show to the user. +} ``` +`activate()` returns `true` / `false` from the option update on success, or a `WP_Error` when the key is empty, the +plugin is not the premium build, the API call fails, or the response does not include an install ID. + ### License Deactivation -To deactivate a license, simply call the `deactivate()` method. +```php +$result = $freemius->license()->deactivate(); +``` + +`deactivate()` refuses to proceed when the stored UID does not match the current site — that means the activation was +moved to another host, and we let the new host appear unlicensed rather than silently freeing the original seat. + +### Reading the Current Activation ```php -$freemius->license()->deactivate(); +$activation = $freemius->license()->get_activation(); + +if ( $activation->is_active() ) { + // $activation->license_key(), $activation->install_id(), … +} ``` +`get_activation()` always returns an `Activation` value object — use `is_empty()` to detect the no-activation case. + ### Updates -The library will automatically handle plugin updates as long as a valid license is active. No additional code is -required to check for and apply updates. +Update hooks are registered automatically during `boot()` for premium builds. There is no manual integration needed — +WordPress will check for, display, and apply updates through its standard pipeline. + +To force a refresh from the host plugin's UI: + +```php +$freemius->update()->get_update_data( true ); +``` + +### Addons + +```php +$addons = $freemius->addon()->get_addons(); // Cached for 24h. +$addons = $freemius->addon()->get_addons( true ); // Force refresh. +``` + +Each entry is enriched with a `link` field (Freemius checkout URL) and an `is_premium` boolean. Use the +`duckdev_freemius_format_addon_data` filter to add or rewrite fields per addon. + +## Hooks + +### Actions + +| Hook | Arguments | When | +|---------------------------------------|--------------------------|---------------------------------------| +| `duckdev_freemius_license_activated` | `array $activation, bool $success` | After a successful activation. | +| `duckdev_freemius_license_deactivated`| `array $activation, bool $success` | After a successful deactivation. | + +### Filters + +| Hook | Arguments | Use | +|--------------------------------------------|-------------------------------------------------|------------------------------------------------------------------| +| `duckdev_freemius_api_request_args` | `array $args, string $method, string $url, array $data, array $headers` | Tweak the request arguments before they reach `wp_remote_request()`. | +| `duckdev_freemius_api_request_verify_ssl` | `bool $verify, Client $client` | Disable SSL verification (typically only in local dev). | +| `duckdev_freemius_format_addon_data` | `array $addon, Addon $service` | Rewrite or augment each addon entry before it is returned. | + +## Security Notes + +* The library does **not** verify nonces or capabilities. Host plugins MUST do that before forwarding form input to + `License::activate()` / `License::deactivate()`. +* The license key is stored in the `duckdev_freemius_activation_data` option (an autoload-safe option keyed by plugin + ID). It is blanked from storage on deactivation. + +## License + +GPL-2.0+ diff --git a/composer.json b/composer.json index 3030b35..ed383e9 100644 --- a/composer.json +++ b/composer.json @@ -29,8 +29,8 @@ } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "DuckDev\\Freemius\\": "src/" + } } } diff --git a/src/Api/ApiFactory.php b/src/Api/ApiFactory.php new file mode 100644 index 0000000..0303ed3 --- /dev/null +++ b/src/Api/ApiFactory.php @@ -0,0 +1,137 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Api + */ + +namespace DuckDev\Freemius\Api; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use DuckDev\Freemius\Contracts\ApiClientInterface; +use DuckDev\Freemius\Data\ApiKeys; +use DuckDev\Freemius\Data\Plugin; + +/** + * Class ApiFactory. + */ +class ApiFactory { + + /** + * Signer reused across every signed client this factory produces. + * + * @since 1.0.0 + * + * @var RequestSigner + */ + private RequestSigner $signer; + + /** + * Constructor. + * + * @since 1.0.0 + * + * @param RequestSigner|null $signer Signer to use. A default instance is + * constructed when null is supplied. + */ + public function __construct( ?RequestSigner $signer = null ) { + $this->signer = $signer ?? new RequestSigner(); + } + + /** + * Build an unauthenticated client. + * + * Used by {@see \DuckDev\Freemius\Services\License} for the + * activate.json / deactivate.json endpoints which accept a + * license key in the body and do not require signing. + * + * @since 1.0.0 + * + * @param string $id Entity ID. + * @param string $scope API scope. + * + * @return ApiClientInterface + */ + public function make_public( string $id, string $scope = 'plugin' ): ApiClientInterface { + return new Client( $id, $scope ); + } + + /** + * Build a signed client from an explicit key pair. + * + * @since 1.0.0 + * + * @param string $id Entity ID. + * @param ApiKeys $keys Key pair. + * @param string $scope API scope. + * + * @return ApiClientInterface + */ + public function make_signed( string $id, ApiKeys $keys, string $scope = 'user' ): ApiClientInterface { + return new SignedClient( $id, $keys, $this->signer, $scope ); + } + + /** + * Build a plugin-scoped client signed with the plugin's public key. + * + * Internally this uses the public key as both the public and the + * secret half of the pair, which causes {@see RequestSigner} to + * emit `FSP` (public-key-hash) authentication — the scheme + * Freemius expects when only the plugin public key is known to + * the host (info.json, addons.json). + * + * @since 1.0.0 + * + * @param Plugin $plugin Plugin instance. + * + * @return ApiClientInterface + */ + public function make_for_plugin( Plugin $plugin ): ApiClientInterface { + $public_key = $plugin->get_public_key(); + + return $this->make_signed( + (string) $plugin->get_id(), + new ApiKeys( $public_key, $public_key ), + 'plugin' + ); + } + + /** + * Build an install-scoped client signed with the credentials + * returned by the API at activation time. + * + * @since 1.0.0 + * + * @param string $install_id Install ID returned by the activation response. + * @param ApiKeys $keys Install key pair (from {@see \DuckDev\Freemius\Data\Activation::api_keys()}). + * + * @return ApiClientInterface + */ + public function make_for_install( string $install_id, ApiKeys $keys ): ApiClientInterface { + return $this->make_signed( $install_id, $keys, 'install' ); + } +} diff --git a/src/Api/Client.php b/src/Api/Client.php new file mode 100644 index 0000000..8e99ae8 --- /dev/null +++ b/src/Api/Client.php @@ -0,0 +1,305 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Api + */ + +namespace DuckDev\Freemius\Api; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use DuckDev\Freemius\Contracts\ApiClientInterface; +use WP_Error; + +/** + * Class Client. + */ +class Client implements ApiClientInterface { + + /** + * Base URL for the Freemius API. + * + * @since 1.0.0 + * + * @var string + */ + protected string $base_url = 'https://api.freemius.com'; + + /** + * Entity ID injected into the scoped URL path. + * + * @since 1.0.0 + * + * @var string + */ + protected string $id; + + /** + * API scope (user / install / plugin). + * + * Mapped to the URL segment `/{scope}s/{id}/`. + * + * @since 1.0.0 + * + * @var string + */ + protected string $scope; + + /** + * Constructor. + * + * @since 1.0.0 + * + * @param string $id Entity ID. + * @param string $scope API scope. Defaults to `plugin`. + */ + public function __construct( string $id, string $scope = 'plugin' ) { + $this->id = $id; + $this->scope = $scope; + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + */ + public function get( string $endpoint, array $params = array() ) { + return $this->prepare_request( 'GET', $endpoint, $params ); + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + */ + public function post( string $endpoint, array $data = array() ) { + return $this->prepare_request( 'POST', $endpoint, $data ); + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + */ + public function put( string $endpoint, array $data = array() ) { + return $this->prepare_request( 'PUT', $endpoint, $data ); + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + */ + public function delete( string $endpoint, array $data = array() ) { + return $this->prepare_request( 'DELETE', $endpoint, $data ); + } + + /** + * Normalise an HTTP response into either a decoded array or a WP_Error. + * + * Surfaces error envelopes at two levels: + * 1. The outer wp_remote_request response when it already carries + * an `error` array (rare). + * 2. The JSON-decoded body when it carries an `error.code` / + * `error.message` pair — the standard Freemius shape. + * + * @since 1.0.0 + * + * @param array|\WP_Error $response Raw response from `wp_remote_request()`. + * + * @return array|\WP_Error + */ + public function prepare_response( $response ) { + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( isset( $response['error']['code'], $response['error']['message'] ) ) { + return new WP_Error( $response['error']['code'], $response['error']['message'] ); + } + + $decoded = json_decode( $response['body'] ?? '', true ); + + if ( isset( $decoded['error']['code'], $decoded['error']['message'] ) ) { + return new WP_Error( $decoded['error']['code'], $decoded['error']['message'] ); + } + + return $decoded; + } + + /** + * Prepare URL/headers and dispatch a request. + * + * @since 1.0.0 + * + * @param string $method HTTP method. + * @param string $endpoint Caller-supplied endpoint (relative to the entity scope). + * @param array $data Data sent in the query string (GET) or body (everything else). + * + * @return array|\WP_Error + */ + protected function prepare_request( string $method, string $endpoint, array $data = array() ) { + $endpoint = $this->prepare_endpoint( $endpoint ); + $url = $this->prepare_url( $method, $endpoint, $data ); + $headers = $this->build_headers( $method, $endpoint, $data ); + + return $this->perform_http_request( $method, $url, $data, $headers ); + } + + /** + * Build the request headers. + * + * Returns an empty array for the unsigned client. Subclasses override + * to add `Authorization`, `Date`, and `Content-MD5` headers. + * + * @since 1.0.0 + * + * @param string $method HTTP method. + * @param string $endpoint Prepared endpoint path (after `prepare_endpoint()`). + * @param array $data Body / query data. + * + * @return array + */ + protected function build_headers( string $method, string $endpoint, array $data ): array { + unset( $method, $endpoint, $data ); + + return array(); + } + + /** + * Compose the full request URL. + * + * For GET requests, body data is folded into the query string; + * other verbs leave the URL alone and serialise the body to JSON + * inside {@see perform_http_request()}. + * + * @since 1.0.0 + * + * @param string $method HTTP method. + * @param string $endpoint Prepared endpoint path. + * @param array $data Data to attach. + * + * @return string Fully-qualified URL. + */ + protected function prepare_url( string $method, string $endpoint, array $data = array() ): string { + $url = $this->base_url . $endpoint; + + if ( 'GET' === $method && ! empty( $data ) ) { + $url = add_query_arg( $data, $url ); + } + + return $url; + } + + /** + * Build the scoped endpoint path: `/v1/{scope}s/{id}/{endpoint}`. + * + * @since 1.0.0 + * + * @param string $endpoint Caller-supplied endpoint (with or without leading slash). + * + * @return string + */ + protected function prepare_endpoint( string $endpoint ): string { + $url_parts = array( + '', + 'v1', + $this->scope . 's', + $this->id, + ltrim( $endpoint, '/' ), + ); + + return implode( '/', $url_parts ); + } + + /** + * Dispatch the HTTP request via `wp_remote_request()`. + * + * @since 1.0.0 + * + * @param string $method HTTP method (already uppercased upstream is fine). + * @param string $url Full URL. + * @param array $data Body data (ignored for GET). + * @param array $headers Request headers, to which `Content-type: application/json` is added for mutating verbs. + * + * @return array|\WP_Error + */ + protected function perform_http_request( string $method, string $url, array $data, array $headers ) { + $method = strtoupper( $method ); + $body = null; + + if ( in_array( $method, array( 'POST', 'PUT', 'DELETE' ), true ) ) { + $headers['Content-type'] = 'application/json'; + $body = wp_json_encode( $data ); + } + + $args = array( + 'method' => $method, + 'connect_timeout' => 10, + 'timeout' => 60, + 'sslverify' => $this->verify_ssl(), + 'follow_redirects' => true, + 'redirection' => 5, + 'user-agent' => 'WordPress/' . get_bloginfo( 'version' ) . '; ' . home_url( '/' ), + 'blocking' => true, + 'headers' => $headers, + 'body' => $body, + ); + + /** + * Filter the arguments used in the API request. + * + * @since 1.0.0 + * + * @param array $args Request arguments. + * @param string $method HTTP method. + * @param string $url Request URL. + * @param array $data Request body. + * @param array $headers Request headers. + */ + $args = apply_filters( 'duckdev_freemius_api_request_args', $args, $method, $url, $data, $headers ); + + $response = wp_remote_request( $url, $args ); + + return $this->prepare_response( $response ); + } + + /** + * Whether to verify the SSL certificate of `api.freemius.com`. + * + * Filterable via `duckdev_freemius_api_request_verify_ssl` for the + * rare case where a local dev environment needs to disable it. + * + * @since 1.0.0 + * + * @return bool + */ + protected function verify_ssl(): bool { + /** + * Filter to change if the API SSL should be verified. + * + * @since 1.0.0 + * + * @param bool $verify Should verify? + * @param Client $client Current client instance. + */ + return (bool) apply_filters( 'duckdev_freemius_api_request_verify_ssl', true, $this ); + } +} diff --git a/src/Api/RequestSigner.php b/src/Api/RequestSigner.php new file mode 100644 index 0000000..2732896 --- /dev/null +++ b/src/Api/RequestSigner.php @@ -0,0 +1,108 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Api + */ + +namespace DuckDev\Freemius\Api; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use DuckDev\Freemius\Data\ApiKeys; + +/** + * Class RequestSigner. + */ +class RequestSigner { + + /** + * Build the signed header set for an outgoing request. + * + * @since 1.0.0 + * + * @param string $resource_url Prepared endpoint path (the value `prepare_endpoint()` produced). + * @param string $method HTTP method (any case). + * @param array $post_params Body parameters. Only used when method is not GET. + * @param string $id Entity ID embedded in the Authorization header. + * @param ApiKeys $keys Key pair used to sign. + * + * @return array Header map ready to be merged into the request's `headers` arg. + */ + public function sign( string $resource_url, string $method, array $post_params, string $id, ApiKeys $keys ): array { + $method = strtoupper( $method ); + $eol = "\n"; + $content_md5 = ''; + $content_type = ''; + $date = gmdate( 'r' ); + + // Only mutating verbs carry a JSON body. + if ( in_array( $method, array( 'POST', 'PUT' ), true ) ) { + $content_type = 'application/json'; + } + + // MD5 of the body is part of the signature when a body is sent. + if ( ! empty( $post_params ) && 'GET' !== $method ) { + $content_md5 = md5( wp_json_encode( $post_params ) ); + } + + $string_to_sign = implode( + $eol, + array( + $method, + $content_md5, + $content_type, + $date, + $resource_url, + ) + ); + + $public_key = $keys->get_public_key(); + $secret_key = $keys->get_secret_key(); + + // Identical keys signal FSP (public-key-hash) auth; otherwise standard FS. + $auth_type = ( $secret_key !== $public_key ) ? 'FS' : 'FSP'; + + // URL-safe base64 of the HMAC-SHA256, without padding. + $hash = hash_hmac( 'sha256', $string_to_sign, $secret_key ); + $hash = base64_encode( $hash ); + $hash = strtr( $hash, '+/', '-_' ); + $hash = str_replace( '=', '', $hash ); + + $headers = array( + 'Date' => $date, + 'Authorization' => "$auth_type $id:$public_key:$hash", + ); + + // Include Content-MD5 only when a body is sent — must match what + // went into $string_to_sign or the server will reject the request. + if ( '' !== $content_md5 ) { + $headers['Content-MD5'] = $content_md5; + } + + return $headers; + } +} diff --git a/src/Api/SignedClient.php b/src/Api/SignedClient.php new file mode 100644 index 0000000..177bada --- /dev/null +++ b/src/Api/SignedClient.php @@ -0,0 +1,93 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Api + */ + +namespace DuckDev\Freemius\Api; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use DuckDev\Freemius\Data\ApiKeys; + +/** + * Class SignedClient. + */ +class SignedClient extends Client { + + /** + * Key pair used to sign each request. + * + * @since 1.0.0 + * + * @var ApiKeys + */ + private ApiKeys $keys; + + /** + * Collaborator that produces the auth headers. + * + * @since 1.0.0 + * + * @var RequestSigner + */ + private RequestSigner $signer; + + /** + * Constructor. + * + * @since 1.0.0 + * + * @param string $id Entity ID. + * @param ApiKeys $keys Key pair to sign with. + * @param RequestSigner $signer Signer used to build the header set. + * @param string $scope API scope. Defaults to `plugin`. + */ + public function __construct( string $id, ApiKeys $keys, RequestSigner $signer, string $scope = 'plugin' ) { + parent::__construct( $id, $scope ); + + $this->keys = $keys; + $this->signer = $signer; + } + + /** + * Build signed auth headers for the outgoing request. + * + * Returns an empty header set (falling back to an unsigned request) + * when the configured key pair is not signable — for example when + * an activation's install data did not include both keys. The + * caller will receive whatever the Freemius API returns in that + * case (typically an authentication error). + * + * @since 1.0.0 + * + * @param string $method HTTP method. + * @param string $endpoint Prepared endpoint path. + * @param array $data Request data. + * + * @return array + */ + protected function build_headers( string $method, string $endpoint, array $data ): array { + if ( ! $this->keys->is_signable() ) { + return array(); + } + + return $this->signer->sign( $endpoint, $method, $data, $this->id, $this->keys ); + } +} diff --git a/src/Contracts/ApiClientInterface.php b/src/Contracts/ApiClientInterface.php new file mode 100644 index 0000000..2ab73a9 --- /dev/null +++ b/src/Contracts/ApiClientInterface.php @@ -0,0 +1,79 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Contracts + */ + +namespace DuckDev\Freemius\Contracts; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +/** + * Interface ApiClientInterface. + */ +interface ApiClientInterface { + + /** + * Perform a GET request against a scoped endpoint. + * + * @since 1.0.0 + * + * @param string $endpoint Endpoint relative to the entity scope (e.g. "info.json"). + * @param array $params Query string parameters. + * + * @return array|\WP_Error Decoded response or WP_Error on failure. + */ + public function get( string $endpoint, array $params = array() ); + + /** + * Perform a POST request against a scoped endpoint. + * + * @since 1.0.0 + * + * @param string $endpoint Endpoint relative to the entity scope. + * @param array $data Body data — JSON-encoded by the client. + * + * @return array|\WP_Error Decoded response or WP_Error on failure. + */ + public function post( string $endpoint, array $data = array() ); + + /** + * Perform a PUT request against a scoped endpoint. + * + * @since 1.0.0 + * + * @param string $endpoint Endpoint relative to the entity scope. + * @param array $data Body data — JSON-encoded by the client. + * + * @return array|\WP_Error Decoded response or WP_Error on failure. + */ + public function put( string $endpoint, array $data = array() ); + + /** + * Perform a DELETE request against a scoped endpoint. + * + * @since 1.0.0 + * + * @param string $endpoint Endpoint relative to the entity scope. + * @param array $data Body data — JSON-encoded by the client. + * + * @return array|\WP_Error Decoded response or WP_Error on failure. + */ + public function delete( string $endpoint, array $data = array() ); +} diff --git a/src/Contracts/CacheInterface.php b/src/Contracts/CacheInterface.php new file mode 100644 index 0000000..ec4c895 --- /dev/null +++ b/src/Contracts/CacheInterface.php @@ -0,0 +1,101 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Contracts + */ + +namespace DuckDev\Freemius\Contracts; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +/** + * Interface CacheInterface. + */ +interface CacheInterface { + + /** + * Retrieve a cached value. + * + * Implementations MUST return boolean false when the key is missing + * or expired so callers can rely on a strict `false === $value` + * comparison to detect a miss. + * + * @since 1.0.0 + * + * @param string $key Cache key (unprefixed — the implementation prefixes it). + * + * @return mixed + */ + public function get( string $key ); + + /** + * Persist a value in the cache. + * + * @since 1.0.0 + * + * @param string $key Cache key (unprefixed). + * @param mixed $value Value to cache. Must be serializable. + * @param int $expiration Expiration in seconds. 0 means "no expiration". + * + * @return bool True on success. + */ + public function set( string $key, $value, int $expiration = 0 ): bool; + + /** + * Delete a cached value. + * + * @since 1.0.0 + * + * @param string $key Cache key (unprefixed). + * + * @return bool True when the entry existed and was removed. + */ + public function delete( string $key ): bool; + + /** + * Whether a throttle window keyed by $key is currently open. + * + * Used to short-circuit outbound requests when one has already been + * made recently. Always returns false until the matching + * {@see mark_requested()} call has been made. + * + * @since 1.0.0 + * + * @param string $key Throttle key. + * + * @return bool + */ + public function is_throttled( string $key ): bool; + + /** + * Open a throttle window for the given key. + * + * After this call, {@see is_throttled()} returns true for the same + * key until the implementation's throttle window expires. + * + * @since 1.0.0 + * + * @param string $key Throttle key. + * + * @return bool True on success. + */ + public function mark_requested( string $key ): bool; +} diff --git a/src/Contracts/ServiceInterface.php b/src/Contracts/ServiceInterface.php new file mode 100644 index 0000000..cd9669b --- /dev/null +++ b/src/Contracts/ServiceInterface.php @@ -0,0 +1,64 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Contracts + */ + +namespace DuckDev\Freemius\Contracts; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use DuckDev\Freemius\Data\Plugin; + +/** + * Interface ServiceInterface. + * + * Marks a class as a Freemius service that the container can manage. + */ +interface ServiceInterface { + + /** + * Get the plugin data instance the service belongs to. + * + * Useful when a caller has a service reference and needs to inspect + * the host plugin (slug, ID, premium flag, etc.) without going back + * through the container. + * + * @since 1.0.0 + * + * @return Plugin + */ + public function get_plugin(): Plugin; + + /** + * Register WordPress hooks for the service. + * + * Called once by {@see \DuckDev\Freemius\Freemius::boot()} after + * every service has been constructed. Implementations may early + * return when there is nothing to register (for example when the + * host plugin is the free build). + * + * @since 1.0.0 + * + * @return void + */ + public function boot(): void; +} diff --git a/src/Data/Activation.php b/src/Data/Activation.php new file mode 100644 index 0000000..a33823b --- /dev/null +++ b/src/Data/Activation.php @@ -0,0 +1,288 @@ + 12345, + * 'date' => 'Y-m-d H:i:s', + * 'status' => 'activated' | 'deactivated', + * 'activation_params' => [ + * 'license_key' => 'XXXX-XXXX-XXXX', + * 'uid' => '', + * 'url' => 'https://example.com', + * 'version' => '1.0.0', + * 'install_id' => 12345, // present after first activation + * ], + * 'install_data' => [ // raw API response + * 'install_public_key' => 'pk_…', + * 'install_secret_key' => 'sk_…', + * … + * ], + * ] + * + * @link https://duckdev.com/ + * @license http://www.gnu.org/licenses/ GNU General Public License + * @author Joel James + * @since 1.0.0 + * @package Freemius + * @subpackage Data + */ + +namespace DuckDev\Freemius\Data; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +/** + * Class Activation. + */ +class Activation { + + /** + * Active status string persisted in the activation array. + * + * @since 1.0.0 + */ + const STATUS_ACTIVATED = 'activated'; + + /** + * Inactive status string persisted in the activation array. + * + * @since 1.0.0 + */ + const STATUS_DEACTIVATED = 'deactivated'; + + /** + * Underlying activation array. + * + * @since 1.0.0 + * + * @var array + */ + private array $data; + + /** + * Constructor. + * + * Accepts an empty array to represent "no activation yet" — see + * {@see is_empty()} for the inverse check. + * + * @since 1.0.0 + * + * @param array $data Raw activation array. + */ + public function __construct( array $data = array() ) { + $this->data = $data; + } + + /** + * Named constructor for clarity at call sites. + * + * @since 1.0.0 + * + * @param array $data Raw activation array. + * + * @return self + */ + public static function from_array( array $data ): self { + return new self( $data ); + } + + /** + * Return the underlying array. Used by the repository for persistence. + * + * @since 1.0.0 + * + * @return array + */ + public function to_array(): array { + return $this->data; + } + + /** + * Install ID returned by the Freemius API at activation time. + * + * @since 1.0.0 + * + * @return string Empty string when no install has been created. + */ + public function install_id(): string { + return (string) ( $this->data['install_id'] ?? '' ); + } + + /** + * License key as currently stored. + * + * Note: the key is blanked from storage on deactivation, see + * {@see with_scrubbed_license()}. + * + * @since 1.0.0 + * + * @return string + */ + public function license_key(): string { + return (string) ( $this->data['activation_params']['license_key'] ?? '' ); + } + + /** + * Deterministic UID of the site this activation belongs to. + * + * @since 1.0.0 + * + * @return string + */ + public function uid(): string { + return (string) ( $this->data['activation_params']['uid'] ?? '' ); + } + + /** + * Activation status, one of {@see STATUS_ACTIVATED} or {@see STATUS_DEACTIVATED}. + * + * @since 1.0.0 + * + * @return string + */ + public function status(): string { + return (string) ( $this->data['status'] ?? '' ); + } + + /** + * Activation timestamp (formatted as `Y-m-d H:i:s`). + * + * @since 1.0.0 + * + * @return string + */ + public function date(): string { + return (string) ( $this->data['date'] ?? '' ); + } + + /** + * Raw activation parameters originally sent to the API. + * + * @since 1.0.0 + * + * @return array + */ + public function activation_params(): array { + return $this->data['activation_params'] ?? array(); + } + + /** + * Raw install data as returned by the Freemius API. + * + * @since 1.0.0 + * + * @return array + */ + public function install_data(): array { + return $this->data['install_data'] ?? array(); + } + + /** + * Build an {@see ApiKeys} pair from the persisted install data. + * + * Returns an empty pair if the install data is incomplete — call + * {@see ApiKeys::is_signable()} on the result to check. + * + * @since 1.0.0 + * + * @return ApiKeys + */ + public function api_keys(): ApiKeys { + $install = $this->install_data(); + + return new ApiKeys( + (string) ( $install['install_public_key'] ?? '' ), + (string) ( $install['install_secret_key'] ?? '' ) + ); + } + + /** + * Whether the activation has the identifying fields needed to + * deactivate or to authenticate install-scoped API calls. + * + * @since 1.0.0 + * + * @return bool + */ + public function has_required_keys(): bool { + return '' !== $this->install_id() + && '' !== $this->license_key() + && '' !== $this->uid(); + } + + /** + * Whether the activation represents a currently-active license. + * + * Requires the identifying fields to be present and the status to + * be {@see STATUS_ACTIVATED}. + * + * @since 1.0.0 + * + * @return bool + */ + public function is_active(): bool { + return $this->has_required_keys() + && self::STATUS_ACTIVATED === $this->status(); + } + + /** + * Whether nothing has been stored for the plugin yet. + * + * @since 1.0.0 + * + * @return bool + */ + public function is_empty(): bool { + return empty( $this->data ); + } + + /** + * Return a new instance with the supplied top-level keys overridden. + * + * @since 1.0.0 + * + * @param array $changes Associative array of changes. + * + * @return self + */ + public function with( array $changes ): self { + return new self( array_merge( $this->data, $changes ) ); + } + + /** + * Return a new instance with the license key blanked. + * + * Called on deactivation so the key is not left visible in the + * options table while still preserving the rest of the + * activation context for diagnostics. + * + * @since 1.0.0 + * + * @return self + */ + public function with_scrubbed_license(): self { + $data = $this->data; + if ( ! empty( $data['activation_params']['license_key'] ) ) { + $data['activation_params']['license_key'] = ''; + } + + return new self( $data ); + } +} diff --git a/src/Data/ApiKeys.php b/src/Data/ApiKeys.php new file mode 100644 index 0000000..af9bd6d --- /dev/null +++ b/src/Data/ApiKeys.php @@ -0,0 +1,106 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Data + */ + +namespace DuckDev\Freemius\Data; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +/** + * Class ApiKeys. + */ +class ApiKeys { + + /** + * Public key. + * + * @since 1.0.0 + * + * @var string + */ + private string $public_key; + + /** + * Secret key. Defaults to the public key when omitted (FSP mode). + * + * @since 1.0.0 + * + * @var string + */ + private string $secret_key; + + /** + * Constructor. + * + * Passing an empty string (or omitting the parameter) for the + * secret key copies the public key into the secret slot, which is + * how the signer recognises FSP mode. + * + * @since 1.0.0 + * + * @param string $public_key Public key. + * @param string $secret_key Secret key. Optional. + */ + public function __construct( string $public_key, string $secret_key = '' ) { + $this->public_key = $public_key; + $this->secret_key = '' === $secret_key ? $public_key : $secret_key; + } + + /** + * Get the public key. + * + * @since 1.0.0 + * + * @return string + */ + public function get_public_key(): string { + return $this->public_key; + } + + /** + * Get the secret key. + * + * @since 1.0.0 + * + * @return string + */ + public function get_secret_key(): string { + return $this->secret_key; + } + + /** + * Whether the pair has enough information to sign a request. + * + * @since 1.0.0 + * + * @return bool + */ + public function is_signable(): bool { + return '' !== $this->public_key && '' !== $this->secret_key; + } +} diff --git a/src/Data/Plugin.php b/src/Data/Plugin.php new file mode 100644 index 0000000..4c8f232 --- /dev/null +++ b/src/Data/Plugin.php @@ -0,0 +1,214 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Data + */ + +namespace DuckDev\Freemius\Data; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +/** + * Class Plugin. + */ +class Plugin { + + /** + * Freemius product ID. + * + * @since 1.0.0 + * + * @var int + */ + private int $id; + + /** + * Freemius product slug. + * + * @since 1.0.0 + * + * @var string + */ + private string $slug; + + /** + * Absolute path to the plugin's main PHP file. + * + * Required by WordPress to look up plugin headers and compute + * `plugin_basename()`. + * + * @since 1.0.0 + * + * @var string + */ + private string $main_file; + + /** + * Freemius public key (`pk_…`). + * + * @since 1.0.0 + * + * @var string + */ + private string $public_key; + + /** + * Whether this is the premium build of the plugin. + * + * Used by services to decide whether to register update hooks or + * accept license activation requests. + * + * @since 1.0.0 + * + * @var bool + */ + private bool $is_premium; + + /** + * Whether the plugin has addons to list. + * + * @since 1.0.0 + * + * @var bool + */ + private bool $has_addons; + + /** + * Cached output of `get_plugin_data()` for the main file. + * + * Populated lazily on first call to {@see get_data()}. + * + * @since 1.0.0 + * + * @var array + */ + private array $data = array(); + + /** + * Constructor. + * + * @since 1.0.0 + * + * @param int $id Freemius product ID. + * @param array $args Plugin args (see class docblock for the schema). + */ + public function __construct( int $id, array $args ) { + $this->id = $id; + $this->slug = (string) ( $args['slug'] ?? '' ); + $this->main_file = (string) ( $args['main_file'] ?? '' ); + $this->public_key = (string) ( $args['public_key'] ?? '' ); + $this->is_premium = (bool) ( $args['is_premium'] ?? false ); + $this->has_addons = (bool) ( $args['has_addons'] ?? false ); + } + + /** + * Get the Freemius product ID. + * + * @since 1.0.0 + * + * @return int + */ + public function get_id(): int { + return $this->id; + } + + /** + * Get the Freemius product slug. + * + * @since 1.0.0 + * + * @return string + */ + public function get_slug(): string { + return $this->slug; + } + + /** + * Get the absolute path to the plugin's main file. + * + * @since 1.0.0 + * + * @return string + */ + public function get_main_file(): string { + return $this->main_file; + } + + /** + * Get the Freemius public key. + * + * @since 1.0.0 + * + * @return string + */ + public function get_public_key(): string { + return $this->public_key; + } + + /** + * Whether the host plugin is the premium build. + * + * @since 1.0.0 + * + * @return bool + */ + public function is_premium(): bool { + return $this->is_premium; + } + + /** + * Whether the host plugin has addons to list. + * + * @since 1.0.0 + * + * @return bool + */ + public function has_addons(): bool { + return $this->has_addons; + } + + /** + * Read the plugin headers using WordPress's `get_plugin_data()`. + * + * The result is cached on the instance after the first read. The + * helper from `wp-admin/includes/plugin.php` is loaded on demand + * because it isn't available on front-end requests by default. + * + * @since 1.0.0 + * + * @return array Plugin headers as returned by `get_plugin_data()`. + */ + public function get_data(): array { + if ( empty( $this->data ) ) { + if ( ! function_exists( '\get_plugin_data' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $this->data = \get_plugin_data( $this->main_file ); + } + + return $this->data; + } +} diff --git a/src/Exceptions/FreemiusException.php b/src/Exceptions/FreemiusException.php new file mode 100644 index 0000000..178248a --- /dev/null +++ b/src/Exceptions/FreemiusException.php @@ -0,0 +1,34 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Exceptions + */ + +namespace DuckDev\Freemius\Exceptions; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use Exception; + +/** + * Class FreemiusException. + */ +class FreemiusException extends Exception { +} diff --git a/src/Services/AbstractService.php b/src/Services/AbstractService.php new file mode 100644 index 0000000..eaa169b --- /dev/null +++ b/src/Services/AbstractService.php @@ -0,0 +1,76 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Services + */ + +namespace DuckDev\Freemius\Services; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use DuckDev\Freemius\Contracts\ServiceInterface; +use DuckDev\Freemius\Data\Plugin; + +/** + * Class AbstractService. + */ +abstract class AbstractService implements ServiceInterface { + + /** + * Plugin the service is operating on behalf of. + * + * @since 1.0.0 + * + * @var Plugin + */ + protected Plugin $plugin; + + /** + * Constructor. + * + * @since 1.0.0 + * + * @param Plugin $plugin Plugin instance. + */ + public function __construct( Plugin $plugin ) { + $this->plugin = $plugin; + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + */ + public function get_plugin(): Plugin { + return $this->plugin; + } + + /** + * {@inheritDoc} + * + * Default implementation registers no hooks. Override in subclasses + * to attach to WordPress filters/actions during boot. + * + * @since 1.0.0 + */ + public function boot(): void { + // No-op by default. + } +} diff --git a/src/Services/Addon.php b/src/Services/Addon.php new file mode 100644 index 0000000..68e3b1f --- /dev/null +++ b/src/Services/Addon.php @@ -0,0 +1,181 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Services + */ + +namespace DuckDev\Freemius\Services; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use DuckDev\Freemius\Api\ApiFactory; +use DuckDev\Freemius\Contracts\CacheInterface; +use DuckDev\Freemius\Data\Plugin; +use WP_Error; + +/** + * Class Addon. + */ +class Addon extends AbstractService { + + /** + * Prefix for the Freemius hosted checkout page. + * + * The addon ID is appended at format time. Extracted as a + * constant so it is easy to find when Freemius changes the + * checkout domain or path. + * + * @since 1.0.0 + */ + const CHECKOUT_URL = 'https://checkout.freemius.com/plugin/'; + + /** + * Cache used to memoise the addon list and throttle API calls. + * + * @since 1.0.0 + * + * @var CacheInterface + */ + private CacheInterface $cache; + + /** + * Factory used to obtain API clients. + * + * @since 1.0.0 + * + * @var ApiFactory + */ + private ApiFactory $api_factory; + + /** + * Constructor. + * + * @since 1.0.0 + * + * @param Plugin $plugin Plugin instance. + * @param CacheInterface $cache Cache. + * @param ApiFactory $api_factory API factory. + */ + public function __construct( Plugin $plugin, CacheInterface $cache, ApiFactory $api_factory ) { + parent::__construct( $plugin ); + + $this->cache = $cache; + $this->api_factory = $api_factory; + } + + /** + * Get the list of addons for the host plugin. + * + * Returns an empty array (rather than a WP_Error) when: + * - the host plugin does not declare `has_addons`, + * - the API request fails, or + * - the throttle window is still open. + * + * Use `$force = true` to bypass the cache when the user explicitly + * asks for a refresh from the host plugin's UI. + * + * @since 1.0.0 + * + * @param bool $force Whether to bypass the cache. + * + * @return array List of addons (associative arrays). + */ + public function get_addons( bool $force = false ): array { + if ( ! $this->plugin->has_addons() ) { + return array(); + } + + if ( ! $force ) { + $cached = $this->cache->get( 'addons' ); + if ( false !== $cached && is_array( $cached ) ) { + return $cached; + } + } + + $addons = $this->get_remote_addons(); + if ( is_wp_error( $addons ) ) { + return array(); + } + + $addons = array_map( array( $this, 'format_addon_data' ), $addons ); + $this->cache->set( 'addons', $addons, DAY_IN_SECONDS ); + + return $addons; + } + + /** + * Fetch the raw addon list from the Freemius API. + * + * Uses the plugin-scoped signed client (FSP encoding) since this + * endpoint is reachable with only the public key. + * + * @since 1.0.0 + * + * @return array|\WP_Error List of addons or WP_Error on failure / throttle. + */ + protected function get_remote_addons() { + if ( $this->cache->is_throttled( 'addons_check' ) ) { + return new WP_Error( 'too_many_requests', __( 'Too many requests. Slow down.', 'duckdev-freemius' ) ); + } + + $api = $this->api_factory->make_for_plugin( $this->plugin ); + $response = $api->get( + 'addons.json', + array( + 'enriched' => true, + 'show_pending' => false, + ) + ); + + $this->cache->mark_requested( 'addons_check' ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + return $response['plugins'] ?? array(); + } + + /** + * Enrich a single addon entry with computed fields. + * + * Adds: + * - `link` — Freemius checkout URL for the addon. + * - `is_premium` — boolean mirror of `is_pricing_visible`. + * + * @since 1.0.0 + * + * @param array $addon Raw addon entry from the API. + * + * @return array + */ + protected function format_addon_data( array $addon ): array { + $addon['link'] = self::CHECKOUT_URL . ( $addon['id'] ?? '' ); + $addon['is_premium'] = $addon['is_pricing_visible'] ?? false; + + /** + * Filter the formatted addon data before it is returned / cached. + * + * @since 1.0.0 + * + * @param array $addon Addon data. + * @param Addon $self Current service instance. + */ + return apply_filters( 'duckdev_freemius_format_addon_data', $addon, $this ); + } +} diff --git a/src/Services/License.php b/src/Services/License.php new file mode 100644 index 0000000..baa9fce --- /dev/null +++ b/src/Services/License.php @@ -0,0 +1,261 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Services + */ + +namespace DuckDev\Freemius\Services; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use DateTime; +use DuckDev\Freemius\Api\ApiFactory; +use DuckDev\Freemius\Data\Activation; +use DuckDev\Freemius\Data\Plugin; +use DuckDev\Freemius\Storage\ActivationRepository; +use DuckDev\Freemius\Support\SiteIdentity; +use WP_Error; + +/** + * Class License. + */ +class License extends AbstractService { + + /** + * Repository used to read and persist the activation. + * + * @since 1.0.0 + * + * @var ActivationRepository + */ + private ActivationRepository $activations; + + /** + * Factory used to obtain API clients. + * + * @since 1.0.0 + * + * @var ApiFactory + */ + private ApiFactory $api_factory; + + /** + * Helper used to compute the current site's UID. + * + * @since 1.0.0 + * + * @var SiteIdentity + */ + private SiteIdentity $site; + + /** + * Constructor. + * + * @since 1.0.0 + * + * @param Plugin $plugin Plugin instance. + * @param ActivationRepository $activations Activation repository. + * @param ApiFactory $api_factory API factory. + * @param SiteIdentity $site Site identity helper. + */ + public function __construct( + Plugin $plugin, + ActivationRepository $activations, + ApiFactory $api_factory, + SiteIdentity $site + ) { + parent::__construct( $plugin ); + + $this->activations = $activations; + $this->api_factory = $api_factory; + $this->site = $site; + } + + /** + * Get the current activation for the host plugin. + * + * Always returns an {@see Activation} — empty when nothing is + * stored yet. + * + * @since 1.0.0 + * + * @return Activation + */ + public function get_activation(): Activation { + return $this->activations->get( $this->plugin->get_id() ); + } + + /** + * Activate a license key for the current site. + * + * The site UID and current plugin version are sent along with the + * key. On a successful response the install ID is persisted so + * subsequent activate calls reuse the same install. + * + * @since 1.0.0 + * + * @param string $key License key supplied by the user. + * + * @return bool|\WP_Error True/false from the underlying option update + * on success; WP_Error on validation or API failure. + */ + public function activate( string $key ) { + if ( '' === $key ) { + return new WP_Error( 'empty_activation_key', __( 'License key is empty.', 'duckdev-freemius' ) ); + } + + // Only premium plugins require a license. + if ( ! $this->plugin->is_premium() ) { + return new WP_Error( 'not_premium', __( 'Not a premium plugin.', 'duckdev-freemius' ) ); + } + + $plugin_data = $this->plugin->get_data(); + + $args = array( + 'license_key' => $key, + 'uid' => $this->site->get_uid(), + 'url' => get_site_url(), + 'version' => $plugin_data['Version'] ?? '', + ); + + // Reuse the install ID if we already have one — prevents the + // API from creating a duplicate install entry on re-activation. + $activation = $this->get_activation(); + if ( '' !== $activation->install_id() ) { + $args['install_id'] = $activation->install_id(); + } else { + $activation = new Activation(); + } + + $api = $this->api_factory->make_public( (string) $this->plugin->get_id(), 'plugin' ); + $response = $api->post( 'activate.json', $args ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( isset( $response['install_id'] ) ) { + $activation = $activation->with( + array( + 'activation_params' => $args, + 'install_id' => $response['install_id'], + 'date' => ( new DateTime() )->format( 'Y-m-d H:i:s' ), + 'status' => Activation::STATUS_ACTIVATED, + 'install_data' => $response, + ) + ); + + $success = $this->activations->save( $this->plugin->get_id(), $activation ); + + /** + * Fires after a plugin license is activated. + * + * @since 1.0.0 + * + * @param array $activation Activation data array. + * @param bool $success Whether the option update succeeded. + */ + do_action( 'duckdev_freemius_license_activated', $activation->to_array(), $success ); + + return $success; + } + + return new WP_Error( 'unknown_error', __( 'Unknown error.', 'duckdev-freemius' ) ); + } + + /** + * Deactivate the current license. + * + * Refuses to proceed when the stored activation does not include + * the identifying fields, or when the site UID has changed — that + * happens when an activated database is moved to a different URL, + * in which case the new site is treated as not licensed rather + * than silently freeing the seat at the original host. + * + * @since 1.0.0 + * + * @return bool|\WP_Error + */ + public function deactivate() { + $activation = $this->get_activation(); + + if ( ! $this->can_deactivate( $activation ) ) { + return new WP_Error( 'invalid_activation_data', __( 'Invalid activation data.', 'duckdev-freemius' ) ); + } + + $args = array( + 'uid' => $activation->uid(), + 'install_id' => $activation->install_id(), + 'license_key' => $activation->license_key(), + 'url' => get_site_url(), + ); + + $api = $this->api_factory->make_public( (string) $this->plugin->get_id(), 'plugin' ); + $response = $api->post( 'deactivate.json', $args ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( isset( $response['id'] ) ) { + $activation = $activation + ->with( array( 'status' => Activation::STATUS_DEACTIVATED ) ) + ->with_scrubbed_license(); + + $success = $this->activations->save( $this->plugin->get_id(), $activation ); + + /** + * Fires after a plugin license is deactivated. + * + * @since 1.0.0 + * + * @param array $activation Activation data array. + * @param bool $success Whether the option update succeeded. + */ + do_action( 'duckdev_freemius_license_deactivated', $activation->to_array(), $success ); + + return $success; + } + + return new WP_Error( 'unknown_error', __( 'Unknown error.', 'duckdev-freemius' ) ); + } + + /** + * Whether the given activation may be deactivated from this site. + * + * Requires: + * 1. The activation is not empty. + * 2. The identifying fields (install ID, UID, license key) are present. + * 3. The stored UID matches the current site UID. + * + * @since 1.0.0 + * + * @param Activation $activation Activation to check. + * + * @return bool + */ + protected function can_deactivate( Activation $activation ): bool { + if ( $activation->is_empty() || ! $activation->has_required_keys() ) { + return false; + } + + return $activation->uid() === $this->site->get_uid(); + } +} diff --git a/src/Services/Update.php b/src/Services/Update.php new file mode 100644 index 0000000..0dc5db5 --- /dev/null +++ b/src/Services/Update.php @@ -0,0 +1,386 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Services + */ + +namespace DuckDev\Freemius\Services; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use DuckDev\Freemius\Api\ApiFactory; +use DuckDev\Freemius\Contracts\CacheInterface; +use DuckDev\Freemius\Data\Plugin; +use DuckDev\Freemius\Storage\ActivationRepository; +use stdClass; +use WP_Error; + +/** + * Class Update. + */ +class Update extends AbstractService { + + /** + * Repository used to read the current activation. + * + * @since 1.0.0 + * + * @var ActivationRepository + */ + private ActivationRepository $activations; + + /** + * Cache used to throttle and memoise API calls. + * + * @since 1.0.0 + * + * @var CacheInterface + */ + private CacheInterface $cache; + + /** + * Factory used to obtain API clients. + * + * @since 1.0.0 + * + * @var ApiFactory + */ + private ApiFactory $api_factory; + + /** + * Constructor. + * + * @since 1.0.0 + * + * @param Plugin $plugin Plugin instance. + * @param ActivationRepository $activations Activation repository. + * @param CacheInterface $cache Cache. + * @param ApiFactory $api_factory API factory. + */ + public function __construct( + Plugin $plugin, + ActivationRepository $activations, + CacheInterface $cache, + ApiFactory $api_factory + ) { + parent::__construct( $plugin ); + + $this->activations = $activations; + $this->cache = $cache; + $this->api_factory = $api_factory; + } + + /** + * Register update-related hooks. + * + * Only attaches when the host plugin is the premium build — the + * free build does not consume the Freemius update API. + * + * @since 1.0.0 + * + * @return void + */ + public function boot(): void { + if ( ! $this->plugin->is_premium() ) { + return; + } + + add_filter( 'plugins_api', array( $this, 'plugins_api_filter' ), 10, 3 ); + add_filter( 'site_transient_update_plugins', array( $this, 'plugin_updates_transient' ) ); + add_action( 'upgrader_process_complete', array( $this, 'purge_plugin' ), 10, 2 ); + } + + /** + * Get the latest update payload for the plugin. + * + * Cached for a day. Pass `$force = true` (or visit any page with + * `?force-check=1`, the WordPress convention) to bypass the cache + * and re-fetch. + * + * @since 1.0.0 + * + * @param bool $force Whether to bypass the cache. + * + * @return array|\WP_Error Decoded update payload, or an empty array + * when the API returned an error. + */ + public function get_update_data( bool $force = false ) { + // Honour `?force-check=1` on the updates screen. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['force-check'] ) ) { + $force = true; + } + + if ( $force ) { + $this->cache->delete( 'update_data' ); + } + + $update_data = $this->cache->get( 'update_data' ); + if ( false === $update_data ) { + $update_data = $this->get_remote_latest(); + if ( is_wp_error( $update_data ) ) { + $update_data = array(); + } + + $this->cache->set( 'update_data', $update_data, DAY_IN_SECONDS ); + } + + return $update_data; + } + + /** + * Get the product info payload (banners, description). + * + * Cached for a day on success. + * + * @since 1.0.0 + * + * @return array|\WP_Error + */ + public function get_plugin_info() { + $info = $this->cache->get( 'plugin_info' ); + + if ( false === $info || empty( $info ) ) { + $info = $this->get_remote_plugin_info(); + if ( ! is_wp_error( $info ) ) { + $this->cache->set( 'plugin_info', $info, DAY_IN_SECONDS ); + } + } + + return $info; + } + + /** + * Purge the update cache once this plugin has been upgraded. + * + * Bound to `upgrader_process_complete`. The check ensures we only + * purge when the upgrader actually touched THIS plugin. + * + * @since 1.0.0 + * + * @param mixed $upgrader Plugin upgrader. Unused. + * @param array $hook_extra Hook context from WordPress. + * + * @return void + */ + public function purge_plugin( $upgrader, array $hook_extra ): void { + unset( $upgrader ); + + if ( ! isset( $hook_extra['action'], $hook_extra['type'] ) ) { + return; + } + + if ( 'update' !== $hook_extra['action'] || 'plugin' !== $hook_extra['type'] ) { + return; + } + + $plugin_base_name = plugin_basename( $this->plugin->get_main_file() ); + $updated_plugins = $hook_extra['plugins'] ?? array(); + + if ( in_array( $plugin_base_name, $updated_plugins, true ) ) { + $this->cache->delete( 'update_data' ); + } + } + + /** + * Hook callback for `plugins_api`. + * + * Produces the "View details" payload shown in the modal on the + * Plugins screen. Returns the original `$data` argument untouched + * when the request is not for this plugin. + * + * @since 1.0.0 + * + * @param false|object|array $data Existing payload from upstream filters. + * @param string $action The current `plugins_api` action. + * @param object|null $args Arguments from the API call. + * + * @return object|array|false + */ + public function plugins_api_filter( $data, string $action = '', ?object $args = null ) { + // Only respond to the plugin information action with a slug. + if ( 'plugin_information' !== $action || ! isset( $args->slug ) ) { + return $data; + } + + // Bail out when it isn't our plugin. + if ( $this->plugin->get_slug() !== $args->slug ) { + return $data; + } + + // Only do this for activated plugins. + if ( ! $this->is_activated() ) { + return $data; + } + + $plugin_data = $this->plugin->get_data(); + $update_data = $this->get_update_data(); + + if ( empty( $update_data ) || is_wp_error( $update_data ) ) { + return $data; + } + + $plugin_info = $this->get_plugin_info(); + if ( is_wp_error( $plugin_info ) || empty( $plugin_info ) ) { + $plugin_info = array(); + } + + $data = $args; + $data->name = $plugin_data['Name'] ?? ''; + $data->author = $plugin_data['Author'] ?? ''; + $data->version = $update_data['version'] ?? ''; + $data->last_updated = ! empty( $update_data['updated'] ) + ? $update_data['updated'] + : ( $update_data['created'] ?? '' ); + $data->requires = $update_data['requires_platform_version'] ?? ''; + $data->requires_php = $update_data['requires_programming_language_version'] ?? ''; + $data->tested = $update_data['tested_up_to_version'] ?? ''; + $data->download_link = $update_data['url'] ?? ''; + $data->banners = array( + 'high' => $plugin_info['banner_url'] ?? '', + 'low' => $plugin_info['card_banner_url'] ?? '', + ); + $data->sections = wp_parse_args( + $update_data['readme']['sections'] ?? array(), + array( + 'description' => $plugin_info['description'] ?? 'Upgrade ' . ( $plugin_data['Name'] ?? '' ) . ' to latest.', + ) + ); + + return $data; + } + + /** + * Hook callback for `site_transient_update_plugins`. + * + * Injects our plugin's update info into the standard WP transient + * so the core Plugins screen offers the upgrade. + * + * @since 1.0.0 + * + * @param mixed $transient Existing transient value. + * + * @return mixed Mutated transient. + */ + public function plugin_updates_transient( $transient ) { + if ( empty( $transient->checked ) ) { + return $transient; + } + + if ( ! $this->is_activated() ) { + return $transient; + } + + $plugin_data = $this->plugin->get_data(); + $update_data = $this->get_update_data(); + + if ( + ! empty( $update_data ) + && ! is_wp_error( $update_data ) + && isset( + $update_data['version'], + $update_data['requires_platform_version'], + $update_data['requires_programming_language_version'], + $update_data['url'] + ) + && version_compare( $plugin_data['Version'] ?? '0', $update_data['version'], '<' ) + && version_compare( $update_data['requires_platform_version'], get_bloginfo( 'version' ), '<=' ) + && version_compare( $update_data['requires_programming_language_version'], PHP_VERSION, '<' ) + ) { + $res = new stdClass(); + $res->slug = $this->plugin->get_slug(); + $res->plugin = plugin_basename( $this->plugin->get_main_file() ); + $res->new_version = $update_data['version']; + $res->tested = $update_data['requires_platform_version']; + $res->package = $update_data['url']; + + $transient->response[ $res->plugin ] = $res; + } + + return $transient; + } + + /** + * Fetch the latest version payload from the Freemius API. + * + * Throttled to one request per cache throttle window (5 minutes + * by default). Requires an active license — returns a WP_Error + * otherwise so the caller can cache an empty result and avoid + * re-hitting the API on every page load. + * + * @since 1.0.0 + * + * @return array|\WP_Error + */ + protected function get_remote_latest() { + if ( $this->cache->is_throttled( 'update_check' ) ) { + return new WP_Error( 'too_many_requests', __( 'Too many requests. Slow down.', 'duckdev-freemius' ) ); + } + + $activation = $this->activations->get( $this->plugin->get_id() ); + if ( ! $activation->is_active() ) { + return new WP_Error( 'license_not_active', __( 'No valid license is active.', 'duckdev-freemius' ) ); + } + + $plugin_data = $this->plugin->get_data(); + + $api = $this->api_factory->make_for_install( $activation->install_id(), $activation->api_keys() ); + $updates = $api->get( + 'updates/latest.json', + array( + 'readme' => true, + 'newer_than' => $plugin_data['Version'] ?? '', + ) + ); + + $this->cache->mark_requested( 'update_check' ); + + return $updates; + } + + /** + * Fetch the product info payload from the Freemius API. + * + * @since 1.0.0 + * + * @return array|\WP_Error + */ + protected function get_remote_plugin_info() { + $api = $this->api_factory->make_for_plugin( $this->plugin ); + + return $api->get( 'info.json' ); + } + + /** + * Whether the host plugin currently has an active license. + * + * @since 1.0.0 + * + * @return bool + */ + protected function is_activated(): bool { + return $this->activations->get( $this->plugin->get_id() )->is_active(); + } +} diff --git a/src/Storage/ActivationRepository.php b/src/Storage/ActivationRepository.php new file mode 100644 index 0000000..6eed28d --- /dev/null +++ b/src/Storage/ActivationRepository.php @@ -0,0 +1,94 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Storage + */ + +namespace DuckDev\Freemius\Storage; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use DuckDev\Freemius\Data\Activation; + +/** + * Class ActivationRepository. + */ +class ActivationRepository { + + /** + * Option key that stores activations for all Duck Dev plugins. + * + * @since 1.0.0 + */ + const OPTION_KEY = 'duckdev_freemius_activation_data'; + + /** + * Get the activation for a plugin. + * + * Always returns an {@see Activation} — callers should use + * {@see Activation::is_empty()} to detect the no-activation case + * rather than null-checking the result. + * + * @since 1.0.0 + * + * @param int $plugin_id Freemius plugin ID. + * + * @return Activation + */ + public function get( int $plugin_id ): Activation { + $all = get_option( self::OPTION_KEY, array() ); + + return new Activation( $all[ $plugin_id ] ?? array() ); + } + + /** + * Persist an activation for a plugin. + * + * @since 1.0.0 + * + * @param int $plugin_id Freemius plugin ID. + * @param Activation $activation Activation to persist. + * + * @return bool True when the underlying `update_option()` succeeded. + */ + public function save( int $plugin_id, Activation $activation ): bool { + $all = get_option( self::OPTION_KEY, array() ); + $all[ $plugin_id ] = $activation->to_array(); + + return update_option( self::OPTION_KEY, $all ); + } + + /** + * Remove the activation for a plugin entirely. + * + * @since 1.0.0 + * + * @param int $plugin_id Freemius plugin ID. + * + * @return bool + */ + public function clear( int $plugin_id ): bool { + $all = get_option( self::OPTION_KEY, array() ); + unset( $all[ $plugin_id ] ); + + return update_option( self::OPTION_KEY, $all ); + } +} diff --git a/src/Storage/TransientCache.php b/src/Storage/TransientCache.php new file mode 100644 index 0000000..0912f06 --- /dev/null +++ b/src/Storage/TransientCache.php @@ -0,0 +1,123 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Storage + */ + +namespace DuckDev\Freemius\Storage; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +use DuckDev\Freemius\Contracts\CacheInterface; +use DuckDev\Freemius\Data\Plugin; + +/** + * Class TransientCache. + */ +class TransientCache implements CacheInterface { + + /** + * Plugin instance the cache is scoped to. + * + * @since 1.0.0 + * + * @var Plugin + */ + private Plugin $plugin; + + /** + * Throttle window length in seconds. + * + * @since 1.0.0 + * + * @var int + */ + private int $throttle_window; + + /** + * Constructor. + * + * @since 1.0.0 + * + * @param Plugin $plugin Plugin instance the cache is scoped to. + * @param int $throttle_window Throttle window in seconds. Defaults to + * 5 minutes when 0 or negative. + */ + public function __construct( Plugin $plugin, int $throttle_window = 0 ) { + $this->plugin = $plugin; + $this->throttle_window = $throttle_window > 0 ? $throttle_window : 5 * MINUTE_IN_SECONDS; + } + + /** + * Build a plugin-scoped transient key from a caller-supplied key. + * + * @since 1.0.0 + * + * @param string $key Caller key (un-prefixed). + * + * @return string Fully prefixed transient key. + */ + private function build_key( string $key ): string { + return 'duckdev_freemius_' . $this->plugin->get_id() . '_' . $key; + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + */ + public function get( string $key ) { + return get_transient( $this->build_key( $key ) ); + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + */ + public function set( string $key, $value, int $expiration = 0 ): bool { + return (bool) set_transient( $this->build_key( $key ), $value, $expiration ); + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + */ + public function delete( string $key ): bool { + return (bool) delete_transient( $this->build_key( $key ) ); + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + */ + public function is_throttled( string $key ): bool { + return false !== $this->get( $key ); + } + + /** + * {@inheritDoc} + * + * @since 1.0.0 + */ + public function mark_requested( string $key ): bool { + return $this->set( $key, time(), $this->throttle_window ); + } +} diff --git a/src/Support/SiteIdentity.php b/src/Support/SiteIdentity.php new file mode 100644 index 0000000..35b80f7 --- /dev/null +++ b/src/Support/SiteIdentity.php @@ -0,0 +1,55 @@ + + * @since 1.0.0 + * @package Freemius + * @subpackage Support + */ + +namespace DuckDev\Freemius\Support; + +// If this file is called directly, abort. +defined( 'WPINC' ) || die; + +/** + * Class SiteIdentity. + */ +class SiteIdentity { + + /** + * Compute a stable UID for the current site. + * + * The UID is the md5 of `host-blog_id[-path]`, so multisite + * subsites get distinct UIDs and subdirectory installs do not + * collide with the root site. + * + * @since 1.0.0 + * + * @return string 32-character hexadecimal UID. + */ + public function get_uid(): string { + $blog_id = get_current_blog_id(); + $site_url = get_site_url( $blog_id ); + $site_url_parts = wp_parse_url( $site_url ); + + $data = array( $site_url_parts['host'] ?? '', $blog_id ); + if ( isset( $site_url_parts['path'] ) ) { + $data[] = $site_url_parts['path']; + } + + return md5( implode( '-', $data ) ); + } +} diff --git a/src/api/class-api.php b/src/api/class-api.php deleted file mode 100644 index 4e12ca0..0000000 --- a/src/api/class-api.php +++ /dev/null @@ -1,428 +0,0 @@ - - * @since 1.0.0 - * @package Freemius - * @subpackage API - */ - -namespace DuckDev\Freemius\Api; - -// If this file is called directly, abort. -defined( 'WPINC' ) || die; - -use WP_Error; - -/** - * Class Api. - */ -class Api { - - /** - * The base URL of the API. - * - * @since 1.0.0 - * - * @var string - */ - protected string $base_url = 'https://api.freemius.com'; - - /** - * Entity ID. - * - * @since 1.0.0 - * - * @var string - */ - protected string $id; - - /** - * API scope for request. - * - * @since 1.0.0 - * - * @var string $scope user|install|plugin - */ - protected string $scope = 'plugin'; - - /** - * Public key for authentication. - * - * @since 1.0.0 - * - * @var string - */ - protected string $public_key = ''; - - /** - * Secret key for authentication. - * - * @since 1.0.0 - * - * @var string - */ - protected string $secret_key = ''; - - /** - * Api constructor. - * - * @since 1.0.0 - * - * @param string $scope Type of scope (e.g., plugin, user, install). - * @param string $id Entity ID. - */ - protected function __construct( string $id, string $scope = 'plugin' ) { - $this->id = $id; - $this->scope = $scope; - } - - /** - * Get the singleton instance of the public API. - * - * @since 1.0.0 - * - * @param string $id Entity ID. - * @param string $scope Scope for API. - * - * @return Api - */ - public static function get_instance( string $id, string $scope = 'plugin' ): Api { - static $instances = array(); - - // Create new instance only if doesn't exist. - if ( ! isset( $instances["$scope.$id"] ) ) { - $instances["$scope.$id"] = new self( $id, $scope ); - } - - return $instances["$scope.$id"]; - } - - /** - * Get the singleton instance of the authenticated API. - * - * @since 1.0.0 - * - * @param string $id Entity ID. - * @param string $public_key Public key. - * @param string $secret_key Secret key. - * @param string $scope Scope for API. - * - * @return Api - */ - public static function get_auth_instance( string $id, string $public_key, string $secret_key, string $scope = 'user' ): Api { - $instance = self::get_instance( $id, $scope ); - $instance->public_key = $public_key; - $instance->secret_key = $secret_key; - - return $instance; - } - - /** - * Perform a GET request. - * - * @since 1.0.0 - * - * @param string $endpoint Endpoint. - * @param array $params Request params. - * - * @return array|WP_Error - */ - public function get( string $endpoint, array $params = array() ) { - return $this->prepare_request( 'GET', $endpoint, $params ); - } - - /** - * Perform a POST request. - * - * @since 1.0.0 - * - * @param string $endpoint Endpoint. - * @param array $data Request data. - * - * @return array|WP_Error - */ - public function post( string $endpoint, array $data = array() ) { - return $this->prepare_request( 'POST', $endpoint, $data ); - } - - /** - * Perform a PUT request. - * - * @since 1.0.0 - * - * @param string $endpoint Endpoint. - * @param array $data Request data. - * - * @return array|WP_Error - */ - public function put( string $endpoint, array $data = array() ) { - return $this->prepare_request( 'PUT', $endpoint, $data ); - } - - /** - * Perform a DELETE request. - * - * @since 1.0.0 - * - * @param string $endpoint Endpoint. - * @param array $data Request data. - * - * @return array|WP_Error - */ - public function delete( string $endpoint, array $data = array() ) { - return $this->prepare_request( 'DELETE', $endpoint, $data ); - } - - /** - * Validate an HTTP response. - * - * Double check responses for errors. - * - * @since 1.0.0 - * - * @param array|WP_Error $response Response data. - * - * @return mixed|WP_Error - */ - public function prepare_response( $response ) { - // If WP error, return as it is. - if ( is_wp_error( $response ) ) { - return $response; - } - - // Create new WP error instance and return. - if ( isset( $response['error']['code'], $response['error']['message'] ) ) { - return new WP_Error( $response['error']['code'], $response['error']['message'] ); - } - - // Decode json data. - $response = json_decode( $response['body'], true ); - - // Create new WP error instance and return. - if ( isset( $response['error']['code'], $response['error']['message'] ) ) { - return new WP_Error( $response['error']['code'], $response['error']['message'] ); - } - - return $response; - } - - /** - * Prepare an authenticated request. - * - * @since 1.0.0 - * - * @param string $method HTTP method. - * @param string $endpoint API endpoint. - * @param array $data Data to be sent in the request. - * - * @return array|WP_Error - */ - protected function prepare_request( string $method, string $endpoint, array $data = array() ) { - $endpoint = $this->prepare_endpoint( $endpoint ); - $url = $this->prepare_url( $method, $endpoint, $data ); - - $headers = array(); - - // Sign the request for auth if both pub and secret keys are set. - if ( $this->public_key && $this->secret_key ) { - $headers = $this->get_signed_headers( - $endpoint, - $method, - $data, - $this->id, - $this->public_key, - $this->secret_key - ); - } - - // Perform the request. - return $this->perform_http_request( $method, $url, $data, $headers ); - } - - /** - * Prepare API url for request. - * - * @since 1.0.0 - * - * @param string $method HTTP method. - * @param string $endpoint API endpoint. - * @param array $data Data to be sent in the request. - * - * @return string - */ - protected function prepare_url( string $method, string $endpoint, array $data = array() ): string { - $url = $this->base_url . $endpoint; - - // For GET request add query params. - if ( $method === 'GET' && ! empty( $data ) ) { - $url = add_query_arg( $data, $url ); - } - - return $url; - } - - /** - * Prepare API endpoint for request. - * - * @since 1.0.0 - * - * @param string $endpoint API endpoint. - * - * @return string - */ - protected function prepare_endpoint( string $endpoint ): string { - $url_parts = array( - '', - 'v1', - $this->scope . 's', - $this->id, - ltrim( $endpoint, '/' ), - ); - - return join( '/', $url_parts ); - } - - /** - * Execute the HTTP request. - * - * @since 1.0.0 - * - * @param string $method HTTP method. - * @param string $url Request URL. - * @param array $data Request body. - * @param array $headers Request headers. - * - * @return array|WP_Error - */ - protected function perform_http_request( string $method, string $url, array $data = array(), array $headers = array() ) { - $method = strtoupper( $method ); - $body = null; - // Make sure the content type is JSON. - if ( in_array( $method, array( 'POST', 'PUT', 'DELETE' ) ) ) { - $headers['Content-type'] = 'application/json'; - $body = json_encode( $data ); - } - - // Request args. - $args = array( - 'method' => $method, - 'connect_timeout' => 10, - 'timeout' => 60, - 'sslverify' => $this->verify_ssl(), - 'follow_redirects' => true, - 'redirection' => 5, - 'user-agent' => 'WordPress/' . get_bloginfo( 'version' ) . '; ' . home_url( '/' ), - 'blocking' => true, - 'headers' => $headers, - 'body' => $body, - ); - - /** - * Filters the arguments used in the API request. - * - * @since 1.0.0 - * - * @param array $args Request arguments. - * @param string $method HTTP method. - * @param string $url Request URL. - * @param array $data Request body. - * @param array $headers Request headers. - */ - $args = apply_filters( 'duckdev_freemius_api_request_args', $args, $method, $url, $data, $headers ); - - // Use WP HTTP to send request. - $response = wp_remote_request( $url, $args ); - - return $this->prepare_response( $response ); - } - - /** - * Returns if the SSL of the store should be verified. - * - * @since 1.0.0 - * - * @return bool - */ - protected function verify_ssl(): bool { - /** - * Filter to change if the SSL of the store should be verified. - * - * @since 1.0.0 - * - * @param bool $verify Should verify? - * @param self $this Current class instance. - */ - return (bool) apply_filters( 'duckdev_freemius_api_request_verify_ssl', true, $this ); - } - - /** - * Generate signature signed headers for the request. - * - * @since 1.0.0 - * - * @param string $resource_url Resource URL. - * @param string $method HTTP method. - * @param array $post_params Parameters for POST requests. - * @param string $id Entity ID. - * @param string $public_key Public key for authentication. - * @param string $secret_key Secret key for authentication. - * - * @return array - */ - private function get_signed_headers( - string $resource_url, - string $method, - array $post_params, - string $id, - string $public_key, - string $secret_key - ): array { - $method = strtoupper( $method ); - $eol = "\n"; - $content_md5 = ''; - $content_type = ''; - $date = date( 'r', time() ); - - // Make sure the content type is JSON. - if ( in_array( $method, array( 'POST', 'PUT' ) ) ) { - $content_type = 'application/json'; - } - - if ( ! empty( $post_params ) && 'GET' !== $method ) { - $content_md5 = md5( json_encode( $post_params ) ); - } - - $string_to_sign = implode( - $eol, - array( - $method, - $content_md5, - $content_type, - $date, - $resource_url, - ) - ); - - // If secret and public keys are identical, it means that the signature uses public key hash encoding. - $auth_type = ( $secret_key !== $public_key ) ? 'FS' : 'FSP'; - $hash = hash_hmac( 'sha256', $string_to_sign, $secret_key ); - $hash = base64_encode( $hash ); - $hash = strtr( $hash, '+/', '-_' ); - $hash = str_replace( '=', '', $hash ); - - $auth = array( - 'Date' => $date, - 'Authorization' => "$auth_type $id:$public_key:$hash", - ); - - if ( ! empty( $content_md5 ) ) { - $auth['Content-MD5'] = $content_md5; - } - - return $auth; - } -} diff --git a/src/data/class-plugin.php b/src/data/class-plugin.php deleted file mode 100644 index 0c3aee9..0000000 --- a/src/data/class-plugin.php +++ /dev/null @@ -1,189 +0,0 @@ - - * @since 1.0.0 - * @package Freemius - * @subpackage Data - */ - -namespace DuckDev\Freemius\Data; - -// If this file is called directly, abort. -defined( 'WPINC' ) || die; - -/** - * Class Plugin. - */ -class Plugin { - - /** - * Plugin ID. - * - * @since 1.0.0 - * - * @var int - */ - private int $id; - - /** - * Plugin slug. - * - * @since 1.0.0 - * - * @var string - */ - private string $slug; - - /** - * Plugin main file. - * - * @since 1.0.0 - * - * @var string - */ - private string $main_file; - - /** - * Plugin public key. - * - * @since 1.0.0 - * - * @var string - */ - private string $public_key = ''; - - /** - * Is a premium plugin. - * - * @since 1.0.0 - * - * @var bool - */ - private bool $is_premium; - - /** - * Has addons. - * - * @since 1.0.0 - * - * @var bool - */ - private bool $has_addons; - - /** - * Plugin data. - * - * @since 1.0.0 - * - * @var array - */ - private array $data = array(); - - /** - * Plugin class constructor. - * - * @since 1.0.0 - * - * @param int $id Plugin ID. - * @param array $args Arguments. - * - * @return void - */ - public function __construct( int $id, array $args ) { - $this->id = $id; - $this->slug = $args['slug'] ?? ''; - $this->is_premium = $args['is_premium'] ?? false; - $this->has_addons = $args['has_addons'] ?? false; - $this->main_file = $args['main_file'] ?? ''; - $this->public_key = $args['public_key'] ?? ''; - } - - /** - * Gets the plugin ID. - * - * @since 1.0.0 - * - * @return int - */ - public function get_id(): int { - return $this->id; - } - - /** - * Gets the plugin slug. - * - * @since 1.0.0 - * - * @return string - */ - public function get_slug(): string { - return $this->slug; - } - - /** - * Gets the plugin main file. - * - * @since 1.0.0 - * - * @return string - */ - public function get_main_file(): string { - return $this->main_file; - } - - /** - * Gets the plugin public key. - * - * @since 1.0.0 - * - * @return string - */ - public function get_public_key(): string { - return $this->public_key; - } - - /** - * Check if current plugin is premium. - * - * @since 1.0.0 - * - * @return bool - */ - public function is_premium(): bool { - return $this->is_premium; - } - - /** - * Check if current plugin has addons. - * - * @since 1.0.0 - * - * @return bool - */ - public function has_addons(): bool { - return $this->has_addons; - } - - /** - * Get current plugin data. - * - * @since 1.0.0 - * - * @return array - */ - public function get_data(): array { - if ( empty( $this->data ) ) { - if ( ! function_exists( '\get_plugin_data' ) ) { - require_once( ABSPATH . 'wp-admin/includes/plugin.php' ); - } - - $this->data = \get_plugin_data( $this->main_file ); - } - - return $this->data; - } -} diff --git a/src/freemius.php b/src/freemius.php index dc2c14f..6e5af89 100644 --- a/src/freemius.php +++ b/src/freemius.php @@ -1,9 +1,30 @@ * @since 1.0.0 + * @package Freemius */ namespace DuckDev\Freemius; @@ -20,10 +42,14 @@ // If this file is called directly, abort. defined( 'WPINC' ) || die; +use DuckDev\Freemius\Api\ApiFactory; use DuckDev\Freemius\Data\Plugin; use DuckDev\Freemius\Services\Addon; use DuckDev\Freemius\Services\License; use DuckDev\Freemius\Services\Update; +use DuckDev\Freemius\Storage\ActivationRepository; +use DuckDev\Freemius\Storage\TransientCache; +use DuckDev\Freemius\Support\SiteIdentity; /** * Class Freemius. @@ -31,7 +57,16 @@ class Freemius { /** - * License manager service instance. + * Plugin data instance. + * + * @since 1.0.0 + * + * @var Plugin + */ + private Plugin $plugin; + + /** + * License service. * * @since 1.0.0 * @@ -40,7 +75,7 @@ class Freemius { private License $license; /** - * Updates manager service instance. + * Update service. * * @since 1.0.0 * @@ -49,7 +84,7 @@ class Freemius { private Update $update; /** - * Addons manager service instance. + * Addon service. * * @since 1.0.0 * @@ -58,56 +93,102 @@ class Freemius { private Addon $addon; /** - * Class constructor. + * Whether {@see boot()} has run for this container. * * @since 1.0.0 * - * @param int $id Plugin ID. - * @param array $args Arguments. + * @var bool + */ + private bool $booted = false; + + /** + * Constructor. + * + * Marked protected so that consumers go through + * {@see get_instance()} — there is one container per plugin ID + * and reuse matters because boot() registers WordPress hooks. + * + * @since 1.0.0 + * + * @param int $id Freemius product ID. + * @param array $args Arguments forwarded to {@see Plugin}. */ protected function __construct( int $id, array $args ) { - // Create a plugin data instance. - $plugin = new Plugin( $id, $args ); - // Create services. - $this->license = new License( $plugin ); - $this->update = new Update( $plugin ); - $this->addon = new Addon( $plugin ); + $this->plugin = new Plugin( $id, $args ); + + // Compose the collaborator graph. + $activations = new ActivationRepository(); + $cache = new TransientCache( $this->plugin ); + $api_factory = new ApiFactory(); + $site = new SiteIdentity(); + + // Wire each service with the collaborators it actually needs. + $this->license = new License( $this->plugin, $activations, $api_factory, $site ); + $this->update = new Update( $this->plugin, $activations, $cache, $api_factory ); + $this->addon = new Addon( $this->plugin, $cache, $api_factory ); } /** - * Get the singleton instance of the class. + * Get (or create) the container for a given plugin ID. + * + * The first call MUST supply $args. Subsequent calls may omit + * them — the second argument is only consulted when a fresh + * instance is being constructed. * * @since 1.0.0 * - * @param int $id Plugin ID. - * @param array $args Arguments. + * @param int $id Freemius product ID. + * @param array $args Plugin arguments. Required on first call. * - * @return Freemius + * @return self */ - public static function get_instance( int $id, array $args ): Freemius { + public static function get_instance( int $id, array $args = array() ): self { static $instances = array(); - // Create new instance only if doesn't exist. - if ( ! isset( $instances[ $id ] ) || ! $instances[ $id ] instanceof Freemius ) { + if ( ! isset( $instances[ $id ] ) ) { $instances[ $id ] = new self( $id, $args ); + $instances[ $id ]->boot(); } return $instances[ $id ]; } /** - * Get the addons manager service instance. + * Boot every service — register WordPress hooks. + * + * Idempotent: a second call is a no-op. Normally invoked + * automatically by {@see get_instance()}; exposed publicly only + * so unusual host integrations can defer hook registration. * * @since 1.0.0 * - * @return Addon + * @return void */ - public function addon(): Addon { - return $this->addon; + public function boot(): void { + if ( $this->booted ) { + return; + } + + $this->license->boot(); + $this->update->boot(); + $this->addon->boot(); + + $this->booted = true; + } + + /** + * Get the plugin data instance. + * + * @since 1.0.0 + * + * @return Plugin + */ + public function plugin(): Plugin { + return $this->plugin; } /** - * Get the license manager service instance. + * Get the license service. * * @since 1.0.0 * @@ -118,7 +199,7 @@ public function license(): License { } /** - * Get the updates manager service instance. + * Get the update service. * * @since 1.0.0 * @@ -127,4 +208,15 @@ public function license(): License { public function update(): Update { return $this->update; } -} \ No newline at end of file + + /** + * Get the addon service. + * + * @since 1.0.0 + * + * @return Addon + */ + public function addon(): Addon { + return $this->addon; + } +} diff --git a/src/services/class-addon.php b/src/services/class-addon.php deleted file mode 100644 index 60cd447..0000000 --- a/src/services/class-addon.php +++ /dev/null @@ -1,127 +0,0 @@ - - * @since 1.0.0 - * @package Freemius - * @subpackage Services - */ - -namespace DuckDev\Freemius\Services; - -// If this file is called directly, abort. -defined( 'WPINC' ) || die; - -use DuckDev\Freemius\Api\Api; -use WP_Error; - -/** - * Class Addon - */ -class Addon extends Service { - - /** - * Get the list of addons. - * - * @since 1.0.0 - * - * @param bool $force Should force update cache. - * - * @return array - */ - public function get_addons( bool $force = false ): array { - // Only if current plugin has addons. - if ( ! $this->plugin->has_addons() ) { - return array(); - } - - // Get from cache first. - if ( ! $force ) { - $addons = $this->get_transient( 'addons' ); - // If found is cache, return it. - if ( false !== $addons ) { - return $addons; - } - } - - // Get from the API. - $addons = $this->get_remote_addons(); - - if ( ! is_wp_error( $addons ) ) { - // Format the data. - $addons = array_map( array( $this, 'format_addon_data' ), $addons ); - // Save to cache. - $this->set_transient( 'addons', $addons, DAY_IN_SECONDS ); - - return $addons; - } - - return array(); - } - - /** - * Get the list of addons from the API. - * - * @since 1.0.0 - * - * @return array|WP_Error - */ - protected function get_remote_addons() { - // Avoid multiple requests. - if ( $this->is_duplicate_request( 'addons_check' ) ) { - return new WP_Error( 'too_many_requests', __( 'Too many requests. Slow down.', 'duckdev-freemius' ) ); - } - - // Get authenticated API instance using public key. - $api = Api::get_auth_instance( - $this->plugin->get_id(), - $this->plugin->get_public_key(), - $this->plugin->get_public_key(), // Use public key again for secret key to use public key encryption. - 'plugin' - ); - - // Addon list from the API. - $response = $api->get( - 'addons.json', - array( - 'enriched' => true, // Get addon info. - 'show_pending' => false, // Get only released addons. - ) - ); - - // To prevent multiple requests for 5 mins. - $this->set_request_time( 'addons_check' ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - return $response['plugins'] ?? array(); - } - - /** - * Format the addon list to add additional data. - * - * @since 1.0.0 - * - * @return array|string|WP_Error - */ - protected function format_addon_data( $addon ): array { - // Add checkout link. - $addon['link'] = "https://checkout.freemius.com/plugin/{$addon['id']}"; - // Premium if pricing is visible. - $addon['is_premium'] = $addon['is_pricing_visible'] ?? false; - - /** - * Filter to modify addon data. - * - * @since 1.0.0 - * - * @param array $addon Addon data. - * @param Addon $this Current class instance. - */ - return apply_filters( 'duckdev_freemius_format_addon_data', $addon, $this ); - } -} diff --git a/src/services/class-license.php b/src/services/class-license.php deleted file mode 100644 index 278f9dd..0000000 --- a/src/services/class-license.php +++ /dev/null @@ -1,214 +0,0 @@ - - * @since 1.0.0 - * @package Freemius - * @subpackage Services - */ - -namespace DuckDev\Freemius\Services; - -// If this file is called directly, abort. -defined( 'WPINC' ) || die; - -use DateTime; -use DuckDev\Freemius\Api\Api; -use WP_Error; - -/** - * Class Licenses - */ -class License extends Service { - - /** - * Activates the license key for the site. - * - * This also saves a unique ID based on the site URL to the database. - * This will be used to cross check during deactivation on whether or - * not to continue deactivating. We do not store the site URL directly. - * - * @since 1.0.0 - * - * @param string $key License key. - * - * @return bool|WP_Error - */ - public function activate( string $key ) { - // We need a key!. - if ( empty( $key ) ) { - return new WP_Error( 'empty_activation_key', __( 'License key is empty.', 'duckdev-freemius' ) ); - } - - // Only a premium plugin requires a license. - if ( ! $this->plugin->is_premium() ) { - return new WP_Error( 'not_premium', __( 'Not a premium plugin.', 'duckdev-freemius' ) ); - } - - // Get current plugin data. - $plugin_data = $this->plugin->get_data(); - // Prepare activation args. - $args = array( - 'license_key' => $key, - 'uid' => $this->get_current_site_uid(), - 'url' => get_site_url(), - 'version' => $plugin_data['Version'], - ); - - // Add any existing install ID if it exists so we don't add a new entry. - $activation = $this->get_activation_data(); - if ( ! empty( $activation['install_id'] ) ) { - $args['install_id'] = $activation['install_id']; - } else { - $activation = array(); - } - - // Remotely activate the license. - $response = Api::get_instance( $this->plugin->get_id() )->post( 'activate.json', $args ); - // Request failed. - if ( is_wp_error( $response ) ) { - return $response; - } - - // Save the activation data after successful activation. - if ( isset( $response['install_id'] ) ) { - $activation['activation_params'] = $args; - $activation['install_id'] = $response['install_id']; - $activation['date'] = ( new DateTime() )->format( 'Y-m-d H:i:s' ); - $activation['status'] = self::ACTIVATED; - $activation['install_data'] = $response; - - // Update activation data. - $success = $this->set_activation_data( $activation ); - - /** - * Action hook to trigger after a plugin license is activated. - * - * @since 1.0.0 - * - * @param array $activation Activation data. - * @param bool $success Was the update successful. - */ - do_action( 'duckdev_freemius_license_activated', $activation, $success ); - - return $success; - } - - // Unknown error, but this shouldn't be happening. - return new WP_Error( 'unknown_error', __( 'Unknown error.', 'duckdev-freemius' ) ); - } - - /** - * Deactivates a license key. - * - * @since 1.0.0 - * - * @return bool|array|WP_Error - */ - public function deactivate() { - // Get activation data. - $activation = $this->get_activation_data(); - - // Check if we can deactivate. - if ( ! $this->can_deactivate() ) { - return new WP_Error( 'invalid_activation_data', __( 'Invalid activation data.', 'duckdev-freemius' ) ); - } - - // Prepare deactivation args. - $args = array( - 'uid' => $activation['activation_params']['uid'], - 'install_id' => $activation['install_id'], - 'license_key' => $activation['activation_params']['license_key'], - 'url' => get_site_url(), - ); - - // Remotely deactivate the license. - $response = Api::get_instance( $this->plugin->get_id() )->post( 'deactivate.json', $args ); - // Request failed. - if ( is_wp_error( $response ) ) { - return $response; - } - - // Save the data. - if ( isset( $response['id'] ) ) { - $activation['status'] = self::DEACTIVATED; - // Remove the license key so it's not visible in the database. - if ( ! empty( $activation['activation_params']['license_key'] ) ) { - $activation['activation_params']['license_key'] = ''; - } - - // Update deactivation data. - $success = $this->set_activation_data( $activation ); - - /** - * Action hook to trigger after a plugin license is deactivated. - * - * @since 1.0.0 - * - * @param array $activation Activation data. - * @param bool $success Was the update successful. - */ - do_action( 'duckdev_freemius_license_deactivated', $activation, $success ); - - return $success; - } - - // Unknown error, but this shouldn't be happening. - return new WP_Error( 'unknown_error', __( 'Unknown error.', 'duckdev-freemius' ) ); - } - - /** - * Check if current license can be deactivated. - * - * @since 1.0.0 - * - * @return bool - */ - protected function can_deactivate(): bool { - $activation = $this->get_activation_data(); - - // We need activation data. - if ( empty( $activation ) ) { - return false; - } - - // Check for uid, install id & license key. - if ( - empty( $activation['install_id'] ) || - empty( $activation['activation_params']['uid'] ) || - empty( $activation['activation_params']['license_key'] ) - ) { - return false; - } - - // Current site id should match. - if ( $activation['activation_params']['uid'] !== $this->get_current_site_uid() ) { - return false; - } - - return true; - } - - /** - * Get a unique UUID for current site. - * - * @since 1.0.0 - * - * @return string - */ - protected function get_current_site_uid(): string { - $blog_id = get_current_blog_id(); - $site_url = get_site_url( $blog_id ); - $site_url_parts = parse_url( $site_url ); - - $data = array( $site_url_parts['host'], $blog_id ); - if ( isset( $site_url_parts['path'] ) ) { - $data[] = $site_url_parts['path']; - } - - return md5( implode( '-', $data ) ); - } -} diff --git a/src/services/class-service.php b/src/services/class-service.php deleted file mode 100644 index 63d768a..0000000 --- a/src/services/class-service.php +++ /dev/null @@ -1,194 +0,0 @@ - - * @since 1.0.0 - * @package Freemius - * @subpackage Services - */ - -namespace DuckDev\Freemius\Services; - -// If this file is called directly, abort. -defined( 'WPINC' ) || die; - -use DuckDev\Freemius\Data\Plugin; - -/** - * Class Service - */ -class Service { - - /** - * Option key for activation data. - * - * @since 1.0.0 - */ - const OPTION_KEY = 'duckdev_freemius_activation_data'; - - /** - * Activated status. - * - * @since 1.0.0 - */ - const ACTIVATED = 'activated'; - - /** - * Deactivated status. - * - * @since 1.0.0 - */ - const DEACTIVATED = 'deactivated'; - - /** - * Plugin data instance. - * - * @since 1.0.0 - * - * @var Plugin $plugin - */ - protected Plugin $plugin; - - /** - * Class constructor. - * - * @since 1.0.0 - * - * @param Plugin $plugin Plugin data. - * - * @return void - */ - public function __construct( Plugin $plugin ) { - $this->plugin = $plugin; - } - - /** - * Get plugin activation data. - * - * @since 1.0.0 - * - * @return array - */ - public function get_activation_data(): array { - $activation = get_option( self::OPTION_KEY, array() ); - - return $activation[ $this->plugin->get_id() ] ?? array(); - } - - /** - * Set plugin activation data. - * - * @since 1.0.0 - * - * @param array $data Data. - * - * @return bool - */ - public function set_activation_data( array $data ): bool { - // Get existing activation data. - $activation = get_option( self::OPTION_KEY, array() ); - // Update the plugin's data. - $activation[ $this->plugin->get_id() ] = $data; - - return update_option( self::OPTION_KEY, $activation ); - } - - /** - * Get current plugin data object. - * - * @since 1.0.0 - * - * @return Plugin - */ - public function get_plugin(): Plugin { - return $this->plugin; - } - - /** - * Get a transient key. - * - * Always use this method to generate unique keys for each plugins. - * - * @since 1.0.0 - * - * @param string $key Transient key. - * - * @return string - */ - protected function get_transient_key( string $key ): string { - return "duckdev_freemius_{$this->plugin->get_id()}_$key"; - } - - /** - * Get a transient value. - * - * @since 1.0.0 - * - * @param string $key Transient key. - * - * @return mixed - */ - protected function get_transient( string $key ) { - return get_transient( $this->get_transient_key( $key ) ); - } - - /** - * Sets a transient value. - * - * @since 1.0.0 - * - * @param string $key Transient key. - * @param mixed $value Transient value. - * @param int $expiration Expiration. - * - * @return bool - */ - protected function set_transient( string $key, $value, int $expiration = 0 ): bool { - return set_transient( $this->get_transient_key( $key ), $value, $expiration ); - } - - /** - * Delete a transient value. - * - * @since 1.0.0 - * - * @param string $key Transient key. - * - * @return bool - */ - protected function delete_transient( string $key ): bool { - return delete_transient( $this->get_transient_key( $key ) ); - } - - /** - * Checks if a request is too frequent. - * - * Use this to avoid multiple HTTP requests being fired in a short period. - * - * @since 1.0.0 - * - * @param string $key Transient key. - * - * @return bool - */ - protected function is_duplicate_request( string $key ): bool { - return $this->get_transient( $key ) ?? false; - } - - /** - * Sets a flag for request time. - * - * This request flag is valid only for 5 mins. - * - * @since 1.0.0 - * - * @param string $key Transient key. - * - * @return bool - */ - protected function set_request_time( string $key ): bool { - return $this->set_transient( $key, time(), MINUTE_IN_SECONDS * 5 ); - } -} diff --git a/src/services/class-update.php b/src/services/class-update.php deleted file mode 100644 index a0f102f..0000000 --- a/src/services/class-update.php +++ /dev/null @@ -1,314 +0,0 @@ - - * @since 1.0.0 - * @package Freemius - * @subpackage Services - */ - -namespace DuckDev\Freemius\Services; - -// If this file is called directly, abort. -defined( 'WPINC' ) || die; - -use DuckDev\Freemius\Api\Api; -use DuckDev\Freemius\Data\Plugin; -use WP_Error; - -/** - * Class Updates - */ -class Update extends Service { - - /** - * Class constructor. - * - * @since 1.0.0 - * - * @param Plugin $plugin Plugin data. - * - * @return void - */ - public function __construct( Plugin $plugin ) { - parent::__construct( $plugin ); - - // Plugin update hooks for premium plugins. - if ( $this->plugin->is_premium() ) { - add_filter( 'plugins_api', array( $this, 'plugins_api_filter' ), 10, 3 ); - add_filter( 'site_transient_update_plugins', array( $this, 'plugin_updates_transient' ) ); - add_action( 'upgrader_process_complete', array( $this, 'purge_plugin' ), 10, 2 ); - } - } - - /** - * Get update data for the plugin. - * - * @since 1.0.0 - * - * @return array|WP_Error - */ - public function get_update_data() { - // On force-check, delete transient. - if ( isset( $_GET['force-check'] ) ) { - $this->delete_transient( 'update_data' ); - } - - // Save update data to transient if required. - $update_data = $this->get_transient( 'update_data' ); - if ( false === $update_data ) { - // Get latest update data from the API. - $update_data = $this->get_remote_latest(); - if ( is_wp_error( $update_data ) ) { - $update_data = array(); - } - - // Set to cache for 1 day. - $this->set_transient( 'update_data', $update_data, DAY_IN_SECONDS ); - } - - return $update_data; - } - - /** - * Get plugin info data. - * - * @since 1.0.0 - * - * @return array|WP_Error - */ - public function get_plugin_info() { - // Get from the cache. - $info = $this->get_transient( 'plugin_info' ); - - if ( ! $info ) { - // Get from the API. - $info = $this->get_remote_plugin_info(); - if ( ! is_wp_error( $info ) ) { - $this->set_transient( 'plugin_info', $info, DAY_IN_SECONDS ); - } - } - - return $info; - } - - /** - * Purge plugin update data from the cache. - * - * This is done only once our plugin is updated. - * - * @since 1.0.0 - * - * @param mixed $upgrader Plugin upgrader. - * @param array $hook_extra Array of bulk item update data. - * - * @return void - */ - public function purge_plugin( $upgrader, array $hook_extra ) { - if ( 'update' === $hook_extra['action'] && 'plugin' === $hook_extra['type'] ) { - // Plugin base name. - $plugin_base_name = plugin_basename( $this->plugin->get_main_file() ); - - // Clean the cache when new plugin version is installed. - if ( in_array( $plugin_base_name, $hook_extra['plugins'] ) ) { - $this->delete_transient( 'update_data' ); - } - } - } - - /** - * Filter plugin data to add our custom data. - * - * @since 1.0.0 - * - * @param false|object|array $data Plugin data. - * @param string $action Current action. - * @param object|null $args Arguments. - * - * @return object|array - */ - public function plugins_api_filter( $data, string $action = '', object $args = null ) { - // Do nothing if you're not getting plugin information right now. - if ( 'plugin_information' !== $action && ! isset( $args->slug ) ) { - return $data; - } - - // Do nothing if it is not our plugin. - if ( $this->plugin->get_slug() !== $args->slug ) { - return $data; - } - - // Only do this for activated plugins. - if ( ! $this->is_activated() ) { - return $data; - } - - $plugin_data = $this->plugin->get_data(); - $update_data = $this->get_update_data(); - - // Error while getting update data. - if ( empty( $update_data ) || is_wp_error( $update_data ) ) { - return $data; - } - - // Get plugin info. - $plugin_info = $this->get_plugin_info(); - if ( is_wp_error( $plugin_info ) ) { - $plugin_info = array(); - } - - // Set data. - $data = $args; - $data->name = $plugin_data['Name']; - $data->author = $plugin_data['Author']; - $data->version = $update_data['version']; - $data->last_updated = ! is_null( $update_data['updated'] ) ? $update_data['updated'] : $update_data['created']; - $data->requires = $update_data['requires_platform_version']; - $data->requires_php = $update_data['requires_programming_language_version']; - $data->tested = $update_data['tested_up_to_version']; - $data->download_link = $update_data['url']; - $data->banners = array( - 'high' => $plugin_info['banner_url'] ?? '', - 'low' => $plugin_info['card_banner_url'] ?? '', - ); - // Add sections. - $data->sections = wp_parse_args( - $update_data['readme']['sections'] ?? array(), - array( - 'description' => $plugin_info['description'] ?? 'Upgrade ' . $plugin_data['Name'] . ' to latest.', - ) - ); - - return $data; - } - - /** - * Update current plugin to latest version. - * - * @since 1.0.0 - * - * @param mixed $transient Transient data. - * - * @return mixed - */ - public function plugin_updates_transient( $transient ) { - if ( empty( $transient->checked ) ) { - return $transient; - } - - // Only do this for activated plugins. - if ( ! $this->is_activated() ) { - return $transient; - } - - $plugin_data = $this->plugin->get_data(); - $update_data = $this->get_update_data(); - - if ( - ! empty( $update_data ) - && ! is_wp_error( $update_data ) - && version_compare( $plugin_data['Version'], $update_data['version'], '<' ) - && version_compare( $update_data['requires_platform_version'], get_bloginfo( 'version' ), '<=' ) - && version_compare( $update_data['requires_programming_language_version'], PHP_VERSION, '<' ) - ) { - $res = new \stdClass(); - $res->slug = $this->plugin->get_slug(); - $res->plugin = plugin_basename( $this->plugin->get_main_file() ); - $res->new_version = $update_data['version']; - $res->tested = $update_data['requires_platform_version']; - $res->package = $update_data['url']; - - $transient->response[ $res->plugin ] = $res; - } - - return $transient; - } - - /** - * Get the latest plugin version from the API. - * - * @since 1.0.0 - * - * @return array|WP_Error - */ - protected function get_remote_latest() { - // Prevent multiple requests. - if ( $this->is_duplicate_request( 'update_check' ) ) { - return new WP_Error( 'too_many_requests', __( 'Too many requests. Slow down.', 'duckdev-freemius' ) ); - } - - // Only get if plugin license is active. - if ( ! $this->is_activated() ) { - return new WP_Error( 'license_not_active', __( 'No valid license is active.', 'duckdev-freemius' ) ); - } - - $activation = $this->get_activation_data(); - $plugin_data = $this->plugin->get_data(); - - // Get authenticated API instance. - $api = Api::get_auth_instance( - $activation['install_id'], - $activation['install_data']['install_public_key'], - $activation['install_data']['install_secret_key'], - 'install' - ); - - // Get the latest version data. - $updates = $api->get( - 'updates/latest.json', - array( - 'readme' => true, // Include readme data. - 'newer_than' => $plugin_data['Version'], // Only latest than current version. - ) - ); - - // To prevent multiple requests for 5 mins. - $this->set_request_time( 'update_check' ); - - return $updates; - } - - /** - * Get the latest product info from the API. - * - * @since 1.0.0 - * - * @return array|WP_Error - */ - protected function get_remote_plugin_info() { - // Get authenticated API instance using public key. - $api = Api::get_auth_instance( - $this->plugin->get_id(), - $this->plugin->get_public_key(), - $this->plugin->get_public_key(), // Use public key again for secret key to use public key encryption. - 'plugin' - ); - - // Addon list from API. - return $api->get( 'info.json' ); - } - - /** - * Check if a license is active on the site. - * - * @since 1.0.0 - * - * @return bool - */ - protected function is_activated(): bool { - $activation = $this->get_activation_data(); - - // Check for uid, install id & license key. - if ( - empty( $activation['install_id'] ) || - empty( $activation['activation_params']['uid'] ) || - empty( $activation['activation_params']['license_key'] ) - ) { - return false; - } - - return $activation['status'] === self::ACTIVATED; - } -} From 59144d8ba6169704e72654d30daf3787155ea612 Mon Sep 17 00:00:00 2001 From: Joel James Date: Wed, 3 Jun 2026 09:26:51 +0530 Subject: [PATCH 2/6] Improve: Update version --- composer.json | 2 +- src/Api/ApiFactory.php | 12 ++++----- src/Api/Client.php | 34 ++++++++++++------------- src/Api/RequestSigner.php | 2 +- src/Api/SignedClient.php | 8 +++--- src/Contracts/ApiClientInterface.php | 8 +++--- src/Contracts/CacheInterface.php | 10 ++++---- src/Contracts/ServiceInterface.php | 4 +-- src/Data/Activation.php | 38 ++++++++++++++-------------- src/Data/ApiKeys.php | 12 ++++----- src/Data/Plugin.php | 30 +++++++++++----------- src/Services/AbstractService.php | 8 +++--- src/Services/Addon.php | 16 ++++++------ src/Services/License.php | 20 +++++++-------- src/Services/Update.php | 33 +++++++++++++----------- src/Storage/ActivationRepository.php | 8 +++--- src/Storage/TransientCache.php | 18 ++++++------- src/Support/SiteIdentity.php | 2 +- src/freemius.php | 4 +-- 19 files changed, 136 insertions(+), 133 deletions(-) diff --git a/composer.json b/composer.json index ed383e9..9272b94 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "duckdev/freemius-plugin-licensing", - "version": "1.0.0", + "version": "2.0.0", "type": "library", "description": "Lite version of the Freemius SDK for managing plugin licensing and updates using Freemius APIs, specifically developed for use with Duck Dev plugins.", "keywords": [ diff --git a/src/Api/ApiFactory.php b/src/Api/ApiFactory.php index 0303ed3..552f6b3 100644 --- a/src/Api/ApiFactory.php +++ b/src/Api/ApiFactory.php @@ -44,7 +44,7 @@ class ApiFactory { /** * Signer reused across every signed client this factory produces. * - * @since 1.0.0 + * @since 2.0.0 * * @var RequestSigner */ @@ -53,7 +53,7 @@ class ApiFactory { /** * Constructor. * - * @since 1.0.0 + * @since 2.0.0 * * @param RequestSigner|null $signer Signer to use. A default instance is * constructed when null is supplied. @@ -69,7 +69,7 @@ public function __construct( ?RequestSigner $signer = null ) { * activate.json / deactivate.json endpoints which accept a * license key in the body and do not require signing. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $id Entity ID. * @param string $scope API scope. @@ -83,7 +83,7 @@ public function make_public( string $id, string $scope = 'plugin' ): ApiClientIn /** * Build a signed client from an explicit key pair. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $id Entity ID. * @param ApiKeys $keys Key pair. @@ -104,7 +104,7 @@ public function make_signed( string $id, ApiKeys $keys, string $scope = 'user' ) * Freemius expects when only the plugin public key is known to * the host (info.json, addons.json). * - * @since 1.0.0 + * @since 2.0.0 * * @param Plugin $plugin Plugin instance. * @@ -124,7 +124,7 @@ public function make_for_plugin( Plugin $plugin ): ApiClientInterface { * Build an install-scoped client signed with the credentials * returned by the API at activation time. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $install_id Install ID returned by the activation response. * @param ApiKeys $keys Install key pair (from {@see \DuckDev\Freemius\Data\Activation::api_keys()}). diff --git a/src/Api/Client.php b/src/Api/Client.php index 8e99ae8..d96c087 100644 --- a/src/Api/Client.php +++ b/src/Api/Client.php @@ -36,7 +36,7 @@ class Client implements ApiClientInterface { /** * Base URL for the Freemius API. * - * @since 1.0.0 + * @since 2.0.0 * * @var string */ @@ -45,7 +45,7 @@ class Client implements ApiClientInterface { /** * Entity ID injected into the scoped URL path. * - * @since 1.0.0 + * @since 2.0.0 * * @var string */ @@ -56,7 +56,7 @@ class Client implements ApiClientInterface { * * Mapped to the URL segment `/{scope}s/{id}/`. * - * @since 1.0.0 + * @since 2.0.0 * * @var string */ @@ -65,7 +65,7 @@ class Client implements ApiClientInterface { /** * Constructor. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $id Entity ID. * @param string $scope API scope. Defaults to `plugin`. @@ -78,7 +78,7 @@ public function __construct( string $id, string $scope = 'plugin' ) { /** * {@inheritDoc} * - * @since 1.0.0 + * @since 2.0.0 */ public function get( string $endpoint, array $params = array() ) { return $this->prepare_request( 'GET', $endpoint, $params ); @@ -87,7 +87,7 @@ public function get( string $endpoint, array $params = array() ) { /** * {@inheritDoc} * - * @since 1.0.0 + * @since 2.0.0 */ public function post( string $endpoint, array $data = array() ) { return $this->prepare_request( 'POST', $endpoint, $data ); @@ -96,7 +96,7 @@ public function post( string $endpoint, array $data = array() ) { /** * {@inheritDoc} * - * @since 1.0.0 + * @since 2.0.0 */ public function put( string $endpoint, array $data = array() ) { return $this->prepare_request( 'PUT', $endpoint, $data ); @@ -105,7 +105,7 @@ public function put( string $endpoint, array $data = array() ) { /** * {@inheritDoc} * - * @since 1.0.0 + * @since 2.0.0 */ public function delete( string $endpoint, array $data = array() ) { return $this->prepare_request( 'DELETE', $endpoint, $data ); @@ -120,7 +120,7 @@ public function delete( string $endpoint, array $data = array() ) { * 2. The JSON-decoded body when it carries an `error.code` / * `error.message` pair — the standard Freemius shape. * - * @since 1.0.0 + * @since 2.0.0 * * @param array|\WP_Error $response Raw response from `wp_remote_request()`. * @@ -147,7 +147,7 @@ public function prepare_response( $response ) { /** * Prepare URL/headers and dispatch a request. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $method HTTP method. * @param string $endpoint Caller-supplied endpoint (relative to the entity scope). @@ -169,7 +169,7 @@ protected function prepare_request( string $method, string $endpoint, array $dat * Returns an empty array for the unsigned client. Subclasses override * to add `Authorization`, `Date`, and `Content-MD5` headers. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $method HTTP method. * @param string $endpoint Prepared endpoint path (after `prepare_endpoint()`). @@ -190,7 +190,7 @@ protected function build_headers( string $method, string $endpoint, array $data * other verbs leave the URL alone and serialise the body to JSON * inside {@see perform_http_request()}. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $method HTTP method. * @param string $endpoint Prepared endpoint path. @@ -211,7 +211,7 @@ protected function prepare_url( string $method, string $endpoint, array $data = /** * Build the scoped endpoint path: `/v1/{scope}s/{id}/{endpoint}`. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $endpoint Caller-supplied endpoint (with or without leading slash). * @@ -232,7 +232,7 @@ protected function prepare_endpoint( string $endpoint ): string { /** * Dispatch the HTTP request via `wp_remote_request()`. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $method HTTP method (already uppercased upstream is fine). * @param string $url Full URL. @@ -266,7 +266,7 @@ protected function perform_http_request( string $method, string $url, array $dat /** * Filter the arguments used in the API request. * - * @since 1.0.0 + * @since 2.0.0 * * @param array $args Request arguments. * @param string $method HTTP method. @@ -287,7 +287,7 @@ protected function perform_http_request( string $method, string $url, array $dat * Filterable via `duckdev_freemius_api_request_verify_ssl` for the * rare case where a local dev environment needs to disable it. * - * @since 1.0.0 + * @since 2.0.0 * * @return bool */ @@ -295,7 +295,7 @@ protected function verify_ssl(): bool { /** * Filter to change if the API SSL should be verified. * - * @since 1.0.0 + * @since 2.0.0 * * @param bool $verify Should verify? * @param Client $client Current client instance. diff --git a/src/Api/RequestSigner.php b/src/Api/RequestSigner.php index 2732896..7a926aa 100644 --- a/src/Api/RequestSigner.php +++ b/src/Api/RequestSigner.php @@ -42,7 +42,7 @@ class RequestSigner { /** * Build the signed header set for an outgoing request. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $resource_url Prepared endpoint path (the value `prepare_endpoint()` produced). * @param string $method HTTP method (any case). diff --git a/src/Api/SignedClient.php b/src/Api/SignedClient.php index 177bada..f0a9031 100644 --- a/src/Api/SignedClient.php +++ b/src/Api/SignedClient.php @@ -34,7 +34,7 @@ class SignedClient extends Client { /** * Key pair used to sign each request. * - * @since 1.0.0 + * @since 2.0.0 * * @var ApiKeys */ @@ -43,7 +43,7 @@ class SignedClient extends Client { /** * Collaborator that produces the auth headers. * - * @since 1.0.0 + * @since 2.0.0 * * @var RequestSigner */ @@ -52,7 +52,7 @@ class SignedClient extends Client { /** * Constructor. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $id Entity ID. * @param ApiKeys $keys Key pair to sign with. @@ -75,7 +75,7 @@ public function __construct( string $id, ApiKeys $keys, RequestSigner $signer, s * caller will receive whatever the Freemius API returns in that * case (typically an authentication error). * - * @since 1.0.0 + * @since 2.0.0 * * @param string $method HTTP method. * @param string $endpoint Prepared endpoint path. diff --git a/src/Contracts/ApiClientInterface.php b/src/Contracts/ApiClientInterface.php index 2ab73a9..a4c1274 100644 --- a/src/Contracts/ApiClientInterface.php +++ b/src/Contracts/ApiClientInterface.php @@ -32,7 +32,7 @@ interface ApiClientInterface { /** * Perform a GET request against a scoped endpoint. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $endpoint Endpoint relative to the entity scope (e.g. "info.json"). * @param array $params Query string parameters. @@ -44,7 +44,7 @@ public function get( string $endpoint, array $params = array() ); /** * Perform a POST request against a scoped endpoint. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $endpoint Endpoint relative to the entity scope. * @param array $data Body data — JSON-encoded by the client. @@ -56,7 +56,7 @@ public function post( string $endpoint, array $data = array() ); /** * Perform a PUT request against a scoped endpoint. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $endpoint Endpoint relative to the entity scope. * @param array $data Body data — JSON-encoded by the client. @@ -68,7 +68,7 @@ public function put( string $endpoint, array $data = array() ); /** * Perform a DELETE request against a scoped endpoint. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $endpoint Endpoint relative to the entity scope. * @param array $data Body data — JSON-encoded by the client. diff --git a/src/Contracts/CacheInterface.php b/src/Contracts/CacheInterface.php index ec4c895..23209c6 100644 --- a/src/Contracts/CacheInterface.php +++ b/src/Contracts/CacheInterface.php @@ -38,7 +38,7 @@ interface CacheInterface { * or expired so callers can rely on a strict `false === $value` * comparison to detect a miss. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $key Cache key (unprefixed — the implementation prefixes it). * @@ -49,7 +49,7 @@ public function get( string $key ); /** * Persist a value in the cache. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $key Cache key (unprefixed). * @param mixed $value Value to cache. Must be serializable. @@ -62,7 +62,7 @@ public function set( string $key, $value, int $expiration = 0 ): bool; /** * Delete a cached value. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $key Cache key (unprefixed). * @@ -77,7 +77,7 @@ public function delete( string $key ): bool; * made recently. Always returns false until the matching * {@see mark_requested()} call has been made. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $key Throttle key. * @@ -91,7 +91,7 @@ public function is_throttled( string $key ): bool; * After this call, {@see is_throttled()} returns true for the same * key until the implementation's throttle window expires. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $key Throttle key. * diff --git a/src/Contracts/ServiceInterface.php b/src/Contracts/ServiceInterface.php index cd9669b..82adae1 100644 --- a/src/Contracts/ServiceInterface.php +++ b/src/Contracts/ServiceInterface.php @@ -42,7 +42,7 @@ interface ServiceInterface { * the host plugin (slug, ID, premium flag, etc.) without going back * through the container. * - * @since 1.0.0 + * @since 2.0.0 * * @return Plugin */ @@ -56,7 +56,7 @@ public function get_plugin(): Plugin; * return when there is nothing to register (for example when the * host plugin is the free build). * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ diff --git a/src/Data/Activation.php b/src/Data/Activation.php index a33823b..0ea3e04 100644 --- a/src/Data/Activation.php +++ b/src/Data/Activation.php @@ -56,21 +56,21 @@ class Activation { /** * Active status string persisted in the activation array. * - * @since 1.0.0 + * @since 2.0.0 */ const STATUS_ACTIVATED = 'activated'; /** * Inactive status string persisted in the activation array. * - * @since 1.0.0 + * @since 2.0.0 */ const STATUS_DEACTIVATED = 'deactivated'; /** * Underlying activation array. * - * @since 1.0.0 + * @since 2.0.0 * * @var array */ @@ -82,7 +82,7 @@ class Activation { * Accepts an empty array to represent "no activation yet" — see * {@see is_empty()} for the inverse check. * - * @since 1.0.0 + * @since 2.0.0 * * @param array $data Raw activation array. */ @@ -93,7 +93,7 @@ public function __construct( array $data = array() ) { /** * Named constructor for clarity at call sites. * - * @since 1.0.0 + * @since 2.0.0 * * @param array $data Raw activation array. * @@ -106,7 +106,7 @@ public static function from_array( array $data ): self { /** * Return the underlying array. Used by the repository for persistence. * - * @since 1.0.0 + * @since 2.0.0 * * @return array */ @@ -117,7 +117,7 @@ public function to_array(): array { /** * Install ID returned by the Freemius API at activation time. * - * @since 1.0.0 + * @since 2.0.0 * * @return string Empty string when no install has been created. */ @@ -131,7 +131,7 @@ public function install_id(): string { * Note: the key is blanked from storage on deactivation, see * {@see with_scrubbed_license()}. * - * @since 1.0.0 + * @since 2.0.0 * * @return string */ @@ -142,7 +142,7 @@ public function license_key(): string { /** * Deterministic UID of the site this activation belongs to. * - * @since 1.0.0 + * @since 2.0.0 * * @return string */ @@ -153,7 +153,7 @@ public function uid(): string { /** * Activation status, one of {@see STATUS_ACTIVATED} or {@see STATUS_DEACTIVATED}. * - * @since 1.0.0 + * @since 2.0.0 * * @return string */ @@ -164,7 +164,7 @@ public function status(): string { /** * Activation timestamp (formatted as `Y-m-d H:i:s`). * - * @since 1.0.0 + * @since 2.0.0 * * @return string */ @@ -175,7 +175,7 @@ public function date(): string { /** * Raw activation parameters originally sent to the API. * - * @since 1.0.0 + * @since 2.0.0 * * @return array */ @@ -186,7 +186,7 @@ public function activation_params(): array { /** * Raw install data as returned by the Freemius API. * - * @since 1.0.0 + * @since 2.0.0 * * @return array */ @@ -200,7 +200,7 @@ public function install_data(): array { * Returns an empty pair if the install data is incomplete — call * {@see ApiKeys::is_signable()} on the result to check. * - * @since 1.0.0 + * @since 2.0.0 * * @return ApiKeys */ @@ -217,7 +217,7 @@ public function api_keys(): ApiKeys { * Whether the activation has the identifying fields needed to * deactivate or to authenticate install-scoped API calls. * - * @since 1.0.0 + * @since 2.0.0 * * @return bool */ @@ -233,7 +233,7 @@ public function has_required_keys(): bool { * Requires the identifying fields to be present and the status to * be {@see STATUS_ACTIVATED}. * - * @since 1.0.0 + * @since 2.0.0 * * @return bool */ @@ -245,7 +245,7 @@ public function is_active(): bool { /** * Whether nothing has been stored for the plugin yet. * - * @since 1.0.0 + * @since 2.0.0 * * @return bool */ @@ -256,7 +256,7 @@ public function is_empty(): bool { /** * Return a new instance with the supplied top-level keys overridden. * - * @since 1.0.0 + * @since 2.0.0 * * @param array $changes Associative array of changes. * @@ -273,7 +273,7 @@ public function with( array $changes ): self { * options table while still preserving the rest of the * activation context for diagnostics. * - * @since 1.0.0 + * @since 2.0.0 * * @return self */ diff --git a/src/Data/ApiKeys.php b/src/Data/ApiKeys.php index af9bd6d..bff12a4 100644 --- a/src/Data/ApiKeys.php +++ b/src/Data/ApiKeys.php @@ -39,7 +39,7 @@ class ApiKeys { /** * Public key. * - * @since 1.0.0 + * @since 2.0.0 * * @var string */ @@ -48,7 +48,7 @@ class ApiKeys { /** * Secret key. Defaults to the public key when omitted (FSP mode). * - * @since 1.0.0 + * @since 2.0.0 * * @var string */ @@ -61,7 +61,7 @@ class ApiKeys { * secret key copies the public key into the secret slot, which is * how the signer recognises FSP mode. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $public_key Public key. * @param string $secret_key Secret key. Optional. @@ -74,7 +74,7 @@ public function __construct( string $public_key, string $secret_key = '' ) { /** * Get the public key. * - * @since 1.0.0 + * @since 2.0.0 * * @return string */ @@ -85,7 +85,7 @@ public function get_public_key(): string { /** * Get the secret key. * - * @since 1.0.0 + * @since 2.0.0 * * @return string */ @@ -96,7 +96,7 @@ public function get_secret_key(): string { /** * Whether the pair has enough information to sign a request. * - * @since 1.0.0 + * @since 2.0.0 * * @return bool */ diff --git a/src/Data/Plugin.php b/src/Data/Plugin.php index 4c8f232..b3eac97 100644 --- a/src/Data/Plugin.php +++ b/src/Data/Plugin.php @@ -38,7 +38,7 @@ class Plugin { /** * Freemius product ID. * - * @since 1.0.0 + * @since 2.0.0 * * @var int */ @@ -47,7 +47,7 @@ class Plugin { /** * Freemius product slug. * - * @since 1.0.0 + * @since 2.0.0 * * @var string */ @@ -59,7 +59,7 @@ class Plugin { * Required by WordPress to look up plugin headers and compute * `plugin_basename()`. * - * @since 1.0.0 + * @since 2.0.0 * * @var string */ @@ -68,7 +68,7 @@ class Plugin { /** * Freemius public key (`pk_…`). * - * @since 1.0.0 + * @since 2.0.0 * * @var string */ @@ -80,7 +80,7 @@ class Plugin { * Used by services to decide whether to register update hooks or * accept license activation requests. * - * @since 1.0.0 + * @since 2.0.0 * * @var bool */ @@ -89,7 +89,7 @@ class Plugin { /** * Whether the plugin has addons to list. * - * @since 1.0.0 + * @since 2.0.0 * * @var bool */ @@ -100,7 +100,7 @@ class Plugin { * * Populated lazily on first call to {@see get_data()}. * - * @since 1.0.0 + * @since 2.0.0 * * @var array */ @@ -109,7 +109,7 @@ class Plugin { /** * Constructor. * - * @since 1.0.0 + * @since 2.0.0 * * @param int $id Freemius product ID. * @param array $args Plugin args (see class docblock for the schema). @@ -126,7 +126,7 @@ public function __construct( int $id, array $args ) { /** * Get the Freemius product ID. * - * @since 1.0.0 + * @since 2.0.0 * * @return int */ @@ -137,7 +137,7 @@ public function get_id(): int { /** * Get the Freemius product slug. * - * @since 1.0.0 + * @since 2.0.0 * * @return string */ @@ -148,7 +148,7 @@ public function get_slug(): string { /** * Get the absolute path to the plugin's main file. * - * @since 1.0.0 + * @since 2.0.0 * * @return string */ @@ -159,7 +159,7 @@ public function get_main_file(): string { /** * Get the Freemius public key. * - * @since 1.0.0 + * @since 2.0.0 * * @return string */ @@ -170,7 +170,7 @@ public function get_public_key(): string { /** * Whether the host plugin is the premium build. * - * @since 1.0.0 + * @since 2.0.0 * * @return bool */ @@ -181,7 +181,7 @@ public function is_premium(): bool { /** * Whether the host plugin has addons to list. * - * @since 1.0.0 + * @since 2.0.0 * * @return bool */ @@ -196,7 +196,7 @@ public function has_addons(): bool { * helper from `wp-admin/includes/plugin.php` is loaded on demand * because it isn't available on front-end requests by default. * - * @since 1.0.0 + * @since 2.0.0 * * @return array Plugin headers as returned by `get_plugin_data()`. */ diff --git a/src/Services/AbstractService.php b/src/Services/AbstractService.php index eaa169b..05fdaea 100644 --- a/src/Services/AbstractService.php +++ b/src/Services/AbstractService.php @@ -36,7 +36,7 @@ abstract class AbstractService implements ServiceInterface { /** * Plugin the service is operating on behalf of. * - * @since 1.0.0 + * @since 2.0.0 * * @var Plugin */ @@ -45,7 +45,7 @@ abstract class AbstractService implements ServiceInterface { /** * Constructor. * - * @since 1.0.0 + * @since 2.0.0 * * @param Plugin $plugin Plugin instance. */ @@ -56,7 +56,7 @@ public function __construct( Plugin $plugin ) { /** * {@inheritDoc} * - * @since 1.0.0 + * @since 2.0.0 */ public function get_plugin(): Plugin { return $this->plugin; @@ -68,7 +68,7 @@ public function get_plugin(): Plugin { * Default implementation registers no hooks. Override in subclasses * to attach to WordPress filters/actions during boot. * - * @since 1.0.0 + * @since 2.0.0 */ public function boot(): void { // No-op by default. diff --git a/src/Services/Addon.php b/src/Services/Addon.php index 68e3b1f..2883a1b 100644 --- a/src/Services/Addon.php +++ b/src/Services/Addon.php @@ -40,14 +40,14 @@ class Addon extends AbstractService { * constant so it is easy to find when Freemius changes the * checkout domain or path. * - * @since 1.0.0 + * @since 2.0.0 */ const CHECKOUT_URL = 'https://checkout.freemius.com/plugin/'; /** * Cache used to memoise the addon list and throttle API calls. * - * @since 1.0.0 + * @since 2.0.0 * * @var CacheInterface */ @@ -56,7 +56,7 @@ class Addon extends AbstractService { /** * Factory used to obtain API clients. * - * @since 1.0.0 + * @since 2.0.0 * * @var ApiFactory */ @@ -65,7 +65,7 @@ class Addon extends AbstractService { /** * Constructor. * - * @since 1.0.0 + * @since 2.0.0 * * @param Plugin $plugin Plugin instance. * @param CacheInterface $cache Cache. @@ -89,7 +89,7 @@ public function __construct( Plugin $plugin, CacheInterface $cache, ApiFactory $ * Use `$force = true` to bypass the cache when the user explicitly * asks for a refresh from the host plugin's UI. * - * @since 1.0.0 + * @since 2.0.0 * * @param bool $force Whether to bypass the cache. * @@ -124,7 +124,7 @@ public function get_addons( bool $force = false ): array { * Uses the plugin-scoped signed client (FSP encoding) since this * endpoint is reachable with only the public key. * - * @since 1.0.0 + * @since 2.0.0 * * @return array|\WP_Error List of addons or WP_Error on failure / throttle. */ @@ -158,7 +158,7 @@ protected function get_remote_addons() { * - `link` — Freemius checkout URL for the addon. * - `is_premium` — boolean mirror of `is_pricing_visible`. * - * @since 1.0.0 + * @since 2.0.0 * * @param array $addon Raw addon entry from the API. * @@ -171,7 +171,7 @@ protected function format_addon_data( array $addon ): array { /** * Filter the formatted addon data before it is returned / cached. * - * @since 1.0.0 + * @since 2.0.0 * * @param array $addon Addon data. * @param Addon $self Current service instance. diff --git a/src/Services/License.php b/src/Services/License.php index baa9fce..e2243c0 100644 --- a/src/Services/License.php +++ b/src/Services/License.php @@ -41,7 +41,7 @@ class License extends AbstractService { /** * Repository used to read and persist the activation. * - * @since 1.0.0 + * @since 2.0.0 * * @var ActivationRepository */ @@ -50,7 +50,7 @@ class License extends AbstractService { /** * Factory used to obtain API clients. * - * @since 1.0.0 + * @since 2.0.0 * * @var ApiFactory */ @@ -59,7 +59,7 @@ class License extends AbstractService { /** * Helper used to compute the current site's UID. * - * @since 1.0.0 + * @since 2.0.0 * * @var SiteIdentity */ @@ -68,7 +68,7 @@ class License extends AbstractService { /** * Constructor. * - * @since 1.0.0 + * @since 2.0.0 * * @param Plugin $plugin Plugin instance. * @param ActivationRepository $activations Activation repository. @@ -94,7 +94,7 @@ public function __construct( * Always returns an {@see Activation} — empty when nothing is * stored yet. * - * @since 1.0.0 + * @since 2.0.0 * * @return Activation */ @@ -109,7 +109,7 @@ public function get_activation(): Activation { * key. On a successful response the install ID is persisted so * subsequent activate calls reuse the same install. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $key License key supplied by the user. * @@ -167,7 +167,7 @@ public function activate( string $key ) { /** * Fires after a plugin license is activated. * - * @since 1.0.0 + * @since 2.0.0 * * @param array $activation Activation data array. * @param bool $success Whether the option update succeeded. @@ -189,7 +189,7 @@ public function activate( string $key ) { * in which case the new site is treated as not licensed rather * than silently freeing the seat at the original host. * - * @since 1.0.0 + * @since 2.0.0 * * @return bool|\WP_Error */ @@ -224,7 +224,7 @@ public function deactivate() { /** * Fires after a plugin license is deactivated. * - * @since 1.0.0 + * @since 2.0.0 * * @param array $activation Activation data array. * @param bool $success Whether the option update succeeded. @@ -245,7 +245,7 @@ public function deactivate() { * 2. The identifying fields (install ID, UID, license key) are present. * 3. The stored UID matches the current site UID. * - * @since 1.0.0 + * @since 2.0.0 * * @param Activation $activation Activation to check. * diff --git a/src/Services/Update.php b/src/Services/Update.php index 0dc5db5..8fc450b 100644 --- a/src/Services/Update.php +++ b/src/Services/Update.php @@ -45,7 +45,7 @@ class Update extends AbstractService { /** * Repository used to read the current activation. * - * @since 1.0.0 + * @since 2.0.0 * * @var ActivationRepository */ @@ -54,7 +54,7 @@ class Update extends AbstractService { /** * Cache used to throttle and memoise API calls. * - * @since 1.0.0 + * @since 2.0.0 * * @var CacheInterface */ @@ -63,7 +63,7 @@ class Update extends AbstractService { /** * Factory used to obtain API clients. * - * @since 1.0.0 + * @since 2.0.0 * * @var ApiFactory */ @@ -72,7 +72,7 @@ class Update extends AbstractService { /** * Constructor. * - * @since 1.0.0 + * @since 2.0.0 * * @param Plugin $plugin Plugin instance. * @param ActivationRepository $activations Activation repository. @@ -98,7 +98,7 @@ public function __construct( * Only attaches when the host plugin is the premium build — the * free build does not consume the Freemius update API. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ @@ -119,7 +119,7 @@ public function boot(): void { * `?force-check=1`, the WordPress convention) to bypass the cache * and re-fetch. * - * @since 1.0.0 + * @since 2.0.0 * * @param bool $force Whether to bypass the cache. * @@ -141,7 +141,10 @@ public function get_update_data( bool $force = false ) { if ( false === $update_data ) { $update_data = $this->get_remote_latest(); if ( is_wp_error( $update_data ) ) { - $update_data = array(); + // Don't cache failures — a transient error (throttle, network blip, + // license-not-yet-active) would otherwise suppress updates for a + // full day. Callers already handle WP_Error as "no update". + return $update_data; } $this->cache->set( 'update_data', $update_data, DAY_IN_SECONDS ); @@ -155,7 +158,7 @@ public function get_update_data( bool $force = false ) { * * Cached for a day on success. * - * @since 1.0.0 + * @since 2.0.0 * * @return array|\WP_Error */ @@ -178,7 +181,7 @@ public function get_plugin_info() { * Bound to `upgrader_process_complete`. The check ensures we only * purge when the upgrader actually touched THIS plugin. * - * @since 1.0.0 + * @since 2.0.0 * * @param mixed $upgrader Plugin upgrader. Unused. * @param array $hook_extra Hook context from WordPress. @@ -211,7 +214,7 @@ public function purge_plugin( $upgrader, array $hook_extra ): void { * Plugins screen. Returns the original `$data` argument untouched * when the request is not for this plugin. * - * @since 1.0.0 + * @since 2.0.0 * * @param false|object|array $data Existing payload from upstream filters. * @param string $action The current `plugins_api` action. @@ -278,7 +281,7 @@ public function plugins_api_filter( $data, string $action = '', ?object $args = * Injects our plugin's update info into the standard WP transient * so the core Plugins screen offers the upgrade. * - * @since 1.0.0 + * @since 2.0.0 * * @param mixed $transient Existing transient value. * @@ -307,7 +310,7 @@ public function plugin_updates_transient( $transient ) { ) && version_compare( $plugin_data['Version'] ?? '0', $update_data['version'], '<' ) && version_compare( $update_data['requires_platform_version'], get_bloginfo( 'version' ), '<=' ) - && version_compare( $update_data['requires_programming_language_version'], PHP_VERSION, '<' ) + && version_compare( $update_data['requires_programming_language_version'], PHP_VERSION, '<=' ) ) { $res = new stdClass(); $res->slug = $this->plugin->get_slug(); @@ -330,7 +333,7 @@ public function plugin_updates_transient( $transient ) { * otherwise so the caller can cache an empty result and avoid * re-hitting the API on every page load. * - * @since 1.0.0 + * @since 2.0.0 * * @return array|\WP_Error */ @@ -363,7 +366,7 @@ protected function get_remote_latest() { /** * Fetch the product info payload from the Freemius API. * - * @since 1.0.0 + * @since 2.0.0 * * @return array|\WP_Error */ @@ -376,7 +379,7 @@ protected function get_remote_plugin_info() { /** * Whether the host plugin currently has an active license. * - * @since 1.0.0 + * @since 2.0.0 * * @return bool */ diff --git a/src/Storage/ActivationRepository.php b/src/Storage/ActivationRepository.php index 6eed28d..1747e45 100644 --- a/src/Storage/ActivationRepository.php +++ b/src/Storage/ActivationRepository.php @@ -36,7 +36,7 @@ class ActivationRepository { /** * Option key that stores activations for all Duck Dev plugins. * - * @since 1.0.0 + * @since 2.0.0 */ const OPTION_KEY = 'duckdev_freemius_activation_data'; @@ -47,7 +47,7 @@ class ActivationRepository { * {@see Activation::is_empty()} to detect the no-activation case * rather than null-checking the result. * - * @since 1.0.0 + * @since 2.0.0 * * @param int $plugin_id Freemius plugin ID. * @@ -62,7 +62,7 @@ public function get( int $plugin_id ): Activation { /** * Persist an activation for a plugin. * - * @since 1.0.0 + * @since 2.0.0 * * @param int $plugin_id Freemius plugin ID. * @param Activation $activation Activation to persist. @@ -79,7 +79,7 @@ public function save( int $plugin_id, Activation $activation ): bool { /** * Remove the activation for a plugin entirely. * - * @since 1.0.0 + * @since 2.0.0 * * @param int $plugin_id Freemius plugin ID. * diff --git a/src/Storage/TransientCache.php b/src/Storage/TransientCache.php index 0912f06..67b89e6 100644 --- a/src/Storage/TransientCache.php +++ b/src/Storage/TransientCache.php @@ -34,7 +34,7 @@ class TransientCache implements CacheInterface { /** * Plugin instance the cache is scoped to. * - * @since 1.0.0 + * @since 2.0.0 * * @var Plugin */ @@ -43,7 +43,7 @@ class TransientCache implements CacheInterface { /** * Throttle window length in seconds. * - * @since 1.0.0 + * @since 2.0.0 * * @var int */ @@ -52,7 +52,7 @@ class TransientCache implements CacheInterface { /** * Constructor. * - * @since 1.0.0 + * @since 2.0.0 * * @param Plugin $plugin Plugin instance the cache is scoped to. * @param int $throttle_window Throttle window in seconds. Defaults to @@ -66,7 +66,7 @@ public function __construct( Plugin $plugin, int $throttle_window = 0 ) { /** * Build a plugin-scoped transient key from a caller-supplied key. * - * @since 1.0.0 + * @since 2.0.0 * * @param string $key Caller key (un-prefixed). * @@ -79,7 +79,7 @@ private function build_key( string $key ): string { /** * {@inheritDoc} * - * @since 1.0.0 + * @since 2.0.0 */ public function get( string $key ) { return get_transient( $this->build_key( $key ) ); @@ -88,7 +88,7 @@ public function get( string $key ) { /** * {@inheritDoc} * - * @since 1.0.0 + * @since 2.0.0 */ public function set( string $key, $value, int $expiration = 0 ): bool { return (bool) set_transient( $this->build_key( $key ), $value, $expiration ); @@ -97,7 +97,7 @@ public function set( string $key, $value, int $expiration = 0 ): bool { /** * {@inheritDoc} * - * @since 1.0.0 + * @since 2.0.0 */ public function delete( string $key ): bool { return (bool) delete_transient( $this->build_key( $key ) ); @@ -106,7 +106,7 @@ public function delete( string $key ): bool { /** * {@inheritDoc} * - * @since 1.0.0 + * @since 2.0.0 */ public function is_throttled( string $key ): bool { return false !== $this->get( $key ); @@ -115,7 +115,7 @@ public function is_throttled( string $key ): bool { /** * {@inheritDoc} * - * @since 1.0.0 + * @since 2.0.0 */ public function mark_requested( string $key ): bool { return $this->set( $key, time(), $this->throttle_window ); diff --git a/src/Support/SiteIdentity.php b/src/Support/SiteIdentity.php index 35b80f7..769a105 100644 --- a/src/Support/SiteIdentity.php +++ b/src/Support/SiteIdentity.php @@ -36,7 +36,7 @@ class SiteIdentity { * subsites get distinct UIDs and subdirectory installs do not * collide with the root site. * - * @since 1.0.0 + * @since 2.0.0 * * @return string 32-character hexadecimal UID. */ diff --git a/src/freemius.php b/src/freemius.php index 6e5af89..51c7b82 100644 --- a/src/freemius.php +++ b/src/freemius.php @@ -160,7 +160,7 @@ public static function get_instance( int $id, array $args = array() ): self { * automatically by {@see get_instance()}; exposed publicly only * so unusual host integrations can defer hook registration. * - * @since 1.0.0 + * @since 2.0.0 * * @return void */ @@ -179,7 +179,7 @@ public function boot(): void { /** * Get the plugin data instance. * - * @since 1.0.0 + * @since 2.0.0 * * @return Plugin */ From c6a51581b52fe2c474494a9900149c37a3733fc9 Mon Sep 17 00:00:00 2001 From: Joel James Date: Wed, 3 Jun 2026 10:09:09 +0530 Subject: [PATCH 3/6] New: Added unit tests, PHPCS checks --- .github/workflows/phpcs.yml | 39 ++ .github/workflows/tests.yml | 43 ++ .gitignore | 4 + composer.json | 21 + phpcs.xml.dist | 67 +++ phpunit.xml.dist | 25 ++ tests/Api/ApiFactoryTest.php | 74 ++++ tests/Api/ClientTest.php | 187 ++++++++ tests/Api/RequestSignerTest.php | 119 ++++++ tests/Api/SignedClientTest.php | 71 ++++ tests/Contracts/ContractsTest.php | 45 ++ tests/Data/ActivationTest.php | 117 +++++ tests/Data/ApiKeysTest.php | 37 ++ tests/Data/PluginTest.php | 76 ++++ tests/Exceptions/FreemiusExceptionTest.php | 19 + tests/FreemiusTest.php | 61 +++ tests/Services/AbstractServiceTest.php | 27 ++ tests/Services/AddonTest.php | 145 +++++++ tests/Services/LicenseTest.php | 305 +++++++++++++ tests/Services/UpdateTest.php | 470 +++++++++++++++++++++ tests/Storage/ActivationRepositoryTest.php | 81 ++++ tests/Storage/TransientCacheTest.php | 83 ++++ tests/Stubs/WP_Error.php | 37 ++ tests/Support/SiteIdentityTest.php | 57 +++ tests/TestCase.php | 41 ++ tests/bootstrap.php | 38 ++ 26 files changed, 2289 insertions(+) create mode 100644 .github/workflows/phpcs.yml create mode 100644 .github/workflows/tests.yml create mode 100644 phpcs.xml.dist create mode 100644 phpunit.xml.dist create mode 100644 tests/Api/ApiFactoryTest.php create mode 100644 tests/Api/ClientTest.php create mode 100644 tests/Api/RequestSignerTest.php create mode 100644 tests/Api/SignedClientTest.php create mode 100644 tests/Contracts/ContractsTest.php create mode 100644 tests/Data/ActivationTest.php create mode 100644 tests/Data/ApiKeysTest.php create mode 100644 tests/Data/PluginTest.php create mode 100644 tests/Exceptions/FreemiusExceptionTest.php create mode 100644 tests/FreemiusTest.php create mode 100644 tests/Services/AbstractServiceTest.php create mode 100644 tests/Services/AddonTest.php create mode 100644 tests/Services/LicenseTest.php create mode 100644 tests/Services/UpdateTest.php create mode 100644 tests/Storage/ActivationRepositoryTest.php create mode 100644 tests/Storage/TransientCacheTest.php create mode 100644 tests/Stubs/WP_Error.php create mode 100644 tests/Support/SiteIdentityTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/bootstrap.php diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml new file mode 100644 index 0000000..34f1be6 --- /dev/null +++ b/.github/workflows/phpcs.yml @@ -0,0 +1,39 @@ +name: PHPCS + +on: + push: + branches: [main] + pull_request: + +jobs: + phpcs: + name: WPCS lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + tools: composer:v2, cs2pr + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-phpcs-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-composer-phpcs- + + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + + - name: Run PHPCS + run: vendor/bin/phpcs -q --report=checkstyle | cs2pr diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b81ce87 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + phpunit: + name: PHPUnit (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3'] + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-composer-${{ matrix.php }}- + + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + + - name: Run PHPUnit + run: vendor/bin/phpunit --no-coverage diff --git a/.gitignore b/.gitignore index e486795..3a25481 100755 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ .DS_Store node_modules vendor +.phpunit.result.cache +.phpcs-cache +phpcs.xml +phpunit.xml # local env files diff --git a/composer.json b/composer.json index 9272b94..44c748d 100644 --- a/composer.json +++ b/composer.json @@ -23,14 +23,35 @@ "require": { "php": ">=7.4" }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "brain/monkey": "^2.6", + "wp-coding-standards/wpcs": "^3.1", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "squizlabs/php_codesniffer": "^3.10" + }, "config": { "platform": { "php": "7.4" + }, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true } }, "autoload": { "psr-4": { "DuckDev\\Freemius\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "DuckDev\\Freemius\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "phpcs": "phpcs", + "phpcbf": "phpcbf" } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..41d0e3d --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,67 @@ + + + WordPress coding standards adapted for a PSR-4 library. + + src + tests + + + + + + + + */vendor/* + */node_modules/* + tests/Stubs/* + + + + + + + + + + + + + + + + + + + + src/Api/Client.php + src/Storage/TransientCache.php + + + + + src/Api/RequestSigner.php + + + + + + + + tests/* + + + tests/* + + + tests/* + + + tests/* + + + tests/* + + + tests/* + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..330446b --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + tests + + + + + src + + + diff --git a/tests/Api/ApiFactoryTest.php b/tests/Api/ApiFactoryTest.php new file mode 100644 index 0000000..f920711 --- /dev/null +++ b/tests/Api/ApiFactoryTest.php @@ -0,0 +1,74 @@ +make_public( '1', 'plugin' ); + + $this->assertInstanceOf( Client::class, $client ); + $this->assertNotInstanceOf( SignedClient::class, $client ); + } + + public function test_make_signed_returns_signed_client(): void { + $client = ( new ApiFactory() )->make_signed( '9', new ApiKeys( 'pk', 'sk' ), 'install' ); + + $this->assertInstanceOf( SignedClient::class, $client ); + } + + public function test_make_for_plugin_uses_plugin_public_key_in_both_slots(): void { + $signer = $this->createMock( RequestSigner::class ); + $factory = new ApiFactory( $signer ); + + $plugin = new Plugin( + 42, + array( + 'public_key' => 'pk_test', + ) + ); + + $client = $factory->make_for_plugin( $plugin ); + + $this->assertInstanceOf( SignedClient::class, $client ); + + // Reflect into the internal keys to confirm both slots use the public key (FSP). + $ref = new \ReflectionObject( $client ); + $prop = $ref->getProperty( 'keys' ); + $prop->setAccessible( true ); + $keys = $prop->getValue( $client ); + $this->assertInstanceOf( ApiKeys::class, $keys ); + + $this->assertSame( 'pk_test', $keys->get_public_key() ); + $this->assertSame( 'pk_test', $keys->get_secret_key() ); + } + + public function test_make_for_install_uses_provided_keys(): void { + $keys = new ApiKeys( 'pk', 'sk' ); + $client = ( new ApiFactory() )->make_for_install( '42', $keys ); + + $ref = new \ReflectionObject( $client ); + $prop = $ref->getProperty( 'keys' ); + $prop->setAccessible( true ); + + $this->assertSame( $keys, $prop->getValue( $client ) ); + } + + public function test_factory_returns_fresh_instances_per_call(): void { + $factory = new ApiFactory(); + $a = $factory->make_public( '1' ); + $b = $factory->make_public( '1' ); + + $this->assertNotSame( $a, $b ); + } +} diff --git a/tests/Api/ClientTest.php b/tests/Api/ClientTest.php new file mode 100644 index 0000000..dfe1cf7 --- /dev/null +++ b/tests/Api/ClientTest.php @@ -0,0 +1,187 @@ +justReturn( '6.5' ); + Functions\when( 'home_url' )->justReturn( 'https://example.com/' ); + Functions\when( 'add_query_arg' )->alias( + static function ( array $args, string $url ) { + return $url . '?' . http_build_query( $args ); + } + ); + } + + public function test_prepare_response_passes_through_wp_error(): void { + $err = new WP_Error( 'x', 'y' ); + + $this->assertSame( $err, ( new Client( '1' ) )->prepare_response( $err ) ); + } + + public function test_prepare_response_surfaces_top_level_error_envelope(): void { + $response = ( new Client( '1' ) )->prepare_response( + array( + 'error' => array( + 'code' => 'bad', + 'message' => 'nope', + ), + ) + ); + + $this->assertInstanceOf( WP_Error::class, $response ); + $this->assertSame( 'bad', $response->get_error_code() ); + $this->assertSame( 'nope', $response->get_error_message() ); + } + + public function test_prepare_response_decodes_json_body(): void { + $response = ( new Client( '1' ) )->prepare_response( + array( 'body' => json_encode( array( 'ok' => true ) ) ) + ); + + $this->assertSame( array( 'ok' => true ), $response ); + } + + public function test_prepare_response_surfaces_decoded_error_envelope(): void { + $response = ( new Client( '1' ) )->prepare_response( + array( + 'body' => json_encode( + array( + 'error' => array( + 'code' => 'oops', + 'message' => 'broken', + ), + ) + ), + ) + ); + + $this->assertInstanceOf( WP_Error::class, $response ); + $this->assertSame( 'oops', $response->get_error_code() ); + } + + public function test_get_uses_query_string_and_decoded_body(): void { + $this->stubCommonWp(); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturnUsing( + static function ( string $url, array $args ) { + \PHPUnit\Framework\Assert::assertSame( 'GET', $args['method'] ); + \PHPUnit\Framework\Assert::assertNull( $args['body'] ); + \PHPUnit\Framework\Assert::assertStringContainsString( 'foo=bar', $url ); + \PHPUnit\Framework\Assert::assertStringContainsString( '/v1/plugins/1/info.json', $url ); + return array( 'body' => json_encode( array( 'ok' => 1 ) ) ); + } + ); + + $result = ( new Client( '1' ) )->get( 'info.json', array( 'foo' => 'bar' ) ); + + $this->assertSame( array( 'ok' => 1 ), $result ); + } + + public function test_post_serialises_body_to_json_and_sets_content_type(): void { + $this->stubCommonWp(); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturnUsing( + static function ( string $url, array $args ) { + \PHPUnit\Framework\Assert::assertSame( 'POST', $args['method'] ); + \PHPUnit\Framework\Assert::assertSame( 'application/json', $args['headers']['Content-type'] ); + \PHPUnit\Framework\Assert::assertSame( json_encode( array( 'a' => 1 ) ), $args['body'] ); + \PHPUnit\Framework\Assert::assertStringNotContainsString( '?', $url ); + return array( 'body' => json_encode( array() ) ); + } + ); + + ( new Client( '1' ) )->post( 'activate.json', array( 'a' => 1 ) ); + } + + public function test_put_and_delete_use_correct_methods_and_json_body(): void { + $this->stubCommonWp(); + + $seen_methods = array(); + Functions\when( 'wp_remote_request' )->alias( + static function ( string $url, array $args ) use ( &$seen_methods ) { + $seen_methods[] = $args['method']; + return array( 'body' => json_encode( array() ) ); + } + ); + + $client = new Client( '1' ); + $client->put( 'a', array( 'a' => 1 ) ); + $client->delete( 'b', array( 'b' => 2 ) ); + + $this->assertSame( array( 'PUT', 'DELETE' ), $seen_methods ); + } + + public function test_scope_segment_uses_plural_form(): void { + $this->stubCommonWp(); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturnUsing( + static function ( string $url ) { + \PHPUnit\Framework\Assert::assertStringContainsString( '/v1/installs/9/', $url ); + return array( 'body' => '[]' ); + } + ); + + ( new Client( '9', 'install' ) )->get( 'updates/latest.json' ); + } + + public function test_request_args_filter_can_modify_args(): void { + $this->stubCommonWp(); + + Filters\expectApplied( 'duckdev_freemius_api_request_args' ) + ->once() + ->andReturnUsing( + static function ( array $args ) { + $args['timeout'] = 1; + return $args; + } + ); + + Filters\expectApplied( 'duckdev_freemius_api_request_verify_ssl' ) + ->once() + ->andReturn( false ); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturnUsing( + static function ( string $url, array $args ) { + \PHPUnit\Framework\Assert::assertSame( 1, $args['timeout'] ); + \PHPUnit\Framework\Assert::assertFalse( $args['sslverify'] ); + return array( 'body' => '[]' ); + } + ); + + ( new Client( '1' ) )->get( 'info.json' ); + } + + public function test_endpoint_leading_slash_is_normalised(): void { + $this->stubCommonWp(); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturnUsing( + static function ( string $url ) { + \PHPUnit\Framework\Assert::assertStringContainsString( '/v1/plugins/1/info.json', $url ); + \PHPUnit\Framework\Assert::assertStringNotContainsString( '//info.json', $url ); + return array( 'body' => '[]' ); + } + ); + + ( new Client( '1' ) )->get( '/info.json' ); + } +} diff --git a/tests/Api/RequestSignerTest.php b/tests/Api/RequestSignerTest.php new file mode 100644 index 0000000..a9e8962 --- /dev/null +++ b/tests/Api/RequestSignerTest.php @@ -0,0 +1,119 @@ +sign( '/v1/plugins/1/info.json', 'GET', array(), '1', $keys ); + + $this->assertArrayHasKey( 'Date', $headers ); + $this->assertArrayHasKey( 'Authorization', $headers ); + $this->assertArrayNotHasKey( 'Content-MD5', $headers ); + } + + public function test_authorization_uses_fs_scheme_when_keys_differ(): void { + $headers = ( new RequestSigner() )->sign( + '/v1/installs/9/x.json', + 'POST', + array( 'a' => 1 ), + '9', + new ApiKeys( 'pk_pub', 'sk_secret' ) + ); + + $this->assertStringStartsWith( 'FS 9:pk_pub:', $headers['Authorization'] ); + } + + public function test_authorization_uses_fsp_scheme_when_keys_identical(): void { + $headers = ( new RequestSigner() )->sign( + '/v1/plugins/1/info.json', + 'GET', + array(), + '1', + new ApiKeys( 'pk_pub', 'pk_pub' ) + ); + + $this->assertStringStartsWith( 'FSP 1:pk_pub:', $headers['Authorization'] ); + } + + public function test_post_with_body_emits_content_md5_matching_body_hash(): void { + $body = array( 'k' => 'v' ); + + $headers = ( new RequestSigner() )->sign( + '/v1/installs/9/license.json', + 'POST', + $body, + '9', + new ApiKeys( 'pk_pub', 'sk_secret' ) + ); + + $this->assertArrayHasKey( 'Content-MD5', $headers ); + $this->assertSame( md5( json_encode( $body ) ), $headers['Content-MD5'] ); + } + + public function test_put_with_empty_body_omits_content_md5(): void { + $headers = ( new RequestSigner() )->sign( + '/v1/installs/9/x.json', + 'PUT', + array(), + '9', + new ApiKeys( 'pk_pub', 'sk_secret' ) + ); + + $this->assertArrayNotHasKey( 'Content-MD5', $headers ); + } + + public function test_signature_is_deterministic_for_fixed_date(): void { + // We cannot freeze gmdate('r') without injection; check stability + // across two adjacent calls instead. + $signer = new RequestSigner(); + $keys = new ApiKeys( 'pk_pub', 'pk_pub' ); + + $a = $signer->sign( '/v1/plugins/1/info.json', 'GET', array(), '1', $keys ); + $b = $signer->sign( '/v1/plugins/1/info.json', 'GET', array(), '1', $keys ); + + // Authorization hashes the Date which may differ by 1 second between + // calls; both must still be well-formed FSP headers. + $this->assertStringStartsWith( 'FSP 1:pk_pub:', $a['Authorization'] ); + $this->assertStringStartsWith( 'FSP 1:pk_pub:', $b['Authorization'] ); + } + + public function test_signature_hash_is_url_safe_base64(): void { + $headers = ( new RequestSigner() )->sign( + '/v1/plugins/1/info.json', + 'POST', + array( 'a' => 'b' ), + '1', + new ApiKeys( 'pk_pub', 'sk_secret' ) + ); + + [, $signature] = explode( ':pk_pub:', $headers['Authorization'] ); + + $this->assertStringNotContainsString( '+', $signature ); + $this->assertStringNotContainsString( '/', $signature ); + $this->assertStringNotContainsString( '=', $signature ); + } + + public function test_method_case_does_not_change_scheme(): void { + $keys = new ApiKeys( 'pk', 'sk' ); + + $lower = ( new RequestSigner() )->sign( '/x', 'post', array( 'a' => 1 ), '1', $keys ); + $upper = ( new RequestSigner() )->sign( '/x', 'POST', array( 'a' => 1 ), '1', $keys ); + + $this->assertSame( + substr( $lower['Authorization'], 0, 3 ), + substr( $upper['Authorization'], 0, 3 ) + ); + $this->assertArrayHasKey( 'Content-MD5', $lower ); + $this->assertArrayHasKey( 'Content-MD5', $upper ); + } +} diff --git a/tests/Api/SignedClientTest.php b/tests/Api/SignedClientTest.php new file mode 100644 index 0000000..3c6b5f0 --- /dev/null +++ b/tests/Api/SignedClientTest.php @@ -0,0 +1,71 @@ +justReturn( '6.5' ); + Functions\when( 'home_url' )->justReturn( 'https://example.com/' ); + Functions\when( 'add_query_arg' )->alias( + static function ( array $args, string $url ) { + return $url . '?' . http_build_query( $args ); + } + ); + } + + public function test_signed_request_includes_signer_headers(): void { + $this->stubCommonWp(); + + $signer = $this->createMock( RequestSigner::class ); + $signer->expects( $this->once() ) + ->method( 'sign' ) + ->willReturn( + array( + 'Authorization' => 'FS 9:pk:sig', + 'Date' => 'Wed, 01 Jan 2020 00:00:00 GMT', + ) + ); + + $keys = new ApiKeys( 'pk', 'sk' ); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturnUsing( + static function ( string $url, array $args ) { + \PHPUnit\Framework\Assert::assertSame( 'FS 9:pk:sig', $args['headers']['Authorization'] ); + return array( 'body' => '[]' ); + } + ); + + ( new SignedClient( '9', $keys, $signer, 'install' ) )->get( 'updates/latest.json' ); + } + + public function test_unsignable_keys_skip_signer_and_send_no_auth_headers(): void { + $this->stubCommonWp(); + + $signer = $this->createMock( RequestSigner::class ); + $signer->expects( $this->never() )->method( 'sign' ); + + $keys = new ApiKeys( '', '' ); + + Functions\expect( 'wp_remote_request' ) + ->once() + ->andReturnUsing( + static function ( string $url, array $args ) { + \PHPUnit\Framework\Assert::assertArrayNotHasKey( 'Authorization', $args['headers'] ); + return array( 'body' => '[]' ); + } + ); + + ( new SignedClient( '9', $keys, $signer, 'install' ) )->get( 'updates/latest.json' ); + } +} diff --git a/tests/Contracts/ContractsTest.php b/tests/Contracts/ContractsTest.php new file mode 100644 index 0000000..f9a22dd --- /dev/null +++ b/tests/Contracts/ContractsTest.php @@ -0,0 +1,45 @@ +assertTrue( is_subclass_of( Client::class, ApiClientInterface::class ) ); + $this->assertTrue( is_subclass_of( TransientCache::class, CacheInterface::class ) ); + $this->assertTrue( is_subclass_of( License::class, ServiceInterface::class ) ); + } + + public function test_api_client_interface_declares_expected_verbs(): void { + $reflection = new \ReflectionClass( ApiClientInterface::class ); + foreach ( array( 'get', 'post', 'put', 'delete' ) as $method ) { + $this->assertTrue( $reflection->hasMethod( $method ), "ApiClientInterface missing $method" ); + } + } + + public function test_cache_interface_declares_expected_methods(): void { + $reflection = new \ReflectionClass( CacheInterface::class ); + foreach ( array( 'get', 'set', 'delete', 'is_throttled', 'mark_requested' ) as $method ) { + $this->assertTrue( $reflection->hasMethod( $method ), "CacheInterface missing $method" ); + } + } + + public function test_service_interface_declares_boot_and_get_plugin(): void { + $reflection = new \ReflectionClass( ServiceInterface::class ); + + $this->assertTrue( $reflection->hasMethod( 'boot' ) ); + $this->assertTrue( $reflection->hasMethod( 'get_plugin' ) ); + $this->assertSame( Plugin::class, $reflection->getMethod( 'get_plugin' )->getReturnType()->getName() ); + } +} diff --git a/tests/Data/ActivationTest.php b/tests/Data/ActivationTest.php new file mode 100644 index 0000000..8ef96da --- /dev/null +++ b/tests/Data/ActivationTest.php @@ -0,0 +1,117 @@ + 12345, + 'date' => '2024-01-02 03:04:05', + 'status' => Activation::STATUS_ACTIVATED, + 'activation_params' => array( + 'license_key' => 'KEY-1', + 'uid' => 'abc123', + 'url' => 'https://example.com', + 'version' => '1.0.0', + ), + 'install_data' => array( + 'install_public_key' => 'pk_x', + 'install_secret_key' => 'sk_x', + ), + ); + } + + public function test_empty_activation_returns_defaults(): void { + $activation = new Activation(); + + $this->assertTrue( $activation->is_empty() ); + $this->assertFalse( $activation->is_active() ); + $this->assertSame( '', $activation->install_id() ); + $this->assertSame( '', $activation->license_key() ); + $this->assertSame( '', $activation->uid() ); + $this->assertSame( '', $activation->status() ); + $this->assertSame( '', $activation->date() ); + $this->assertSame( array(), $activation->activation_params() ); + $this->assertSame( array(), $activation->install_data() ); + } + + public function test_from_array_returns_equivalent_instance(): void { + $data = $this->sampleData(); + $activation = Activation::from_array( $data ); + + $this->assertSame( $data, $activation->to_array() ); + } + + public function test_accessors_return_persisted_values(): void { + $activation = new Activation( $this->sampleData() ); + + $this->assertSame( '12345', $activation->install_id() ); + $this->assertSame( 'KEY-1', $activation->license_key() ); + $this->assertSame( 'abc123', $activation->uid() ); + $this->assertSame( Activation::STATUS_ACTIVATED, $activation->status() ); + $this->assertSame( '2024-01-02 03:04:05', $activation->date() ); + $this->assertNotEmpty( $activation->activation_params() ); + $this->assertNotEmpty( $activation->install_data() ); + } + + public function test_api_keys_built_from_install_data(): void { + $keys = ( new Activation( $this->sampleData() ) )->api_keys(); + + $this->assertSame( 'pk_x', $keys->get_public_key() ); + $this->assertSame( 'sk_x', $keys->get_secret_key() ); + $this->assertTrue( $keys->is_signable() ); + } + + public function test_api_keys_unsignable_when_install_data_missing(): void { + $keys = ( new Activation() )->api_keys(); + + $this->assertFalse( $keys->is_signable() ); + } + + public function test_has_required_keys_false_when_any_missing(): void { + $data = $this->sampleData(); + unset( $data['install_id'] ); + + $this->assertFalse( ( new Activation( $data ) )->has_required_keys() ); + } + + public function test_is_active_requires_status_activated(): void { + $data = $this->sampleData(); + $data['status'] = Activation::STATUS_DEACTIVATED; + + $this->assertFalse( ( new Activation( $data ) )->is_active() ); + } + + public function test_is_active_true_for_complete_activated_record(): void { + $this->assertTrue( ( new Activation( $this->sampleData() ) )->is_active() ); + } + + public function test_with_returns_new_instance_with_overrides(): void { + $original = new Activation( $this->sampleData() ); + $mutated = $original->with( array( 'status' => Activation::STATUS_DEACTIVATED ) ); + + $this->assertNotSame( $original, $mutated ); + $this->assertSame( Activation::STATUS_ACTIVATED, $original->status() ); + $this->assertSame( Activation::STATUS_DEACTIVATED, $mutated->status() ); + } + + public function test_with_scrubbed_license_clears_only_license_key(): void { + $activation = ( new Activation( $this->sampleData() ) )->with_scrubbed_license(); + + $this->assertSame( '', $activation->license_key() ); + $this->assertSame( 'abc123', $activation->uid() ); + $this->assertSame( '12345', $activation->install_id() ); + } + + public function test_with_scrubbed_license_is_noop_when_no_key(): void { + $activation = ( new Activation() )->with_scrubbed_license(); + + $this->assertSame( '', $activation->license_key() ); + } +} diff --git a/tests/Data/ApiKeysTest.php b/tests/Data/ApiKeysTest.php new file mode 100644 index 0000000..dc6f2ac --- /dev/null +++ b/tests/Data/ApiKeysTest.php @@ -0,0 +1,37 @@ +assertSame( 'pk_pub', $keys->get_public_key() ); + $this->assertSame( 'sk_secret', $keys->get_secret_key() ); + $this->assertTrue( $keys->is_signable() ); + } + + public function test_empty_secret_falls_back_to_public_key_fsp_mode(): void { + $keys = new ApiKeys( 'pk_pub' ); + + $this->assertSame( 'pk_pub', $keys->get_public_key() ); + $this->assertSame( 'pk_pub', $keys->get_secret_key() ); + $this->assertTrue( $keys->is_signable() ); + } + + public function test_is_signable_false_when_public_key_empty(): void { + $keys = new ApiKeys( '', 'sk' ); + $this->assertFalse( $keys->is_signable() ); + } + + public function test_is_signable_false_when_both_empty(): void { + $keys = new ApiKeys( '', '' ); + $this->assertFalse( $keys->is_signable() ); + } +} diff --git a/tests/Data/PluginTest.php b/tests/Data/PluginTest.php new file mode 100644 index 0000000..6bb1190 --- /dev/null +++ b/tests/Data/PluginTest.php @@ -0,0 +1,76 @@ +assertSame( 100, $plugin->get_id() ); + $this->assertSame( '', $plugin->get_slug() ); + $this->assertSame( '', $plugin->get_main_file() ); + $this->assertSame( '', $plugin->get_public_key() ); + $this->assertFalse( $plugin->is_premium() ); + $this->assertFalse( $plugin->has_addons() ); + } + + public function test_accessors_return_constructor_args(): void { + $plugin = new Plugin( + 42, + array( + 'slug' => 'duck', + 'main_file' => '/abs/main.php', + 'public_key' => 'pk_abc', + 'is_premium' => true, + 'has_addons' => true, + ) + ); + + $this->assertSame( 42, $plugin->get_id() ); + $this->assertSame( 'duck', $plugin->get_slug() ); + $this->assertSame( '/abs/main.php', $plugin->get_main_file() ); + $this->assertSame( 'pk_abc', $plugin->get_public_key() ); + $this->assertTrue( $plugin->is_premium() ); + $this->assertTrue( $plugin->has_addons() ); + } + + public function test_get_data_delegates_to_get_plugin_data(): void { + Functions\stubs( + array( + 'get_plugin_data' => array( + 'Name' => 'Duck', + 'Version' => '1.2.3', + ), + ) + ); + + $plugin = new Plugin( 1, array( 'main_file' => '/x.php' ) ); + $data = $plugin->get_data(); + + $this->assertSame( 'Duck', $data['Name'] ); + $this->assertSame( '1.2.3', $data['Version'] ); + } + + public function test_get_data_is_cached_on_instance(): void { + $calls = 0; + Functions\when( 'get_plugin_data' )->alias( + static function () use ( &$calls ) { + $calls++; + return array( 'Name' => 'X' ); + } + ); + + $plugin = new Plugin( 1, array( 'main_file' => '/x.php' ) ); + $plugin->get_data(); + $plugin->get_data(); + + $this->assertSame( 1, $calls ); + } +} diff --git a/tests/Exceptions/FreemiusExceptionTest.php b/tests/Exceptions/FreemiusExceptionTest.php new file mode 100644 index 0000000..d1ecff9 --- /dev/null +++ b/tests/Exceptions/FreemiusExceptionTest.php @@ -0,0 +1,19 @@ +assertInstanceOf( \Exception::class, $exception ); + $this->assertSame( 'boom', $exception->getMessage() ); + $this->assertSame( 42, $exception->getCode() ); + } +} diff --git a/tests/FreemiusTest.php b/tests/FreemiusTest.php new file mode 100644 index 0000000..283d2af --- /dev/null +++ b/tests/FreemiusTest.php @@ -0,0 +1,61 @@ +justReturn( true ); + Functions\when( 'add_filter' )->justReturn( true ); + } + + public function test_get_instance_returns_same_instance_for_same_id(): void { + $args = array( + 'slug' => 'duck', + 'public_key' => 'pk', + ); + + $a = Freemius::get_instance( 9001, $args ); + $b = Freemius::get_instance( 9001 ); + + $this->assertSame( $a, $b ); + } + + public function test_get_instance_returns_distinct_instances_per_id(): void { + $a = Freemius::get_instance( 8001, array() ); + $b = Freemius::get_instance( 8002, array() ); + + $this->assertNotSame( $a, $b ); + } + + public function test_accessors_return_collaborator_types(): void { + $f = Freemius::get_instance( 7001, array() ); + + $this->assertInstanceOf( Plugin::class, $f->plugin() ); + $this->assertInstanceOf( License::class, $f->license() ); + $this->assertInstanceOf( Update::class, $f->update() ); + $this->assertInstanceOf( Addon::class, $f->addon() ); + } + + public function test_boot_is_idempotent(): void { + $f = Freemius::get_instance( 6001, array() ); + + // First boot ran inside get_instance; calling again must not throw. + $f->boot(); + $f->boot(); + + $this->addToAssertionCount( 1 ); + } +} diff --git a/tests/Services/AbstractServiceTest.php b/tests/Services/AbstractServiceTest.php new file mode 100644 index 0000000..62b74cd --- /dev/null +++ b/tests/Services/AbstractServiceTest.php @@ -0,0 +1,27 @@ +assertSame( $plugin, $service->get_plugin() ); + } + + public function test_default_boot_is_noop(): void { + $service = new class( new Plugin( 1, array() ) ) extends AbstractService {}; + + $service->boot(); + $this->addToAssertionCount( 1 ); // No-op: success is "did not throw". + } +} diff --git a/tests/Services/AddonTest.php b/tests/Services/AddonTest.php new file mode 100644 index 0000000..bbedd93 --- /dev/null +++ b/tests/Services/AddonTest.php @@ -0,0 +1,145 @@ + $has_addons ) ); + } + + public function test_returns_empty_when_plugin_has_no_addons(): void { + $addon = new Addon( + $this->plugin( false ), + $this->createMock( CacheInterface::class ), + $this->createMock( ApiFactory::class ) + ); + + $this->assertSame( array(), $addon->get_addons() ); + } + + public function test_returns_cached_addons_when_available(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->with( 'addons' )->willReturn( + array( array( 'id' => 7 ) ) + ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->expects( $this->never() )->method( 'make_for_plugin' ); + + $result = ( new Addon( $this->plugin(), $cache, $factory ) )->get_addons(); + + $this->assertSame( array( array( 'id' => 7 ) ), $result ); + } + + public function test_force_bypasses_cache_and_persists_formatted_result(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'is_throttled' )->willReturn( false ); + $cache->expects( $this->never() )->method( 'get' ); + $cache->expects( $this->once() ) + ->method( 'set' ) + ->with( + 'addons', + $this->callback( + static function ( array $value ): bool { + return isset( $value[0]['link'], $value[0]['is_premium'] ) + && 'https://checkout.freemius.com/plugin/77' === $value[0]['link'] + && true === $value[0]['is_premium']; + } + ), + DAY_IN_SECONDS + ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->method( 'get' )->willReturn( + array( + 'plugins' => array( + array( + 'id' => 77, + 'is_pricing_visible' => true, + ), + ), + ) + ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_for_plugin' )->willReturn( $api ); + + Filters\expectApplied( 'duckdev_freemius_format_addon_data' )->andReturnFirstArg(); + + $result = ( new Addon( $this->plugin(), $cache, $factory ) )->get_addons( true ); + + $this->assertCount( 1, $result ); + $this->assertTrue( $result[0]['is_premium'] ); + } + + public function test_returns_empty_array_on_api_error(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->willReturn( false ); + $cache->method( 'is_throttled' )->willReturn( false ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->method( 'get' )->willReturn( new WP_Error( 'fail', 'no' ) ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_for_plugin' )->willReturn( $api ); + + $this->assertSame( array(), ( new Addon( $this->plugin(), $cache, $factory ) )->get_addons() ); + } + + public function test_throttled_request_returns_empty_array_and_does_not_call_api(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->willReturn( false ); + $cache->method( 'is_throttled' )->with( 'addons_check' )->willReturn( true ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->expects( $this->never() )->method( 'get' ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_for_plugin' )->willReturn( $api ); + + $this->assertSame( array(), ( new Addon( $this->plugin(), $cache, $factory ) )->get_addons() ); + } + + public function test_mark_requested_called_after_request(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->willReturn( false ); + $cache->method( 'is_throttled' )->willReturn( false ); + $cache->expects( $this->once() )->method( 'mark_requested' )->with( 'addons_check' ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->method( 'get' )->willReturn( array( 'plugins' => array( array( 'id' => 1 ) ) ) ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_for_plugin' )->willReturn( $api ); + + Filters\expectApplied( 'duckdev_freemius_format_addon_data' )->andReturnFirstArg(); + + ( new Addon( $this->plugin(), $cache, $factory ) )->get_addons( true ); + } + + public function test_empty_plugins_array_is_returned_unchanged(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->willReturn( false ); + $cache->method( 'is_throttled' )->willReturn( false ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->method( 'get' )->willReturn( array() ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_for_plugin' )->willReturn( $api ); + + $this->assertSame( array(), ( new Addon( $this->plugin(), $cache, $factory ) )->get_addons( true ) ); + } +} diff --git a/tests/Services/LicenseTest.php b/tests/Services/LicenseTest.php new file mode 100644 index 0000000..6e33b67 --- /dev/null +++ b/tests/Services/LicenseTest.php @@ -0,0 +1,305 @@ +getMockBuilder( Plugin::class ) + ->setConstructorArgs( array( 1, array( 'is_premium' => $premium ) ) ) + ->onlyMethods( array( 'get_data' ) ) + ->getMock(); + $plugin->method( 'get_data' )->willReturn( array( 'Version' => '1.0.0' ) ); + + return $plugin; + } + + private function license( + Plugin $plugin, + ActivationRepository $repo, + ApiFactory $factory, + ?SiteIdentity $site = null + ): License { + if ( null === $site ) { + $site = $this->createMock( SiteIdentity::class ); + $site->method( 'get_uid' )->willReturn( 'uid-current' ); + } + return new License( $plugin, $repo, $factory, $site ); + } + + public function test_activate_rejects_empty_key(): void { + $license = $this->license( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $this->createMock( ApiFactory::class ) + ); + + $result = $license->activate( '' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'empty_activation_key', $result->get_error_code() ); + } + + public function test_activate_rejects_non_premium_plugin(): void { + $license = $this->license( + $this->plugin( false ), + $this->createMock( ActivationRepository::class ), + $this->createMock( ApiFactory::class ) + ); + + $result = $license->activate( 'KEY' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'not_premium', $result->get_error_code() ); + } + + public function test_activate_returns_api_error_unchanged(): void { + Functions\when( 'get_site_url' )->justReturn( 'https://example.com' ); + + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( new Activation() ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->method( 'post' )->willReturn( new WP_Error( 'bad', 'denied' ) ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_public' )->willReturn( $api ); + + $result = $this->license( $this->plugin(), $repo, $factory )->activate( 'KEY' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'bad', $result->get_error_code() ); + } + + public function test_activate_persists_install_data_and_fires_action(): void { + Functions\when( 'get_site_url' )->justReturn( 'https://example.com' ); + + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( new Activation() ); + $repo->expects( $this->once() ) + ->method( 'save' ) + ->with( + 1, + $this->callback( + static function ( Activation $a ): bool { + return '999' === $a->install_id() + && Activation::STATUS_ACTIVATED === $a->status() + && 'KEY' === $a->license_key(); + } + ) + ) + ->willReturn( true ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->expects( $this->once() ) + ->method( 'post' ) + ->with( + 'activate.json', + $this->callback( + static function ( array $args ): bool { + return 'KEY' === $args['license_key'] + && 'uid-current' === $args['uid'] + && '1.0.0' === $args['version'] + && ! isset( $args['install_id'] ); + } + ) + ) + ->willReturn( + array( + 'install_id' => 999, + 'install_public_key' => 'pk', + 'install_secret_key' => 'sk', + ) + ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_public' )->with( '1', 'plugin' )->willReturn( $api ); + + Actions\expectDone( 'duckdev_freemius_license_activated' )->once(); + + $result = $this->license( $this->plugin(), $repo, $factory )->activate( 'KEY' ); + + $this->assertTrue( $result ); + } + + public function test_activate_reuses_existing_install_id(): void { + Functions\when( 'get_site_url' )->justReturn( 'https://example.com' ); + + $existing = new Activation( array( 'install_id' => 555 ) ); + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( $existing ); + $repo->method( 'save' )->willReturn( true ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->expects( $this->once() ) + ->method( 'post' ) + ->with( + 'activate.json', + $this->callback( + static function ( array $args ): bool { + return isset( $args['install_id'] ) && '555' === (string) $args['install_id']; + } + ) + ) + ->willReturn( array( 'install_id' => 555 ) ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_public' )->willReturn( $api ); + + $this->license( $this->plugin(), $repo, $factory )->activate( 'KEY' ); + } + + public function test_activate_returns_unknown_error_when_response_missing_install_id(): void { + Functions\when( 'get_site_url' )->justReturn( 'https://example.com' ); + + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( new Activation() ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->method( 'post' )->willReturn( array( 'other' => 'thing' ) ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_public' )->willReturn( $api ); + + $result = $this->license( $this->plugin(), $repo, $factory )->activate( 'KEY' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'unknown_error', $result->get_error_code() ); + } + + public function test_deactivate_rejects_empty_activation(): void { + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( new Activation() ); + + $result = $this->license( + $this->plugin(), + $repo, + $this->createMock( ApiFactory::class ) + )->deactivate(); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'invalid_activation_data', $result->get_error_code() ); + } + + public function test_deactivate_rejects_when_uid_mismatch(): void { + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( + new Activation( + array( + 'install_id' => 1, + 'status' => Activation::STATUS_ACTIVATED, + 'activation_params' => array( + 'license_key' => 'KEY', + 'uid' => 'uid-OTHER', + ), + ) + ) + ); + + $result = $this->license( + $this->plugin(), + $repo, + $this->createMock( ApiFactory::class ) + )->deactivate(); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'invalid_activation_data', $result->get_error_code() ); + } + + public function test_deactivate_persists_scrubbed_license_on_success(): void { + Functions\when( 'get_site_url' )->justReturn( 'https://example.com' ); + + $activation = new Activation( + array( + 'install_id' => 1, + 'status' => Activation::STATUS_ACTIVATED, + 'activation_params' => array( + 'license_key' => 'KEY', + 'uid' => 'uid-current', + ), + ) + ); + + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( $activation ); + $repo->expects( $this->once() ) + ->method( 'save' ) + ->with( + 1, + $this->callback( + static function ( Activation $a ): bool { + return '' === $a->license_key() + && Activation::STATUS_DEACTIVATED === $a->status(); + } + ) + ) + ->willReturn( true ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->method( 'post' )->willReturn( array( 'id' => 1 ) ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_public' )->willReturn( $api ); + + Actions\expectDone( 'duckdev_freemius_license_deactivated' )->once(); + + $result = $this->license( $this->plugin(), $repo, $factory )->deactivate(); + + $this->assertTrue( $result ); + } + + public function test_deactivate_passes_api_error_through(): void { + Functions\when( 'get_site_url' )->justReturn( 'https://example.com' ); + + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( + new Activation( + array( + 'install_id' => 1, + 'status' => Activation::STATUS_ACTIVATED, + 'activation_params' => array( + 'license_key' => 'KEY', + 'uid' => 'uid-current', + ), + ) + ) + ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->method( 'post' )->willReturn( new WP_Error( 'denied', 'no' ) ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_public' )->willReturn( $api ); + + $result = $this->license( $this->plugin(), $repo, $factory )->deactivate(); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'denied', $result->get_error_code() ); + } + + public function test_get_activation_delegates_to_repository(): void { + $expected = new Activation( array( 'install_id' => 7 ) ); + + $repo = $this->createMock( ActivationRepository::class ); + $repo->expects( $this->once() )->method( 'get' )->with( 1 )->willReturn( $expected ); + + $this->assertSame( + $expected, + $this->license( $this->plugin(), $repo, $this->createMock( ApiFactory::class ) )->get_activation() + ); + } +} diff --git a/tests/Services/UpdateTest.php b/tests/Services/UpdateTest.php new file mode 100644 index 0000000..5942c9b --- /dev/null +++ b/tests/Services/UpdateTest.php @@ -0,0 +1,470 @@ +getMockBuilder( Plugin::class ) + ->setConstructorArgs( + array( + 1, + array( + 'is_premium' => $premium, + 'slug' => 'duck', + 'main_file' => '/abs/main.php', + ), + ) + ) + ->onlyMethods( array( 'get_data' ) ) + ->getMock(); + $plugin->method( 'get_data' )->willReturn( + array( + 'Name' => 'Duck', + 'Author' => 'DuckDev', + 'Version' => '1.0.0', + ) + ); + + return $plugin; + } + + private function active_activation(): Activation { + return new Activation( + array( + 'install_id' => 1, + 'status' => Activation::STATUS_ACTIVATED, + 'activation_params' => array( + 'license_key' => 'KEY', + 'uid' => 'uid', + ), + 'install_data' => array( + 'install_public_key' => 'pk', + 'install_secret_key' => 'sk', + ), + ) + ); + } + + public function test_boot_skips_hooks_for_non_premium(): void { + Actions\expectAdded( 'upgrader_process_complete' )->never(); + Filters\expectAdded( 'plugins_api' )->never(); + Filters\expectAdded( 'site_transient_update_plugins' )->never(); + + ( new Update( + $this->plugin( false ), + $this->createMock( ActivationRepository::class ), + $this->createMock( CacheInterface::class ), + $this->createMock( ApiFactory::class ) + ) )->boot(); + } + + public function test_boot_registers_hooks_for_premium(): void { + Filters\expectAdded( 'plugins_api' )->once(); + Filters\expectAdded( 'site_transient_update_plugins' )->once(); + Actions\expectAdded( 'upgrader_process_complete' )->once(); + + ( new Update( + $this->plugin( true ), + $this->createMock( ActivationRepository::class ), + $this->createMock( CacheInterface::class ), + $this->createMock( ApiFactory::class ) + ) )->boot(); + } + + public function test_get_update_data_returns_cached_when_present(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->with( 'update_data' )->willReturn( array( 'version' => '9.9' ) ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->expects( $this->never() )->method( 'make_for_install' ); + + $result = ( new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $cache, + $factory + ) )->get_update_data(); + + $this->assertSame( array( 'version' => '9.9' ), $result ); + } + + public function test_get_update_data_fetches_when_cache_miss_and_persists_for_a_day(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->willReturn( false ); + $cache->method( 'is_throttled' )->willReturn( false ); + $cache->expects( $this->once() ) + ->method( 'set' ) + ->with( 'update_data', array( 'version' => '1.1' ), DAY_IN_SECONDS ); + + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( $this->active_activation() ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->method( 'get' )->willReturn( array( 'version' => '1.1' ) ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_for_install' )->willReturn( $api ); + + $result = ( new Update( $this->plugin(), $repo, $cache, $factory ) )->get_update_data(); + + $this->assertSame( array( 'version' => '1.1' ), $result ); + } + + public function test_get_update_data_does_not_cache_wp_error(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->willReturn( false ); + $cache->method( 'is_throttled' )->willReturn( true ); + $cache->expects( $this->never() )->method( 'set' ); + + $result = ( new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $cache, + $this->createMock( ApiFactory::class ) + ) )->get_update_data(); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'too_many_requests', $result->get_error_code() ); + } + + public function test_get_update_data_force_deletes_cache_before_fetch(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->expects( $this->once() )->method( 'delete' )->with( 'update_data' ); + $cache->method( 'get' )->willReturn( array( 'version' => '2.0' ) ); + + ( new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $cache, + $this->createMock( ApiFactory::class ) + ) )->get_update_data( true ); + } + + public function test_force_check_query_arg_bypasses_cache(): void { + $_GET['force-check'] = '1'; + + $cache = $this->createMock( CacheInterface::class ); + $cache->expects( $this->once() )->method( 'delete' )->with( 'update_data' ); + $cache->method( 'get' )->willReturn( array( 'version' => '2.0' ) ); + + ( new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $cache, + $this->createMock( ApiFactory::class ) + ) )->get_update_data(); + } + + public function test_remote_latest_errors_when_license_inactive(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->willReturn( false ); + $cache->method( 'is_throttled' )->willReturn( false ); + + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( new Activation() ); + + $result = ( new Update( + $this->plugin(), + $repo, + $cache, + $this->createMock( ApiFactory::class ) + ) )->get_update_data(); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'license_not_active', $result->get_error_code() ); + } + + public function test_get_plugin_info_caches_on_success_only(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->with( 'plugin_info' )->willReturn( false ); + $cache->expects( $this->once() ) + ->method( 'set' ) + ->with( 'plugin_info', array( 'banner_url' => 'b' ), DAY_IN_SECONDS ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->method( 'get' )->willReturn( array( 'banner_url' => 'b' ) ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_for_plugin' )->willReturn( $api ); + + ( new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $cache, + $factory + ) )->get_plugin_info(); + } + + public function test_get_plugin_info_does_not_cache_error(): void { + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->willReturn( false ); + $cache->expects( $this->never() )->method( 'set' ); + + $api = $this->createMock( ApiClientInterface::class ); + $api->method( 'get' )->willReturn( new WP_Error( 'x', 'y' ) ); + + $factory = $this->createMock( ApiFactory::class ); + $factory->method( 'make_for_plugin' )->willReturn( $api ); + + $result = ( new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $cache, + $factory + ) )->get_plugin_info(); + + $this->assertInstanceOf( WP_Error::class, $result ); + } + + public function test_purge_plugin_clears_cache_only_for_matching_plugin(): void { + Functions\when( 'plugin_basename' )->justReturn( 'duck/duck.php' ); + + $cache = $this->createMock( CacheInterface::class ); + $cache->expects( $this->once() )->method( 'delete' )->with( 'update_data' ); + + ( new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $cache, + $this->createMock( ApiFactory::class ) + ) )->purge_plugin( + null, + array( + 'action' => 'update', + 'type' => 'plugin', + 'plugins' => array( 'duck/duck.php' ), + ) + ); + } + + public function test_purge_plugin_noop_for_other_action(): void { + Functions\when( 'plugin_basename' )->justReturn( 'duck/duck.php' ); + + $cache = $this->createMock( CacheInterface::class ); + $cache->expects( $this->never() )->method( 'delete' ); + + ( new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $cache, + $this->createMock( ApiFactory::class ) + ) )->purge_plugin( + null, + array( + 'action' => 'install', + 'type' => 'plugin', + 'plugins' => array( 'duck/duck.php' ), + ) + ); + } + + public function test_purge_plugin_noop_when_other_plugin_updated(): void { + Functions\when( 'plugin_basename' )->justReturn( 'duck/duck.php' ); + + $cache = $this->createMock( CacheInterface::class ); + $cache->expects( $this->never() )->method( 'delete' ); + + ( new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $cache, + $this->createMock( ApiFactory::class ) + ) )->purge_plugin( + null, + array( + 'action' => 'update', + 'type' => 'plugin', + 'plugins' => array( 'other/other.php' ), + ) + ); + } + + public function test_plugins_api_filter_passes_through_for_other_action(): void { + $update = new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $this->createMock( CacheInterface::class ), + $this->createMock( ApiFactory::class ) + ); + + $args = (object) array( 'slug' => 'duck' ); + $input = (object) array( 'untouched' => true ); + + $this->assertSame( $input, $update->plugins_api_filter( $input, 'other', $args ) ); + } + + public function test_plugins_api_filter_passes_through_for_other_slug(): void { + $update = new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $this->createMock( CacheInterface::class ), + $this->createMock( ApiFactory::class ) + ); + + $args = (object) array( 'slug' => 'other' ); + + $this->assertFalse( $update->plugins_api_filter( false, 'plugin_information', $args ) ); + } + + public function test_plugins_api_filter_passes_through_when_inactive(): void { + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( new Activation() ); + + $update = new Update( + $this->plugin(), + $repo, + $this->createMock( CacheInterface::class ), + $this->createMock( ApiFactory::class ) + ); + + $args = (object) array( 'slug' => 'duck' ); + + $this->assertFalse( $update->plugins_api_filter( false, 'plugin_information', $args ) ); + } + + public function test_plugins_api_filter_builds_payload_on_success(): void { + Functions\when( 'wp_parse_args' )->alias( + static function ( $a, $b ) { + return array_merge( (array) $b, (array) $a ); + } + ); + + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( $this->active_activation() ); + + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->willReturnOnConsecutiveCalls( + array( + 'version' => '2.0', + 'url' => 'https://dl', + 'updated' => '2024-01-01', + 'requires_platform_version' => '6.0', + 'requires_programming_language_version' => '7.4', + 'tested_up_to_version' => '6.5', + ), + array( + 'banner_url' => 'b1', + 'card_banner_url' => 'b2', + 'description' => 'desc', + ) + ); + + $update = new Update( + $this->plugin(), + $repo, + $cache, + $this->createMock( ApiFactory::class ) + ); + + $args = (object) array( 'slug' => 'duck' ); + $result = $update->plugins_api_filter( false, 'plugin_information', $args ); + + $this->assertSame( 'Duck', $result->name ); + $this->assertSame( '2.0', $result->version ); + $this->assertSame( 'https://dl', $result->download_link ); + $this->assertSame( '2024-01-01', $result->last_updated ); + $this->assertSame( 'b1', $result->banners['high'] ); + $this->assertSame( 'desc', $result->sections['description'] ); + } + + public function test_plugin_updates_transient_skips_when_no_checked(): void { + $update = new Update( + $this->plugin(), + $this->createMock( ActivationRepository::class ), + $this->createMock( CacheInterface::class ), + $this->createMock( ApiFactory::class ) + ); + + $transient = (object) array(); + + $this->assertSame( $transient, $update->plugin_updates_transient( $transient ) ); + } + + public function test_plugin_updates_transient_injects_response_when_newer_compatible(): void { + Functions\when( 'plugin_basename' )->justReturn( 'duck/duck.php' ); + Functions\when( 'get_bloginfo' )->justReturn( '6.5' ); + + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( $this->active_activation() ); + + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->willReturn( + array( + 'version' => '2.0', + 'url' => 'https://dl', + 'requires_platform_version' => '5.0', + 'requires_programming_language_version' => '7.0', + ) + ); + + $update = new Update( + $this->plugin(), + $repo, + $cache, + $this->createMock( ApiFactory::class ) + ); + + $transient = (object) array( + 'checked' => array( 'duck/duck.php' => '1.0.0' ), + 'response' => array(), + ); + $result = $update->plugin_updates_transient( $transient ); + + $this->assertArrayHasKey( 'duck/duck.php', $result->response ); + $this->assertSame( '2.0', $result->response['duck/duck.php']->new_version ); + } + + public function test_plugin_updates_transient_skips_when_not_newer(): void { + Functions\when( 'plugin_basename' )->justReturn( 'duck/duck.php' ); + Functions\when( 'get_bloginfo' )->justReturn( '6.5' ); + + $repo = $this->createMock( ActivationRepository::class ); + $repo->method( 'get' )->willReturn( $this->active_activation() ); + + $cache = $this->createMock( CacheInterface::class ); + $cache->method( 'get' )->willReturn( + array( + 'version' => '1.0.0', + 'url' => 'https://dl', + 'requires_platform_version' => '5.0', + 'requires_programming_language_version' => '7.0', + ) + ); + + $update = new Update( + $this->plugin(), + $repo, + $cache, + $this->createMock( ApiFactory::class ) + ); + + $transient = (object) array( + 'checked' => array( 'duck/duck.php' => '1.0.0' ), + 'response' => array(), + ); + $result = $update->plugin_updates_transient( $transient ); + + $this->assertSame( array(), $result->response ); + } +} diff --git a/tests/Storage/ActivationRepositoryTest.php b/tests/Storage/ActivationRepositoryTest.php new file mode 100644 index 0000000..9d6dc7b --- /dev/null +++ b/tests/Storage/ActivationRepositoryTest.php @@ -0,0 +1,81 @@ +once() + ->with( ActivationRepository::OPTION_KEY, array() ) + ->andReturn( array() ); + + $activation = ( new ActivationRepository() )->get( 1 ); + + $this->assertInstanceOf( Activation::class, $activation ); + $this->assertTrue( $activation->is_empty() ); + } + + public function test_get_returns_activation_keyed_by_plugin_id(): void { + Functions\expect( 'get_option' ) + ->once() + ->andReturn( + array( + 42 => array( 'install_id' => 99 ), + ) + ); + + $activation = ( new ActivationRepository() )->get( 42 ); + + $this->assertSame( '99', $activation->install_id() ); + } + + public function test_save_writes_full_map_with_new_entry(): void { + Functions\expect( 'get_option' ) + ->once() + ->andReturn( array( 1 => array( 'install_id' => 1 ) ) ); + + Functions\expect( 'update_option' ) + ->once() + ->with( + ActivationRepository::OPTION_KEY, + array( + 1 => array( 'install_id' => 1 ), + 2 => array( 'install_id' => 2 ), + ) + ) + ->andReturn( true ); + + $result = ( new ActivationRepository() )->save( 2, new Activation( array( 'install_id' => 2 ) ) ); + + $this->assertTrue( $result ); + } + + public function test_clear_unsets_only_target_plugin_entry(): void { + Functions\expect( 'get_option' ) + ->once() + ->andReturn( + array( + 1 => array( 'a' => 1 ), + 2 => array( 'b' => 2 ), + ) + ); + + Functions\expect( 'update_option' ) + ->once() + ->with( + ActivationRepository::OPTION_KEY, + array( 2 => array( 'b' => 2 ) ) + ) + ->andReturn( true ); + + $this->assertTrue( ( new ActivationRepository() )->clear( 1 ) ); + } +} diff --git a/tests/Storage/TransientCacheTest.php b/tests/Storage/TransientCacheTest.php new file mode 100644 index 0000000..d2a0b69 --- /dev/null +++ b/tests/Storage/TransientCacheTest.php @@ -0,0 +1,83 @@ +once() + ->with( 'duckdev_freemius_7_my_key' ) + ->andReturn( 'cached' ); + + $this->assertSame( 'cached', ( new TransientCache( $this->plugin() ) )->get( 'my_key' ) ); + } + + public function test_set_passes_key_value_and_expiration(): void { + Functions\expect( 'set_transient' ) + ->once() + ->with( 'duckdev_freemius_7_foo', 'bar', 120 ) + ->andReturn( true ); + + $this->assertTrue( ( new TransientCache( $this->plugin() ) )->set( 'foo', 'bar', 120 ) ); + } + + public function test_delete_returns_bool_from_delete_transient(): void { + Functions\expect( 'delete_transient' ) + ->once() + ->with( 'duckdev_freemius_7_foo' ) + ->andReturn( false ); + + $this->assertFalse( ( new TransientCache( $this->plugin() ) )->delete( 'foo' ) ); + } + + public function test_is_throttled_false_when_key_missing(): void { + Functions\expect( 'get_transient' )->once()->andReturn( false ); + + $this->assertFalse( ( new TransientCache( $this->plugin() ) )->is_throttled( 'k' ) ); + } + + public function test_is_throttled_true_when_value_present(): void { + Functions\expect( 'get_transient' )->once()->andReturn( time() ); + + $this->assertTrue( ( new TransientCache( $this->plugin() ) )->is_throttled( 'k' ) ); + } + + public function test_mark_requested_writes_with_default_window(): void { + Functions\expect( 'set_transient' ) + ->once() + ->with( 'duckdev_freemius_7_k', \Mockery::type( 'int' ), 5 * MINUTE_IN_SECONDS ) + ->andReturn( true ); + + $this->assertTrue( ( new TransientCache( $this->plugin() ) )->mark_requested( 'k' ) ); + } + + public function test_custom_throttle_window_is_honoured(): void { + Functions\expect( 'set_transient' ) + ->once() + ->with( \Mockery::any(), \Mockery::any(), 30 ) + ->andReturn( true ); + + ( new TransientCache( $this->plugin(), 30 ) )->mark_requested( 'k' ); + } + + public function test_non_positive_window_falls_back_to_default(): void { + Functions\expect( 'set_transient' ) + ->once() + ->with( \Mockery::any(), \Mockery::any(), 5 * MINUTE_IN_SECONDS ) + ->andReturn( true ); + + ( new TransientCache( $this->plugin(), 0 ) )->mark_requested( 'k' ); + } +} diff --git a/tests/Stubs/WP_Error.php b/tests/Stubs/WP_Error.php new file mode 100644 index 0000000..88dc358 --- /dev/null +++ b/tests/Stubs/WP_Error.php @@ -0,0 +1,37 @@ +code = $code; + $this->message = $message; + $this->data = $data; + } + + public function get_error_code(): string { + return $this->code; + } + + public function get_error_message(): string { + return $this->message; + } + + public function get_error_data() { + return $this->data; + } + } +} diff --git a/tests/Support/SiteIdentityTest.php b/tests/Support/SiteIdentityTest.php new file mode 100644 index 0000000..c9cc675 --- /dev/null +++ b/tests/Support/SiteIdentityTest.php @@ -0,0 +1,57 @@ +justReturn( 3 ); + Functions\when( 'get_site_url' )->justReturn( 'https://example.com/sub' ); + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + + $expected = md5( 'example.com-3-/sub' ); + + $this->assertSame( $expected, ( new SiteIdentity() )->get_uid() ); + } + + public function test_uid_omits_path_when_absent(): void { + Functions\when( 'get_current_blog_id' )->justReturn( 1 ); + Functions\when( 'get_site_url' )->justReturn( 'https://example.com' ); + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + + $expected = md5( 'example.com-1' ); + + $this->assertSame( $expected, ( new SiteIdentity() )->get_uid() ); + } + + public function test_uid_returns_32_char_hex(): void { + Functions\when( 'get_current_blog_id' )->justReturn( 1 ); + Functions\when( 'get_site_url' )->justReturn( 'https://example.com' ); + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + + $uid = ( new SiteIdentity() )->get_uid(); + + $this->assertMatchesRegularExpression( '/^[a-f0-9]{32}$/', $uid ); + } + + public function test_uid_differs_per_blog_for_multisite(): void { + Functions\when( 'get_site_url' )->alias( + static fn( $id ) => 'https://example.com/sub' . $id + ); + Functions\when( 'wp_parse_url' )->alias( 'parse_url' ); + + Functions\when( 'get_current_blog_id' )->justReturn( 2 ); + $uid_a = ( new SiteIdentity() )->get_uid(); + + Functions\when( 'get_current_blog_id' )->justReturn( 3 ); + $uid_b = ( new SiteIdentity() )->get_uid(); + + $this->assertNotSame( $uid_a, $uid_b ); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..8c08546 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,41 @@ +alias( + static function ( $thing ) { + return $thing instanceof \WP_Error; + } + ); + + Functions\when( '__' )->returnArg( 1 ); + Functions\when( 'wp_json_encode' )->alias( + static function ( $data ) { + return json_encode( $data ); + } + ); + } + + protected function tearDown(): void { + Monkey\tearDown(); + parent::tearDown(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..d4a1356 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,38 @@ + Date: Wed, 3 Jun 2026 10:12:12 +0530 Subject: [PATCH 4/6] Improve: Add CI for dev branch --- .github/workflows/phpcs.yml | 2 +- .github/workflows/tests.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml index 34f1be6..b340740 100644 --- a/.github/workflows/phpcs.yml +++ b/.github/workflows/phpcs.yml @@ -2,7 +2,7 @@ name: PHPCS on: push: - branches: [main] + branches: [main,dev] pull_request: jobs: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b81ce87..4609cf2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ name: Tests on: push: - branches: [main] + branches: [main,dev] pull_request: jobs: From 615f254acfc0fc8d2937660e5226fa01156edf9b Mon Sep 17 00:00:00 2001 From: Joel James Date: Wed, 3 Jun 2026 10:18:07 +0530 Subject: [PATCH 5/6] Fix: Rename for PSR-4 --- src/{freemius.php => Freemius.php} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{freemius.php => Freemius.php} (100%) diff --git a/src/freemius.php b/src/Freemius.php similarity index 100% rename from src/freemius.php rename to src/Freemius.php From 85670584f40ffb368228dff91142add2b1c89ec2 Mon Sep 17 00:00:00 2001 From: Joel James Date: Wed, 3 Jun 2026 10:25:00 +0530 Subject: [PATCH 6/6] New: Add release action --- .github/workflows/release.yml | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b512279 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Create GitHub Release + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate release notes + id: notes + run: | + previous_tag=$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true) + { + echo "notes<> "$GITHUB_OUTPUT" + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + body: ${{ steps.notes.outputs.notes }} + draft: false + prerelease: ${{ contains(github.ref_name, '-') }}