<?php
namespace Simple_History;
/**
* Class with information and data for a plus plugin.
*/
class AddOn_Plugin {
/**
* Id of plugin, eg basenamed path + index file: "simple-history-plus-woocommerce/index.php".
*
* @var string
*/
public $id;
/**
* Slug of plugin, eg "simple-history-plus-woocommerce".
*
* @var string
*/
public $slug;
/**
* Current version of plugin, eg "1.0.0".
*
* @var string
*/
public $version;
/**
* Name of plugin, eg "Simple History Plus for WooCommerce".
*
* @var string
*/
public $name;
/**
* ID of product that this plugin is for.
* Will be used to check that the entered licence key is for this product.
* For example History PLUS has id 105282.
*
* @var int
*/
public $product_id;
private const OPTION_PREFIX = 'simple_history_plusplugin_';
/**
* Default values for the licence message option.
*
* @var array<string,mixed>
*/
private array $message_defaults = [
'key' => null,
'key_activated' => false,
'key_instance_id' => null,
'key_created_at' => null,
'key_expires_at' => null,
'product_id' => null,
'product_name' => null,
'customer_name' => null,
'customer_email' => null,
// Refreshed by every update check, see update_license_status_from_response().
// Absent from options written by older versions, so read them with ??.
'license_status' => null,
'license_valid' => null,
'license_error' => '',
'license_activation_limit' => null,
'license_activation_usage' => null,
'license_checked_at' => null,
];
/**
* @param string $id Id of plugin, eg basenamed path + index file: "simple-history-plus-woocommerce/index.php".
* @param string $slug Slug of plugin, eg "simple-history-plus-woocommerce".
* @param string $version Current version of plugin, eg "1.0.0".
* @param string $name Name of plugin, eg "Simple History Plus for WooCommerce".
* @param int|null $product_id ID of product that this plugin is for.
*/
public function __construct( $id, $slug, $version, $name = '', $product_id = null ) {
$this->id = $id;
$this->slug = $slug;
$this->version = $version;
$this->name = $name;
$this->product_id = $product_id;
}
/**
* Get the licence key for this plugin.
*
* @return mixed|null Licence key, or null if no key.
*/
public function get_license_key() {
$message = $this->get_license_message();
return $message['key'] ?? null;
}
/**
* Get the licence message for this plugin.
*
* @return array<string,mixed> Licence message.
*/
public function get_license_message() {
$stored = get_option( $this->get_license_message_option_name(), $this->message_defaults );
if ( is_array( $stored ) ) {
/** @var array<string,mixed> */
return wp_parse_args( $stored, $this->message_defaults );
}
return $this->message_defaults;
}
/**
* Set the licence message for this plugin.
*
* @param array<string,mixed> $new_licence_message Licence message.
* @return bool True if option was updated, false if not.
*/
public function set_licence_message( $new_licence_message ) {
return update_option( $this->get_license_message_option_name(), $new_licence_message );
}
/**
* Merge the license object from an update-check response into the stored license message.
*
* The activation response is stored once and never refreshed by Lemon Squeezy,
* so a renewed subscription looks expired locally. The update endpoint on
* simple-history.com now returns the key's current status and expiry on every
* check; this stores it next to the activation data.
*
* Ignores anything that is not a well-formed license object, and never
* creates a license for a site that has none: a missing or null object
* means "could not check", and the previous state must survive.
*
* @param mixed $license Decoded `license` array from the update response.
* @return bool True when the option was written.
*/
public function update_license_status_from_response( $license ) {
if ( ! is_array( $license ) ) {
return false;
}
// checked_at is the marker that a real check happened; without it the
// object is not from our endpoint.
if ( ! isset( $license['checked_at'] ) || ! is_string( $license['checked_at'] ) ) {
return false;
}
$message = $this->get_license_message();
if ( empty( $message['key'] ) || empty( $message['key_activated'] ) ) {
return false;
}
$status = $license['status'] ?? null;
if ( $status !== null && ! is_string( $status ) ) {
return false;
}
$expires_at = $license['expires_at'] ?? null;
if ( $expires_at !== null && ! is_string( $expires_at ) ) {
$expires_at = null;
}
// An empty string means no expiry, same as a missing or null value.
if ( $expires_at === '' ) {
$expires_at = null;
}
// A string that strtotime() cannot parse is worse than no date at
// all: storing it would make get_license_state() silently treat the
// license as having no expiry. Reject the whole response instead.
if ( is_string( $expires_at ) && strtotime( $expires_at ) === false ) {
return false;
}
$license_error = isset( $license['error'] ) && is_string( $license['error'] ) ? $license['error'] : '';
// Cap server-supplied strings before storing: they end up in an
// autoloaded option and this is the only validation they get.
$message['key_expires_at'] = $this->cap_string_length( $expires_at );
$message['license_status'] = $this->cap_string_length( $status );
$message['license_valid'] = ! empty( $license['valid'] );
$message['license_error'] = $this->cap_string_length( $license_error );
$message['license_activation_limit'] = isset( $license['activation_limit'] ) && is_int( $license['activation_limit'] ) ? $license['activation_limit'] : null;
$message['license_activation_usage'] = isset( $license['activation_usage'] ) && is_int( $license['activation_usage'] ) ? $license['activation_usage'] : null;
$message['license_checked_at'] = $this->cap_string_length( $license['checked_at'] );
$this->set_licence_message( $message );
return true;
}
/**
* Record that the update endpoint answered successfully without a license object.
*
* Lemon Squeezy was not reachable for details on this check, but the
* update endpoint itself accepted the key. That is not enough to learn a
* renewal's new expiry, but it is enough to clear a stored problem
* verdict from an earlier check (expired, disabled, or unrecognised) so
* a renewed subscriber is not told to renew until the next check fills
* in the details. A stored active/inactive verdict, or no verdict at
* all, is left untouched.
*
* @return bool True when the option was written.
*/
public function note_valid_key_without_details() {
$message = $this->get_license_message();
if ( empty( $message['key'] ) || empty( $message['key_activated'] ) ) {
return false;
}
$status = isset( $message['license_status'] ) && is_string( $message['license_status'] ) ? $message['license_status'] : null;
$checked_at = isset( $message['license_checked_at'] ) && is_string( $message['license_checked_at'] ) ? $message['license_checked_at'] : null;
$is_stored_problem = $status === 'expired' || $status === 'disabled' || ( $status === null && $checked_at !== null );
if ( ! $is_stored_problem ) {
return false;
}
$message['license_status'] = 'active';
$message['license_error'] = '';
$message['license_checked_at'] = gmdate( 'Y-m-d\TH:i:s\Z' );
return $this->set_licence_message( $message );
}
/**
* Derive one license state from the stored license message.
*
* Prefers the status from the last update check. When no check has run
* since activation, there is no way to tell an expired key from one that
* has since been renewed, so it is reported as active rather than
* guessed as expired from the stale activation-time date.
*
* @return array{
* state: string,
* source: string,
* expires_at: ?string,
* expires_timestamp: ?int,
* is_lifetime: bool,
* checked_at: ?string,
* error: string,
* activation_limit: ?int,
* activation_usage: ?int
* } state is one of none, active, inactive, expired, disabled, invalid. source is one of none, activation, update_check.
*/
public function get_license_state() {
$message = $this->get_license_message();
$state = [
'state' => 'none',
'source' => 'none',
'expires_at' => null,
'expires_timestamp' => null,
'is_lifetime' => false,
'checked_at' => null,
'error' => '',
'activation_limit' => null,
'activation_usage' => null,
];
if ( empty( $message['key'] ) || empty( $message['key_activated'] ) ) {
return $state;
}
$expires_at = isset( $message['key_expires_at'] ) && is_string( $message['key_expires_at'] ) ? $message['key_expires_at'] : null;
$expires_timestamp = $expires_at !== null ? strtotime( $expires_at ) : false;
if ( $expires_timestamp === false ) {
$expires_timestamp = null;
$expires_at = null;
}
$state['expires_at'] = $expires_at;
$state['expires_timestamp'] = $expires_timestamp;
$state['error'] = isset( $message['license_error'] ) && is_string( $message['license_error'] ) ? $message['license_error'] : '';
$state['activation_limit'] = isset( $message['license_activation_limit'] ) && is_int( $message['license_activation_limit'] ) ? $message['license_activation_limit'] : null;
$state['activation_usage'] = isset( $message['license_activation_usage'] ) && is_int( $message['license_activation_usage'] ) ? $message['license_activation_usage'] : null;
$checked_at = isset( $message['license_checked_at'] ) && is_string( $message['license_checked_at'] ) ? $message['license_checked_at'] : null;
if ( $checked_at === null ) {
// Never checked: we cannot tell an expired key from one that has
// since been renewed, so treat it as active until a real check
// says otherwise.
$state['source'] = 'activation';
$state['state'] = 'active';
} else {
$state['source'] = 'update_check';
$state['checked_at'] = $checked_at;
$status = isset( $message['license_status'] ) && is_string( $message['license_status'] ) ? $message['license_status'] : null;
if ( $status === 'active' ) {
$state['state'] = 'active';
} elseif ( $status === 'inactive' ) {
$state['state'] = 'inactive';
} elseif ( $status === 'expired' ) {
$state['state'] = 'expired';
} elseif ( $status === 'disabled' ) {
$state['state'] = 'disabled';
} else {
$state['state'] = 'invalid';
}
}
$state['is_lifetime'] = $state['state'] === 'active' && $expires_at === null;
return $state;
}
/**
* One-sentence, plain-text description of the license state for the Licenses tab.
*
* An activation-source state is always `active` (see get_license_state()),
* but its expiry date is the stale one recorded at activation time. When
* that date has already passed, we still don't know whether the license
* was renewed since, so this makes no claim about expiry: it returns an
* empty string rather than an unverified "valid until" date.
*
* @param array<string,mixed>|null $license_state Pass an already-computed state to avoid recomputing it. Defaults to computing it.
* @return string Empty when there is no license, or when the only evidence about expiry is a stale, already-past activation-time date.
*/
public function get_license_state_description( ?array $license_state = null ) {
$state = $license_state ?? $this->get_license_state();
$date = $state['expires_timestamp'] !== null ? wp_date( get_option( 'date_format' ), $state['expires_timestamp'] ) : '';
if ( $state['source'] === 'activation' && $state['expires_timestamp'] !== null ) {
if ( $state['expires_timestamp'] < time() ) {
return '';
}
/* translators: %s: date */
return sprintf( __( 'Valid until %s.', 'simple-history' ), $date );
}
$usage = '';
if ( $state['activation_limit'] !== null && $state['activation_usage'] !== null ) {
$usage = sprintf(
/* translators: 1: number of sites the key is activated on, 2: number of sites the key allows. */
__( 'Activated on %1$d of %2$d sites.', 'simple-history' ),
$state['activation_usage'],
$state['activation_limit']
);
}
switch ( $state['state'] ) {
case 'active':
if ( $state['is_lifetime'] ) {
$text = __( 'Lifetime license.', 'simple-history' );
} else {
/* translators: %s: date */
$text = sprintf( __( 'Valid until %s.', 'simple-history' ), $date );
}
break;
case 'inactive':
$text = __( 'The key is valid but not activated on any site. Activate it again to tie it to this site.', 'simple-history' );
break;
case 'expired':
/* translators: %s: date */
$text = $date !== '' ? sprintf( __( 'Expired on %s.', 'simple-history' ), $date ) : __( 'License has expired.', 'simple-history' );
break;
case 'disabled':
$text = __( 'The key has been disabled.', 'simple-history' );
break;
case 'invalid':
$text = __( 'The key was not found, check it for typos.', 'simple-history' );
break;
default:
return '';
}
return trim( $text . ' ' . $usage );
}
/**
* Cap a server-supplied value's length before it is stored.
*
* @param mixed $value Value to cap.
* @return mixed The capped string, or the value unchanged when it is not a string.
*/
private function cap_string_length( $value ) {
if ( ! is_string( $value ) ) {
return $value;
}
// wp_check_invalid_utf8() turns an invalid byte sequence into an
// empty string, which the readers of these fields already treat as
// absent.
return wp_check_invalid_utf8( substr( $value, 0, 255 ) );
}
/**
* Forget the cached update-check response for this add-on.
*
* Mirrors Plugin_Updater::$cache_key. Called on (de)activation so the next
* check asks the server instead of replaying a stale answer.
*/
private function purge_updater_cache() {
delete_transient( Plugin_Updater::get_cache_key_for_slug( $this->slug ) );
}
/**
* Get the option name for the licence message for this plugin.
*
* @return string Option name.
*/
private function get_license_message_option_name() {
return self::OPTION_PREFIX . 'message_' . $this->slug;
}
/**
* Activate a license key.
* Stores API result in option.
*
* @param string $license_key License key to activate.
* @return array<mixed>|null Array with info about key activation, or null if invalid.
*/
public function activate_license( $license_key ) {
$activation_url = add_query_arg(
array(
'license_key' => $license_key,
'instance_name' => home_url(),
),
SIMPLE_HISTORY_LICENCES_API_URL . '/activate'
);
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get
$response = wp_remote_get(
$activation_url,
array(
'sslverify' => false,
'timeout' => 3,
)
);
if (
is_wp_error( $response )
|| ( wp_remote_retrieve_response_code( $response ) !== 200 && wp_remote_retrieve_response_code( $response ) !== 400 )
|| empty( wp_remote_retrieve_body( $response ) )
) {
return [
'success' => false,
'message' => __( 'Unknown error', 'simple-history' ),
];
}
$remote_body_json = json_decode( wp_remote_retrieve_body( $response ), true );
if ( is_null( $remote_body_json ) || ! is_array( $remote_body_json ) ) {
return [
'success' => false,
'message' => __( 'Unknown error', 'simple-history' ),
];
}
// Bail when bad request.
if ( wp_remote_retrieve_response_code( $response ) === 400 ) {
$this->set_licence_message( $this->message_defaults );
$message = null;
// Get error. Can be a single field (for example when licence key is not found),
// or array of messages (for example when no licence_key exists in query string).
if ( isset( $remote_body_json['data']['errors'] ) && is_array( $remote_body_json['data']['errors'] ) ) {
$message = $remote_body_json['data']['errors'][0]['detail'];
} elseif ( isset( $remote_body_json['data']['error'] ) ) {
$message = $remote_body_json['data']['error'];
}
return [
'success' => false,
'message' => $message,
];
}
// Key was activated successfully.
$message = [
'key_activated' => true,
'key' => $remote_body_json['data']['license_key']['key'] ?? null,
'key_instance_id' => $remote_body_json['data']['instance']['id'] ?? null,
'key_created_at' => $remote_body_json['data']['instance']['created_at'] ?? null,
'key_expires_at' => $remote_body_json['data']['license_key']['expires_at'] ?? null,
'product_id' => $remote_body_json['data']['meta']['product_id'] ?? null,
'product_name' => $remote_body_json['data']['meta']['product_name'] ?? null,
'customer_name' => $remote_body_json['data']['meta']['customer_name'] ?? null,
'customer_email' => $remote_body_json['data']['meta']['customer_email'] ?? null,
];
$this->set_licence_message( $message );
$this->purge_updater_cache();
// Deactivate and bail if activation was for another product.
if ( $this->product_id && $this->product_id !== $remote_body_json['data']['meta']['product_id'] ) {
$this->deactivate_license();
return [
'success' => false,
'message' => 'The license key is not valid for this plugin.',
];
}
return [
'success' => true,
'message' => 'Licence key successfully activated.',
];
}
/**
* Deactivate a license key.
*
* @return bool|null True if deactivated, null if error.
*/
public function deactivate_license() {
$license_key = $this->get_license_key();
$licence_message = $this->get_license_message();
$instance_id = $licence_message['key_instance_id'];
$activation_url = add_query_arg(
array(
'license_key' => $license_key,
'instance_id' => $instance_id,
),
SIMPLE_HISTORY_LICENCES_API_URL . '/deactivate'
);
// phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get
$response = wp_remote_get(
$activation_url,
array(
'sslverify' => false,
'timeout' => 3,
)
);
if ( wp_remote_retrieve_response_code( $response ) === 200 ) {
$this->set_licence_message( $this->message_defaults );
$this->purge_updater_cache();
return true;
}
return null;
}
}