<?php
namespace Simple_History;
/**
* Class that handles plugin info and plugin updates for add-ons plugins.
*
* This class is instantiated once for each add-on plugin.
*/
class Plugin_Updater {
/**
* @var string
*/
public $plugin_id;
/**
* @var string
*/
public $plugin_slug;
/**
* @var string
*/
public $version;
/**
* @var string
*/
public $api_url;
/**
* @var string
*/
public $cache_key;
/**
* @var string
*/
public $cache_key_plugin_info;
/**
* @var boolean
*
* Default true.
*
* Only disable this for debugging.
*/
public $cache_allowed = true;
/**
* @param string $plugin_id The ID of the plugin.
* @param string $plugin_slug The slug of the plugin.
* @param string $version The current version of the plugin.
* @param string $api_url The API URL to the update server.
*/
public function __construct( $plugin_id, $plugin_slug, $version, $api_url ) {
$this->plugin_id = $plugin_id;
$this->plugin_slug = $plugin_slug;
$this->version = $version;
$this->api_url = $api_url;
$this->cache_key = self::get_cache_key_for_slug( $this->plugin_slug );
$this->cache_key_plugin_info = 'simple_history_updater_info_cache_' . str_replace( '-', '_', $this->plugin_slug );
add_filter( 'plugins_api', array( $this, 'on_plugins_api_handle_plugin_info' ), 20, 3 );
add_filter( 'site_transient_update_plugins', array( $this, 'site_transient_update_plugins_update' ) );
add_action( 'upgrader_process_complete', array( $this, 'purge' ), 10, 2 );
}
/**
* Build the transient key used to cache an add-on's update-check response.
*
* The single source of truth for this key, so AddOn_Plugin::purge_updater_cache()
* and tests can derive it instead of duplicating the literal.
*
* @param string $slug Plugin slug.
* @return string
*/
public static function get_cache_key_for_slug( $slug ) {
return 'simple_history_updater_cache_' . str_replace( '-', '_', $slug );
}
/**
* The add-on this updater serves, for reading and refreshing its license.
*
* @return AddOn_Plugin
*/
protected function get_addon_plugin() {
return new AddOn_Plugin(
$this->plugin_id,
$this->plugin_slug,
$this->version,
);
}
/**
* Get the license key. Normally, your plugin would have a settings page where
* you ask for and store a license key. Fetch it here.
*
* @return string
*/
protected function get_license_key() {
return $this->get_addon_plugin()->get_license_key();
}
/**
* Fetch the update info from the remote server running the Lemon Squeezy plugin.
*
* @return object|stdClass|bool
*/
public function request() {
$lsq_license_key = $this->get_license_key();
// If no licence key is set, user get no updates.
if ( ! $lsq_license_key ) {
return false;
}
$remote = get_transient( $this->cache_key );
if ( $remote !== false && $this->cache_allowed ) {
if ( $remote === 'error' ) {
return false;
}
return json_decode( $remote );
}
// Get the update data from the remote server, i.e. our own server.
$url = add_query_arg(
[
'license_key' => $lsq_license_key,
'plugin_slug' => $this->plugin_slug,
],
$this->api_url . '/update',
);
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get
$remote = wp_remote_get(
$url,
[
'timeout' => 3,
]
);
if ( is_wp_error( $remote ) || empty( wp_remote_retrieve_body( $remote ) ) ) {
// Cache errors for 10 minutes.
set_transient( $this->cache_key, 'error', MINUTE_IN_SECONDS * 10 );
return false;
}
$response_code = wp_remote_retrieve_response_code( $remote );
$payload = wp_remote_retrieve_body( $remote );
$decoded = json_decode( $payload );
// 200 is an update answer. 401 is the endpoint's answer for a key that
// is expired, disabled or unknown, and since issue 315 it carries the
// key's status too. Anything else, or a non-JSON body from an older
// server, is an error like before.
if ( ! in_array( $response_code, [ 200, 401 ], true ) || ! is_object( $decoded ) ) {
// Cache errors for 10 minutes.
set_transient( $this->cache_key, 'error', MINUTE_IN_SECONDS * 10 );
return false;
}
$this->store_license_from_payload( $decoded );
// Cache a 200 answer for an hour. Cache a 401 refusal for only 10
// minutes, the same window used for the network/decode errors above,
// so a renewed customer waits at most ten minutes for updates to
// return rather than up to an hour.
$cache_duration = $response_code === 200 ? HOUR_IN_SECONDS : MINUTE_IN_SECONDS * 10;
set_transient( $this->cache_key, $payload, $cache_duration );
return $decoded;
}
/**
* Hand the `license` object from an update response to the add-on.
*
* Older servers send no such key and newer ones send null when Lemon
* Squeezy could not be reached. Both leave the stored license details
* untouched, but a successful update answer is itself evidence the key
* is valid, so it still clears a previously stored problem verdict.
*
* @param object $decoded Decoded update response.
* @return void
*/
private function store_license_from_payload( $decoded ) {
if ( ! isset( $decoded->license ) || ! is_object( $decoded->license ) ) {
if ( ! empty( $decoded->success ) ) {
$this->get_addon_plugin()->note_valid_key_without_details();
}
return;
}
$this->get_addon_plugin()->update_license_status_from_response( (array) $decoded->license );
}
/**
* Override the WordPress request to return the correct plugin info.
*
* @see https://developer.wordpress.org/reference/hooks/plugins_api/
*
* @param false|object|array $result False if nothing is found, default WP_Error if request failed. An array of data on success.
* @param string $action The type of information being requested from the Plugin Install API.
* @param object $args Plugin API arguments.
* @return object|bool
*/
public function on_plugins_api_handle_plugin_info( $result, $action, $args ) {
// Bail if this is not about getting plugin information.
if ( $action !== 'plugin_information' ) {
return $result;
}
// Bail if it is not our plugin.
if ( $this->plugin_slug !== $args->slug ) {
return $result;
}
// Check cache/transient first.
$remote_json = get_transient( $this->cache_key_plugin_info );
if ( $remote_json !== false && $this->cache_allowed ) {
return $remote_json;
}
// Here: Get plugin info from simple-history.com.
// URLs for a plugin will be like:
// https://simple-history.com/wp-json/simple-history/v1/plugins/simple-history-extended-settings.
$api_url_base = 'https://simple-history.com/wp-json/simple-history/v1/plugins/';
$api_for_plugin = $api_url_base . $this->plugin_slug;
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get
$plugin_info_response = wp_remote_get( $api_for_plugin );
// Bail if response was not ok.
if ( is_wp_error( $plugin_info_response ) || wp_remote_retrieve_response_code( $plugin_info_response ) !== 200 || empty( wp_remote_retrieve_body( $plugin_info_response ) ) ) {
return $result;
}
$remote_json = json_decode( wp_remote_retrieve_body( $plugin_info_response ), false );
// Bail if json decode error.
if ( $remote_json === null ) {
return $result;
}
// Some things must be arrays, not objects.
$remote_json->sections = (array) $remote_json->sections;
$remote_json->tags = (array) $remote_json->tags;
$remote_json->banners = (array) $remote_json->banners;
$remote_json->contributors = (array) $remote_json->contributors;
// Make all contributors arrays, not objects.
foreach ( $remote_json->contributors as $contributor_key => $contributor_value ) {
$remote_json->contributors[ $contributor_key ] = (array) $contributor_value;
}
// Cache the result for 10 minutes.
set_transient( $this->cache_key_plugin_info, $remote_json, MINUTE_IN_SECONDS * 10 );
return $remote_json;
}
/**
* Override the WordPress request to check if an update is available.
*
* @see https://make.wordpress.org/core/2020/07/30/recommended-usage-of-the-updates-api-to-support-the-auto-updates-ui-for-plugins-and-themes-in-wordpress-5-5/
*
* @param object $transient The pre-saved, cached data for plugins.
* @return object $transient
*/
public function site_transient_update_plugins_update( $transient ) {
if ( empty( $transient->checked ) ) {
return $transient;
}
$res = (object) array(
'id' => $this->plugin_id,
'slug' => $this->plugin_slug,
'plugin' => $this->plugin_id,
'new_version' => $this->version,
'url' => '',
'package' => '',
'icons' => array(),
'banners' => array(),
'banners_rtl' => array(),
'tested' => '',
'requires_php' => '',
'compatibility' => new \stdClass(),
);
$remote = $this->request();
if (
$remote && ! empty( $remote->success ) && ! empty( $remote->update )
&& version_compare( $this->version, $remote->update->version, '<' )
) {
// Update is available for plugin.
$res->new_version = $remote->update->version;
$res->package = $remote->update->download_link;
$transient->response[ $res->plugin ] = $res;
} else {
// No update is available for plugin.
// Adding the "mock" item to the `no_update` property is required
// for the enable/disable auto-updates links to correctly appear in UI.
$transient->no_update[ $res->plugin ] = $res;
}
return $transient;
}
/**
* When the update is complete, purge the cache.
*
* @see https://developer.wordpress.org/reference/hooks/upgrader_process_complete/
*
* @param \WP_Upgrader $upgrader The WP_Upgrader instance.
* @param array $options Array of bulk item update arguments.
* @return void
*/
public function purge( $upgrader, $options ) {
if (
! $this->cache_allowed
|| $options['action'] !== 'update'
|| $options['type'] !== 'plugin'
|| empty( $options['plugins'] )
) {
return;
}
foreach ( $options['plugins'] as $plugin ) {
if ( $plugin !== $this->plugin_id ) {
continue;
}
delete_transient( $this->cache_key );
}
}
}