client = &$client;
$this->option_key = $this->client->getHookName( 'manage_license' );
$this->data_key = $this->client->getHookName( 'license' );
$this->schedule_hook = $this->client->getHookName( 'license_check_event' );
$this->redirect_on_activation = $redirect_on_activation;
// Load the license.
$this->get_license();
}
public function set_page_url( $url ): SE_License_SDK_License {
$this->page_url = esc_url_raw( $url );
return $this;
}
public function get_page_url(): string {
if ( null !== $this->page_url ) {
return $this->page_url;
}
return admin_url( 'admin.php?page=' . $this->menu_args['menu_slug'] );
}
public function set_header_message( string $message = null ): SE_License_SDK_License {
$this->header_message = $message;
return $this;
}
public function set_header_content( string $message = null ): SE_License_SDK_License {
$this->header_content = $message;
return $this;
}
public function set_manage_license_url( string $url = null ): SE_License_SDK_License {
$this->manage_license_url = $url;
return $this;
}
/**
* The store/account dashboard URL where the customer manages/downloads their
* product (set from the `store_dashboard_url` init arg), or null.
*
* @return ?string
*/
public function get_manage_license_url(): ?string {
return $this->manage_license_url ?: null;
}
public function use_custom_style(): SE_License_SDK_License {
$this->use_custom_style = true;
return $this;
}
private function updating_license( bool $status ): void {
// Set initial flag.
$this->updating_license = $status;
if ( $status ) {
set_transient( $this->option_key . '_is_updating_license', 'yes', 20 );
} else {
delete_transient( $this->option_key . '_is_updating_license' );
}
// Method Chain.
}
private function is_updating_license(): bool {
return $this->updating_license || 'yes' === get_transient( $this->option_key . '_is_updating_license' );
}
/**
* Initialize License.
*
* @return void
*/
public function init() {
add_action( 'init', [ $this, 'handle_license_page_form' ] );
if ( null === $this->menu_args ) {
$this->set_menu_args();
}
// Run hook to check license status daily.
add_action( $this->schedule_hook, [ $this, 'check_license_status' ] );
$this->userCapability = $this->menu_args['capability'];
$this->is_license_page = isset( $_GET['page'] ) && $_GET['page'] === $this->menu_args['menu_slug']; // phpcs:ignore
if ( $this->client->isPlugin() ) {
add_action( 'plugin_action_links_' . $this->client->getBasename(), [ $this, 'plugin_action_links' ] );
}
add_action( 'admin_notices', [ $this, '__admin_notices' ] );
if ( $this->client->is_network_activated() ) {
add_action( 'network_admin_notices', [ $this, '__admin_notices' ] );
}
add_action( 'admin_init', [ $this, 'maybe_dismiss_renewal_notice' ] );
// Activation/Deactivation hooks.
$this->activation_deactivation();
// Background re-verification of a license whose signature couldn't be checked.
add_action( $this->client->getHookName( 'license_recheck' ), [ $this, 'check_license_status' ] );
// Check the validity and save the state. (check after cron scheduled)
$this->is_valid();
// Self-heal: an active license must have its daily check scheduled.
// Runs after is_valid() so an old-format signature (which included the
// event's timestamp) is migrated before the event is re-created.
if ( ! empty( $this->license['license'] ) && 'active' === ( $this->license['status'] ?? '' ) && ! wp_next_scheduled( $this->schedule_hook ) ) {
$this->schedule_license_check();
}
// Set Did Init Flag
$this->did_init = true;
}
/**
* Expose the License Key.
*
* @return string
*/
public function get_key(): string {
$this->get_license();
return $this->license['license'] ?? '';
}
/**
* Display Admin Notices.
*
* @return void
*/
public function __admin_notices() {
if ( ! current_user_can( $this->userCapability ) ) {
return;
}
if ( defined( 'WP_HTTP_BLOCK_EXTERNAL' ) && WP_HTTP_BLOCK_EXTERNAL === true ) {
$host = wp_parse_url( $this->client->getLicenseserver(), PHP_URL_HOST );
if ( ! defined( 'WP_ACCESSIBLE_HOSTS' ) || ( defined( 'WP_ACCESSIBLE_HOSTS' ) && false === stristr( WP_ACCESSIBLE_HOSTS, $host ) ) ) {
?>
' . esc_html__( 'Warning!', 'storeengine-sdk' ) . '',
esc_html( $this->client->getPackageName() ),
'' . esc_html( $host ) . '',
'WP_ACCESSIBLE_HOSTS'
);
?>
inactive_license_notice();
$this->renewal_notice();
if ( ! empty( $this->error ) ) {
?>
error ) ); ?>
success ) ) {
?>
success ) ); ?>
is_license_page && ! $this->is_valid() && ! $this->is_updating_license() ) {
?>
client->getHookName( 'renewal_notice_days' ), 14 ) );
}
/**
* Transient key snoozing the renewal nag for this product.
*/
protected function renewal_snooze_key(): string {
return 'se_sdk_renew_snooze_' . md5( $this->client->getSlug() );
}
/**
* Nag the admin when an active license is expiring soon or has expired, with
* a renewal link. Skipped on the license page itself (they manage it there),
* for unlimited/no-expiry licenses, and while snoozed. Shown site-wide so a
* lapse is noticed before Pro features quietly stop.
*
* @return void
*/
public function renewal_notice() {
if ( $this->is_license_page || $this->client->isFree() ) {
return;
}
$license = $this->get_license();
$expires = ! empty( $license['expires'] ) ? (int) $license['expires'] : 0;
// No expiry to warn about (lifetime/unlimited or unknown).
if ( ! $expires || ! empty( $license['unlimited'] ) ) {
return;
}
// Only nag for a license the user has actually engaged with (has a key).
if ( empty( $license['license'] ) ) {
return;
}
$now = current_time( 'timestamp', true );
$days_left = (int) floor( ( $expires - $now ) / DAY_IN_SECONDS );
$expired = $expires <= $now;
// Not in the warning window yet.
if ( ! $expired && $days_left > $this->renewal_notice_window() ) {
return;
}
// Snoozed for this exact expiry value?
if ( (string) get_transient( $this->renewal_snooze_key() ) === (string) $expires ) {
return;
}
$renew_url = $this->manage_license_url ?: $this->client->get_purchase_url();
$dismiss = wp_nonce_url(
add_query_arg( 'se_sdk_dismiss_renewal', rawurlencode( $this->client->getSlug() ) ),
'se_sdk_dismiss_renewal_' . $this->client->getSlug()
);
if ( $expired ) {
$message = sprintf(
/* translators: %s: product name. */
esc_html__( 'Your %s license has expired. Renew it to keep receiving automatic updates and support.', 'storeengine-sdk' ),
'' . esc_html( $this->client->getPackageName() ) . ''
);
$class = 'notice-error';
} else {
$message = sprintf(
/* translators: 1: product name, 2: human time diff e.g. "6 days". */
esc_html__( 'Your %1$s license expires in %2$s. Renew now to avoid losing automatic updates and support.', 'storeengine-sdk' ),
'' . esc_html( $this->client->getPackageName() ) . '',
'' . esc_html( human_time_diff( $now, $expires ) ) . ''
);
$class = 'notice-warning';
}
?>
client->getSlug() ) {
return;
}
if ( ! current_user_can( $this->userCapability ?: 'manage_options' ) ) {
return;
}
check_admin_referer( 'se_sdk_dismiss_renewal_' . $this->client->getSlug() );
$expires = (int) ( $this->get_license()['expires'] ?? 0 );
// Snooze for this expiry value for a week; re-nags after that or when the
// expiry changes (e.g. a partial renewal).
set_transient( $this->renewal_snooze_key(), (string) $expires, WEEK_IN_SECONDS );
wp_safe_redirect( remove_query_arg( [ 'se_sdk_dismiss_renewal', '_wpnonce' ] ) );
exit;
}
/**
* Setup plugin action link to the license page.
*
* @param array $links plugin action links.
*
* @return array
*/
public function plugin_action_links( array $links ): array {
$age_url = $this->get_page_url();
if ( ! empty( $age_url ) ) {
$label = $this->is_valid() ? __( 'Manage License', 'storeengine-sdk' ) : __( 'Activate License', 'storeengine-sdk' );
array_unshift( $links, '' . esc_html( $label ) . '' );
}
return $links;
}
/**
* Check license.
*
* @return array
*/
public function check(): array {
return $this->request( 'status', $this->license );
}
/**
* Get Plugin data.
*
* @return array {
* Plugin Information
* @type bool $success API response status
* @type string $api_call_execution_time API Man (Rest Response) Execution Time
* @type array $data {
* Plugin Data ( API Man.)
* @type array $package {
* @type int $product_id API Man Product ID
* }
* @type array $info {
* @type string $id Plugin Id
* @type string $name Plugin Name
* @type string $author Author Name
* @type string $author_profile Author Profile URL
* @type string $slug Plugin Slug
* @type string $plugin Plugin main file path
* @type string $new_version New Version String
* @type string $url Plugin URL
* @type string $package Plugin update download URL
* @type string $icons Plugin Icons
* @type string $banners Plugin Banners
* @type string $banner_rtl RTL Version of Plugin Banners
* @type string $upgrade_notice Upgrade Notice
* @type string $requires Minimum WordPress Version
* @type string $requires_php Minimum PHP Version
* @type string $tested Tested upto WordPress Version
* @type array $compatibility Compatibility information (API Man sends string)
* @type array $contributors Plugin Contributors List (if available)
* @type array $ratings Plugin Rating (if available)
* @type float $num_ratings Plugin Rating (if available)
* @type string $last_updated Last updated Date
* @type string $homepage Plugin Home Page URL
* @type array $sections {
* Plugin Description Sections
* @type string $description Plugin Description
* @type string $changelog Change LOG
* }
* @type mixed $author_block_count
* @type mixed $author_block_rating
* }
* }
* }
*
* @deprecated kept for documentation.
*/
public function get_information(): array {
return $this->request( 'information', $this->license );
}
/**
* Active a license.
*
* @param array $license license data.
*
* @return array
*/
public function activate(
#[\SensitiveParameter]
array $license
): array {
return $this->request( 'activate', $license );
}
/**
* Deactivate current license.
*
* @return array
*/
public function deactivate(): array {
return $this->request( 'deactivate', $this->license );
}
/**
* Send common request.
*
* @param string $action request action.
* @param array $license license data.
*
* @return array
*/
protected function request(
string $action,
#[\SensitiveParameter]
array $license = []
): array {
$actions = [
'activate' => 'activate-license',
'deactivate' => 'deactivate-license',
'status' => 'check-license',
'information' => 'package-info',
'update' => 'check-update',
];
if ( ! in_array( $action, array_keys( $actions ) ) ) {
return [
'success' => false,
'error' => __( 'Invalid Request Action.', 'storeengine-sdk' ),
];
}
// parse license data
$license = wp_parse_args( $license, $this->get_license() );
// validate license data.
if ( ! $this->validate_license_data( $license ) ) {
return [
'success' => false,
'error' => __( 'Invalid/Empty License Data.', 'storeengine-sdk' ),
];
}
return $this->client->request( [ 'body' => array_merge( $license, $this->client->get_admin_info() ), 'route' => $actions[ $action ] ] );
}
public function set_menu_args( $args = [] ): SE_License_SDK_License {
$this->menu_args = wp_parse_args(
$args,
[
'type' => 'submenu', // Can be: menu, options, submenu.
'menu_title' => $this->client->getPackageName(),
'page_title' => sprintf(
/* translators: 1. Theme/Plugin Name. */
esc_html__( '%s License Management', 'storeengine-sdk' ),
esc_html( $this->client->getPackageName() )
),
'capability' => $this->client->is_network_activated() ? 'manage_network_options' : 'manage_options',
'menu_slug' => 'manage-' . $this->client->getSlug() . '-license',
'icon_url' => 'dashicons-admin-network',
'position' => null,
// Themes have no Settings context by convention — put their
// license screen under Appearance; plugins under Settings.
'parent_slug' => $this->client->isTheme() ? 'themes.php' : 'options-general.php',
]
);
return $this;
}
/**
* Add settings page for license.
*
* @param array $args settings for rendering the menu.
*
* @return $this
*/
public function add_settings_page(): SE_License_SDK_License {
if ( $this->did_init ) {
_doing_it_wrong(
__METHOD__,
sprintf(
/* translators: 1. Class Method. */
__( '%s Should be called before License::init()', 'storeengine-sdk' ),
'' . __METHOD__ . ''
),
'1.0.0'
);
return $this;
}
if ( ! in_array( $this->menu_args['type'], [ 'menu', 'options', 'submenu' ], true ) ) {
$this->menu_args['type'] = $this->menu_args['parent_slug'] ? 'submenu' : 'menu';
}
if ( 'submenu' === $this->menu_args['type'] && ! $this->menu_args['parent_slug'] ) {
$this->menu_args['type'] = 'options';
}
if ( 'menu' === $this->menu_args['type'] && $this->menu_args['parent_slug'] ) {
$this->menu_args['type'] = 'submenu';
}
// Network-activated products manage their (network-wide) license from the
// Network Admin; everything else from the site admin.
$menu_hook = $this->client->is_network_activated() ? 'network_admin_menu' : 'admin_menu';
add_action( $menu_hook, [ $this, 'register_admin_menu' ], 999 );
return $this;
}
/**
* Admin Menu hook.
*
* @return void
*/
public function register_admin_menu() {
switch ( $this->menu_args['type'] ) {
case 'submenu':
$this->add_submenu_page();
break;
case 'options':
$this->add_options_page();
break;
case 'menu':
default:
$this->add_menu_page();
break;
}
}
/**
* License menu output.
*
* @return void
*/
public function render_menu_page() {
$mount_id = 'se-sdk-license-app-' . sanitize_html_class( $this->client->getSlug() );
$this->enqueue_license_app( $mount_id );
?>
client->getPackageName() )
); ?>
client->getHookName( 'disable_react_panel' ), false );
if ( ! $react_disabled && $this->client->maybe_init_restapi() ) :
?>
render_license_page();
endif;
?>
client->getSlug() );
wp_enqueue_style(
$handle,
SE_License_SDK::sdk_url( 'static/license-app.css' ),
[],
$this->client->getVersion()
);
wp_enqueue_script(
$handle,
SE_License_SDK::sdk_url( 'static/license-app.js' ),
[ 'wp-element', 'wp-i18n' ],
$this->client->getVersion(),
true
);
// Wire JS __()/sprintf strings to translations shipped in the SDK's
// languages/ folder (storeengine-sdk-{locale}-{handle-md5}.json).
if ( function_exists( 'wp_set_script_translations' ) ) {
wp_set_script_translations( $handle, 'storeengine-sdk', SE_License_SDK::sdk_path( 'languages' ) );
}
$is_free = $this->client->isFree();
// Surface only the bits the React app actually needs — never echo
// raw license data; the JS calls /license/status itself.
$config = [
'mountId' => $mount_id,
'slug' => $this->client->getSlug(),
'packageName' => $this->client->getPackageName(),
'isFree' => $is_free,
'restUrl' => trailingslashit( rest_url( 'storeengine-sdk/v1/' . $this->client->getSlug() ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'initialLicense' => $is_free ? null : $this->get_public_data(),
'storeDashboardUrl' => $this->manage_license_url ?: null,
'purchaseUrl' => $this->client->get_purchase_url() ?: null,
// Manual-install fallback shown when an automatic update fails: where
// to download the package (account dashboard) and where to upload it.
'packageType' => $this->client->getType(),
'uploadUrl' => $this->client->isPlugin()
? self_admin_url( 'plugin-install.php?tab=upload' )
: self_admin_url( 'theme-install.php?upload' ),
];
// Per-instance global so multiple SDK consumers on the same page
// (e.g. plugin + addon) don't clobber each other's config.
$var_name = 'seSdkLicenseAppConfig_' . preg_replace( '/[^A-Za-z0-9_]/', '_', $this->client->getSlug() );
wp_add_inline_script(
$handle,
sprintf( 'window.%s = %s;', $var_name, wp_json_encode( $config ) ),
'before'
);
}
public function render_license_page() {
$this->licenses_style();
$status = isset( $this->license['status'] ) && 'active' === $this->license['status'] ? 'active' : 'inactive';
$action = 'active' === $status ? 'deactivate' : 'activate';
$submit_label = 'activate' === $action ? __( 'Activate License', 'storeengine-sdk' ) : __( 'Deactivate License', 'storeengine-sdk' );
$status_label = 'active' === $status ? __( 'Active', 'storeengine-sdk' ) : __( 'Inactive', 'storeengine-sdk' );
$isUnlimited = (bool) ( $this->license['unlimited'] ?? false );
$remaining = absint( $this->license['remaining'] ?? 0 );
if ( ! $this->header_message ) {
$this->header_message = sprintf(
/* translators: %s: Plugin Name */
esc_html__( 'Active %s license to get professional support and automatic update from your WordPress dashboard.', 'storeengine-sdk' ),
'' . esc_html( $this->client->getPackageName() ) . ''
);
}
if ( ! $this->header_content ) {
$this->header_content = sprintf(
/* translators: %s: Plugin Name */
esc_html__( 'Activate %s to unlock automatic updates, priority support, and all tools to optimize your WordPress store.', 'storeengine-sdk' ),
'' . esc_html( $this->client->getPackageName() ) . ''
);
}
do_action( $this->client->getHookName( 'before_license_section' ), $action );
include __DIR__ . '/../views/license-form.php';
do_action( $this->client->getHookName( 'after_license_section' ) );
}
/**
* License form submit.
*
* @return void
*/
public function handle_license_page_form() {
$check_key = $this->client->getSlug() . '-check-license';
if ( isset( $_GET[ $check_key ] ) && wp_verify_nonce( sanitize_text_field( $_GET[ $check_key ] ), $this->client->getSlug() ) ) {
$this->client->interactive( [ $this, 'check_license_status' ] );
wp_safe_redirect( $this->get_page_url() . '#' . $this->client->getSlug() . '-license-form' );
die();
}
$action = sanitize_text_field( wp_unslash( $_POST[ $this->data_key ]['_action'] ?? '' ) );
if ( $action ) {
check_admin_referer( $this->data_key );
switch ( $action ) {
case 'activate':
$this->activate_client_license( array_map( 'sanitize_text_field', $_POST[ $this->data_key ] ) );
do_action( $this->client->getHookName( 'license-activate' ) );
break;
case 'deactivate':
$this->deactivate_client_license();
do_action( $this->client->getHookName( 'license-deactivate' ) );
break;
default:
break;
}
}
}
/**
* Check license status on schedule.
* Check and update license status on db.
*
* @return void
*/
public function check_license_status() {
$license = $this->get_license();
if ( empty( $license['license'] ) || 'inactive' === $license['status'] ) {
$this->clear_license_check_schedule();
return;
}
$this->updating_license( true );
$response = $this->check();
// --- Authoritative server verdict ------------------------------------
// The server answered (even "inactive" comes back as success=true with
// status=inactive). Apply it verbatim and clear any grace bookkeeping.
if ( isset( $response['success'] ) && $response['success'] ) {
$was_active = 'active' === ( $license['status'] ?? '' );
$license = wp_parse_args( $response['data'], $license );
$this->set_grace_state( [ 'last_verified_at' => time() ] );
$this->set_license( $license );
// Lifecycle hook: license transitioned active → inactive/expired.
if ( $was_active && 'active' !== ( $license['status'] ?? '' ) ) {
$this->emit_license_event( 'license_deactivated', $license );
}
$this->updating_license( false );
return;
}
// --- Server unreachable → grace period --------------------------------
// A transport-level failure (DNS/timeout/TLS/blocked request/5xx) is NOT
// a verdict. Keep the last-known-good license active until the grace
// window since the last successful verification has elapsed. Prevents a
// brief outage from deactivating a paying customer's product.
if ( ! empty( $response['transport_error'] ) ) {
$grace = $this->client->getLicenseGracePeriod();
$state = $this->get_grace_state();
if ( empty( $state['grace_started_at'] ) ) {
$state['grace_started_at'] = time();
}
$state['last_error'] = $response['error'] ?? '';
$anchor = ! empty( $state['last_verified_at'] ) ? (int) $state['last_verified_at'] : (int) $state['grace_started_at'];
$expired = ( 0 === $grace ) || ( ( time() - $anchor ) > $grace );
if ( $expired ) {
// Grace exhausted — fall through to the deactivation path.
$this->set_grace_state( [] );
$this->error = $response['error'] ?: __( 'Could not verify your license and the grace period has ended.', 'storeengine-sdk' );
$this->deactivate_local_license( $license );
$this->emit_license_event( 'license_grace_expired', $license );
} else {
// Still within grace — keep the product working, re-sign so the
// license stays valid, and record when grace started.
$state['in_grace'] = true;
$this->set_grace_state( $state );
$this->set_license( $license );
$this->emit_license_event( 'license_check_deferred', $license );
}
$this->updating_license( false );
return;
}
// --- Definitive rejection --------------------------------------------
// The server responded with a genuine business error (e.g. license
// revoked). Deactivate locally but keep the key so a later renewal can
// reactivate it.
$this->error = $response['error'] ?: __( 'Unknown error occurred.', 'storeengine-sdk' );
$this->set_grace_state( [] );
$this->deactivate_local_license( $license );
$this->emit_license_event( 'license_deactivated', $license );
$this->updating_license( false );
}
/**
* Mark the stored license inactive locally without touching the key, so a
* later successful check (e.g. after renewal) can reactivate it.
*
* @param array $license Current license array.
*/
protected function deactivate_local_license( array $license ) {
// `license` (the key) and `expires` are deliberately preserved: this
// method is the "server said no" / "grace ran out" path, not a user
// asking to unlink the site, and the caller's contract is that a later
// renewal can reactivate without the user digging out their key again.
//
// This used to pass the overrides below as wp_parse_args()' FIRST
// argument, i.e. as `$args` rather than `$defaults`, so they won every
// key — including `'license' => ''` and `'expires' => ''`. The stored key
// was destroyed on every expiry, revocation and grace timeout, which is
// the exact opposite of what both this docblock and the call sites say.
$license = array_merge( $license, [
'status' => 'inactive',
'device_id' => $this->client->get_device_id(),
'slug' => $this->client->getSlug(),
'product_id' => $this->client->getProductId(),
'remaining' => 0,
'activations' => 0,
'limit' => 0,
'unlimited' => false,
] );
$this->set_license( $license );
}
/**
* Grace-period bookkeeping (stored separately from license_data so it never
* fights parse_license_data's whitelist).
*
* @return array{last_verified_at?:int, grace_started_at?:int, last_error?:string, in_grace?:bool}
*/
public function get_grace_state(): array {
$state = $this->client->get_option( 'license_verify_state', [] );
return is_array( $state ) ? $state : [];
}
/**
* Persist grace-period bookkeeping. Passing [] clears it.
*
* @param array $state New state (merged onto the existing one; [] resets).
*/
public function set_grace_state( array $state ) {
if ( empty( $state ) ) {
$this->client->set_option( 'license_verify_state', [] );
return;
}
$this->client->set_option( 'license_verify_state', wp_parse_args( $state, $this->get_grace_state() ) );
}
/**
* Whether the license is currently being honoured under the offline grace
* period (server unreachable, last-known-good still active).
*/
public function is_in_grace(): bool {
$state = $this->get_grace_state();
return ! empty( $state['in_grace'] );
}
/**
* Fire a documented license lifecycle event on this product's hook
* namespace so consuming plugins/themes can react.
*
* @param string $event Event slug (e.g. 'license_activated').
* @param array $license License data at the time of the event.
*/
protected function emit_license_event( string $event, array $license = [] ) {
/**
* Fires on a license lifecycle transition for this product.
*
* Hook name: "{product-hook-prefix}_{$event}". Events:
* - license_activated License successfully activated.
* - license_deactivated License deactivated (by user or server verdict).
* - license_grace_expired Offline grace ended; license failed closed.
* - license_check_deferred Scheduled check skipped (server unreachable, within grace).
*
* @param array $license The license data.
* @param SE_License_SDK_License $this The license handler.
*/
$this->client->do_action( $event, $license, $this );
}
/**
* Check this is a valid license.
*
* @return bool
*/
public function is_valid(): bool {
if ( null !== $this->is_valid_license ) {
return $this->is_valid_license;
}
// Load the license if already not loaded.
$license = $this->get_license();
if ( isset( $license['license'], $license['device_id'], $license['product_id'], $license['status'] ) && 'active' === $license['status'] ) {
$this->is_valid_license = $this->validate_license_signature();
if ( ! $this->is_valid_license ) {
$this->maybe_schedule_signature_recheck();
}
} else {
$this->is_valid_license = false;
}
return $this->is_valid_license;
}
/**
* Validate license data for request.
*
* @param array $license license data.
*
* @return bool
*/
public function validate_license_data(
#[\SensitiveParameter]
array $license = []
): bool {
$license = $this->parse_license_data( $license );
return (
! empty( $license['license'] ) &&
! empty( $license['device_id'] ) &&
// ! empty( $license['activation_id'] ) &&
! empty( $license['slug'] ) &&
! empty( $license['product_id'] )
);
}
/**
* Styles for licenses page.
*
* @return void
*/
private function licenses_style() {
?>
license['updated_at'] ) {
return;
}
$time_diff = $this->license['updated_at'] - current_time( 'timestamp', true );
if ( absint( $time_diff ) < WEEK_IN_SECONDS ) {
// translators: placeholder is human time diff (e.g. "3 weeks")
printf( __( '%s ago', 'storeengine-sdk' ), human_time_diff( current_time( 'timestamp', true ), $this->license['updated_at'] ) );
return;
}
echo esc_html( get_date_from_gmt( '@' . $this->license['updated_at'], 'Y-m-d h:i A (P)' ) );
}
protected function render_license_expire_datetime() {
if ( 'active' !== $this->license['status'] ) {
$expires = __( 'N/A', 'storeengine-sdk' );
$expiring = true;
} elseif ( empty( $this->license['expires'] ) ) {
$expires = __( 'N/A', 'storeengine-sdk' );
$expiring = false;
} else {
$time_diff = $this->license['expires'] - current_time( 'timestamp', true );
if ( $time_diff > 0 && $time_diff < WEEK_IN_SECONDS ) {
// translators: placeholder is human time diff (e.g. "3 weeks")
$expires = sprintf( __( 'In %s', 'storeengine-sdk' ), human_time_diff( current_time( 'timestamp', true ), $this->license['expires'] ) );
$expiring = true;
} elseif ( $time_diff < 0 && absint( $time_diff ) < WEEK_IN_SECONDS ) {
// translators: placeholder is human time diff (e.g. "3 weeks")
$expires = sprintf( __( '%s ago', 'storeengine-sdk' ), human_time_diff( current_time( 'timestamp', true ), $this->license['expires'] ) );
$expiring = true;
} else {
$expires = get_date_from_gmt( '@' . $this->license['expires'], 'Y-m-d h:i A (P)' );
$expiring = false;
}
}
?>
header_icon_url = $url ? esc_url_raw( $url ) : null;
return $this;
}
public function remove_header(): SE_License_SDK_License {
$this->remove_header = true;
return $this;
}
/**
* Card header.
*
* @return void
*/
public function get_error() {
return $this->error;
}
public function get_error_code(): string {
return $this->error_code;
}
public function get_error_data(): array {
return $this->error_data;
}
public function get_success() {
return $this->success;
}
/**
* Activate client license.
*
* @param array $postData post data.
*
* @return void
*/
public function activate_client_license( array $postData ) {
$this->updating_license( true );
// Reset structured error state for this attempt.
$this->error_code = '';
$this->error_data = [];
$license = $this->get_license();
// Re-activating after an expiry/revocation: deactivate_local_license()
// keeps the key on file precisely so the user can renew upstream and
// click Activate without re-typing it. Fall back to the stored key when
// the caller sends none.
if ( empty( $postData['license_key'] ) && ! empty( $license['license'] ) ) {
$postData['license_key'] = $license['license'];
}
if ( empty( $postData['license_key'] ) ) {
$this->error = __( 'The license key field is required.', 'storeengine-sdk' );
$this->updating_license( false );
return;
}
$updateKey = $this->validate_license_data( $license ) && $postData['license_key'] !== $license['license']; // Check if it's a change request.
if ( $updateKey ) {
// Deactivate Previous.
$deactivate = $this->deactivate(); // deactivate first.
if ( ! $deactivate['success'] ) {
$check = $this->check(); // Check api status.
if ( $check['success'] && isset( $check['data']['activated'] ) && $check['data']['activated'] ) {
if ( $deactivate['error'] ) {
$this->error = $deactivate['error'];
} else {
$this->error = __( 'Unknown error occurred.', 'storeengine-sdk' );
}
$this->updating_license( false );
return;
}
}
}
// Set new license info.
$license['license'] = $postData['license_key'];
$license['device_id'] = $this->client->get_device_id();
// Seats the user chose to release during a limit-reached takeover
// (Freemius-style). The server frees these before re-checking the
// activation limit so this site can take a seat.
if ( ! empty( $postData['deactivate_activations'] ) && is_array( $postData['deactivate_activations'] ) ) {
$license['deactivate_activations'] = array_values( array_filter( array_map( 'absint', $postData['deactivate_activations'] ) ) );
}
// Activate The License.
$response = $this->activate( $license );
if ( ! $response['success'] ) {
if ( $response['error'] ) {
$this->error = $response['error'];
} else {
$this->error = __( 'Unknown error occurred.', 'storeengine-sdk' );
}
// Capture the machine-readable code + payload (e.g. the active-site
// list when the activation limit is reached) for the REST layer.
$this->error_code = $response['code'] ?? '';
$this->error_data = ( isset( $response['data'] ) && is_array( $response['data'] ) ) ? $response['data'] : [];
} else {
if ( ! $updateKey ) {
$this->success = __( 'License activated successfully.', 'storeengine-sdk' );
} else {
$this->success = __( 'License updated successfully.', 'storeengine-sdk' );
}
// Fresh authoritative verdict — anchor the grace period here and
// clear any stale offline state from a previous key.
$this->set_grace_state( [ 'last_verified_at' => time() ] );
}
// Don't reset the key.
// keep it, so if the user renew subscription update the status and reactivate the plugin.
// Schedule before saving so the signature payload uses the same next-run timestamp as validation.
$this->schedule_license_check();
$new_license = wp_parse_args( $response['data'], $license );
// Update license status.
$this->set_license( $new_license );
if ( $response['success'] && 'active' === ( $new_license['status'] ?? '' ) ) {
$this->emit_license_event( 'license_activated', $new_license );
}
$this->updating_license( false );
}
/**
* deactivate client license.
*
* @return void
*/
public function deactivate_client_license() {
if ( ! isset( $this->license['license'] ) || empty( $this->license['license'] ) ) {
$this->error = __( 'License key not found.', 'storeengine-sdk' );
} else {
$response = $this->deactivate();
if ( ! $response['success'] ) {
// check api status.
$check = $this->check();
if ( $check['success'] && isset( $check['data']['activated'] ) && $check['data']['activated'] ) {
if ( $response['error'] ) {
$this->error = $response['error'];
} else {
$this->error = __( 'Unknown error occurred.', 'storeengine-sdk' );
}
}
}
}
$this->clear_license_check_schedule();
$this->set_grace_state( [] );
$deactivated_license = $this->get_license();
// Reset license data.
$this->set_license();
$this->emit_license_event( 'license_deactivated', $deactivated_license );
$this->success = __( 'License deactivated successfully.', 'storeengine-sdk' );
}
/**
* Add license menu page.
*
* @return void
*/
private function add_menu_page() {
add_menu_page(
$this->menu_args['page_title'],
$this->menu_args['menu_title'],
$this->menu_args['capability'],
$this->menu_args['menu_slug'],
[ $this, 'render_menu_page' ],
$this->menu_args['icon_url'],
$this->menu_args['position']
);
}
/**
* Add submenu page.
*
* @return void
*/
private function add_submenu_page() {
add_submenu_page(
$this->menu_args['parent_slug'],
$this->menu_args['page_title'],
$this->menu_args['menu_title'],
$this->menu_args['capability'],
$this->menu_args['menu_slug'],
[ $this, 'render_menu_page' ]
);
}
/**
* Add submenu page.
*
* @return void
*/
private function add_options_page() {
add_options_page(
$this->menu_args['page_title'],
$this->menu_args['menu_title'],
$this->menu_args['capability'],
$this->menu_args['menu_slug'],
[ $this, 'render_menu_page' ]
);
}
/**
* Schedule daily license checker event.
*
* @return void
*/
public function schedule_license_check() {
if ( empty( $this->license['license'] ) && ! $this->get_key() ) {
return;
}
if ( ! wp_next_scheduled( $this->schedule_hook ) ) {
// Spread over the first hour so sites activated together (or healed
// together after an update) don't all check at the same minute.
wp_schedule_event( time() + SE_License_SDK_Client::jitter( 30 * MINUTE_IN_SECONDS, 0.95 ), 'daily', $this->schedule_hook );
}
}
/**
* Clear any scheduled hook.
*
* @return void
*/
public function clear_license_check_schedule() {
wp_clear_scheduled_hook( $this->schedule_hook );
}
/**
* Register Activation And Deactivation Hooks.
*
* @return void
*/
private function activation_deactivation() {
switch ( $this->client->getType() ) {
case 'plugin':
register_activation_hook( $this->client->getBasename(), [ $this, 'schedule_license_check' ] );
register_deactivation_hook( $this->client->getBasename(), [ $this, 'project_deactivation' ] );
add_action( 'activated_plugin', [ $this, 'redirect_to_license_page' ], 999, 2 );
break;
case 'theme':
add_action( 'switch_theme', [ $this, 'project_deactivation' ] );
add_action( 'after_switch_theme', [ $this, 'schedule_license_check' ] );
add_action( 'after_switch_theme', [ $this, 'redirect_to_license_page' ], 999, 2 );
break;
}
}
/**
* Project Deactivation Callback.
*
* @return void
*/
public function project_deactivation() {
// Intentionally NOT calling deactivate_client_license() here.
//
// WordPress plugin deactivation is often temporary — a user
// disables a plugin to debug, swap themes, test a conflict, etc.
// Surrendering the license seat on every deactivation forces the
// user to re-paste their key on reactivation, which is bad UX and
// also creates spurious "deactivated → reactivated" noise in the
// vendor's activation audit log.
//
// We match the behaviour of ACF Pro / EDD / Yoast Premium: the
// license stays active across WP plugin deactivate/reactivate.
// The seat is only released when the user explicitly clicks
// "Deactivate License" in the SDK panel, or when they truly
// uninstall the plugin (via wp-admin Plugins → Delete, which
// runs the consumer's own uninstall.php — the SDK can't hook
// that path because it's bundled inside the host plugin).
$this->clear_license_check_schedule();
// Drop the cached update-info transient so a future reactivation
// starts with a fresh check.
if ( method_exists( $this->client, 'updater' ) ) {
$this->client->updater()->delete_cached_version_info();
}
}
/**
* Redirect to the license activation page after plugin/theme is activated.
*
* @TODO make option for the plugin/theme (which is using this lib) can alter this method with their custom function.
*
* @param string $param1 Plugin: base file|Theme: old theme name.
* @param bool|WP_Theme $param2 Plugin: network wide activation status|Theme: WP_Theme instance of the old theme.
*
* @return void
*/
public function redirect_to_license_page( $param1, $param2 = null ) {
/** @noinspection PhpUndefinedClassInspection */
if ( ! $this->redirect_on_activation || class_exists( \WP_CLI::class, false ) ) {
return;
}
$canRedirect = false;
if ( 'plugin' == $this->client->getType() ) {
$canRedirect = ( $param1 == $this->client->getBasename() );
}
if ( 'theme' == $this->client->getType() ) {
$canRedirect = ( ! get_option( 'theme_switched_via_customizer' ) );
}
if ( $canRedirect ) {
wp_safe_redirect( $this->get_page_url( false ) );
die();
}
}
/**
* Form action URL.
*/
private function formActionUrl(): void {
echo esc_url( $this->get_page_url() );
}
/**
* Get input license key.
*
* @param array $license license data.
* @param string $action
*
* @return string
*/
private function get_input_license_value(
#[\SensitiveParameter]
array $license,
string $action
): string {
//phpcs:disable
if ( 'deactivate' !== $action ) {
if ( ! empty( $_REQUEST[ $this->data_key ]['license_key'] ) ) {
return sanitize_text_field( $_REQUEST[ $this->data_key ]['license_key'] );
}
return '';
}
// phpcs:enabled
if ( empty( $license['license'] ) ) {
return '';
}
return $this->mask_string( $license['license'] );
}
public function mask_string(
#[\SensitiveParameter]
string $string,
?int $size = null
): string {
$length = strlen( $string );
if ( ! $size ) {
$size = (int) max( 8, $length / 8 );
$size = (int) ( $length < $size ? $length / 10 : $size );
}
$mid = (int) ( $size * 2 );
$mid = $length > $mid ? $length - $mid : $mid;
$masked = substr( $string, 0, $size );
$masked .= str_repeat( '•', $mid );
$masked .= substr( $string, - 1 * $size );
return $masked;
}
/**
* Signature payload (v2): the product slug and the license data.
*
* Until 1.6.0 the payload also contained wp_next_scheduled() of the daily
* check event, so anything that removed or moved that cron event (cron
* cleanup plugins, migrations, a manual "run now") silently invalidated a
* perfectly good license. The salted HMAC over the license data is what
* stops the option from being edited by hand; the timestamp added nothing
* to that.
*
* @return string
*/
private function license_signature_payload(): string {
return 'v2||' . $this->client->getSlug() . '||' . implode( '||', array_values( $this->license ) );
}
/**
* Pre-1.6.0 payload, kept only to migrate existing signatures.
*
* @return string
*/
private function legacy_license_signature_payload(): string {
return $this->client->getSlug() . '||' . wp_next_scheduled( $this->schedule_hook ) . '||' . implode( '||', array_values( $this->license ) );
}
private function sign( string $payload ): string {
return hash_hmac( 'sha256', $payload, $this->hash( $payload ) );
}
private function update_license_signature() {
if ( $this->license && is_array( $this->license ) ) {
$this->client->set_option( 'license_signature', $this->sign( $this->license_signature_payload() ) );
}
}
private function get_license_signature() {
return $this->client->get_option( 'license_signature', false );
}
private function validate_license_signature(): bool {
$license_signature = $this->get_license_signature();
if ( ! $license_signature || ! is_string( $license_signature ) ) {
return false;
}
if ( hash_equals( $this->sign( $this->license_signature_payload() ), $license_signature ) ) {
return true;
}
// Signed by an older SDK: accept once and re-sign in the new format.
if ( hash_equals( $this->sign( $this->legacy_license_signature_payload() ), $license_signature ) ) {
$this->update_license_signature();
return true;
}
return false;
}
/**
* Recover licenses whose old-format signature can't be verified any more
* because the daily check event was removed: ask the server in the
* background instead of showing the product as unlicensed until the user
* re-activates. The server's answer decides, so this can't be used to
* unlock anything.
*
* @return void
*/
private function maybe_schedule_signature_recheck() {
$license = $this->get_license();
if ( empty( $license['license'] ) || 'active' !== ( $license['status'] ?? '' ) ) {
return;
}
$hook = $this->client->getHookName( 'license_recheck' );
if ( ! has_action( $hook, [ $this, 'check_license_status' ] ) ) {
add_action( $hook, [ $this, 'check_license_status' ] );
}
if ( ! wp_installing() && ! wp_next_scheduled( $hook ) && ! get_site_transient( $hook . '_tried' ) ) {
// Once a day at most, in case the server keeps rejecting it.
set_site_transient( $hook . '_tried', 1, DAY_IN_SECONDS );
wp_schedule_single_event( time(), $hook );
}
}
/**
* Update License Data.
* Call this method without license data will deactivate the license (set empty data).
*
* @param array $license {
* Optional. License Data.
*
* @type string $key The License Key.
* @type string $status Activation Status.
* @type int $remaining Remaining Activation.
* @type int $activation_limit Number of activation allowed for the license key.
* @type int $expires Number of day remaining before the license expires.
* }
*/
private function set_license(
#[\SensitiveParameter]
array $license = []
) {
// Parse & sanitize.
$this->license = $this->parse_license_data( $license );
$this->license['updated_at'] = current_time( 'timestamp', 1 );
// Update in db.
$this->client->set_option( 'license_data', $this->license );
// Update license signature.
$this->update_license_signature();
}
/**
* Get Plugin/Theme License.
*
* @return array {
* Optional. License Data.
* @type string $key The License Key.
* @type string $status Activation Status.
* @type int $remaining Remaining Activation.
* @type int $activation_limit Number of activation allowed for the license key.
* @type int $expires Number of day remaining before the license expires.
* }
*/
public function get_license(): array {
if ( null !== $this->license ) {
return $this->license;
}
$this->license = $this->client->get_option( 'license_data', false );
// Initialize blank inactive license data.
if ( false === $this->license || ! is_array( $this->license ) ) {
$this->set_license();
}
$this->license = $this->parse_license_data( $this->license );
return $this->license;
}
public function get_public_data(): array {
$data = $this->get_license();
if ( $data['license'] ) {
$data['license'] = $this->mask_string( $data['license'] );
}
if ( $data['updated_at'] ) {
$data['updated_at'] = gmdate( 'Y-m-d H:i:s', $data['updated_at'] );
}
if ( $data['expires'] ) {
$data['expires'] = gmdate( 'Y-m-d H:i:s', $data['expires'] );
}
return $data;
}
/**
* Parse License data.
*
* @param array $data license data.
*
* @return array
*/
private function parse_license_data(
#[\SensitiveParameter]
array $data = []
): array {
$defaults = [
'license' => '', // License key.
'status' => 'inactive', // Current status.
'activation_id' => 0,
'device_id' => $this->client->get_device_id(), // Instance unique id.
'slug' => $this->client->getSlug(),
'product_id' => $this->client->getProductId(),
'remaining' => 0, // Remaining activation.
'activations' => 0, // Total activation.
'limit' => 0, //Activation limit.
'unlimited' => false, // Is unlimited activation.
'expires' => '', // Expires set this to a unix timestamp [GMT].
'updated_at' => current_time( 'timestamp', 1 ), // Expires set this to a unix timestamp [GMT].
];
// Parse.
$data = wp_parse_args( $data, $defaults );
$license = [];
$data['updated_at'] = $data['updated_at'] && ! is_numeric( $data['updated_at'] ) ? strtotime( $data['updated_at'] ) : absint( $data['updated_at'] );
// Sanitize data.
$license['license'] = sanitize_text_field( $data['license'] );
$license['status'] = strtolower( $data['status'] ) === 'active' ? 'active' : 'inactive';
$license['activation_id'] = absint( $data['activation_id'] );
$license['device_id'] = sanitize_text_field( $data['device_id'] );
$license['slug'] = sanitize_text_field( $data['slug'] );
$license['product_id'] = absint( $data['product_id'] ); // Product id can be string too.
$license['remaining'] = absint( $data['remaining'] );
$license['activations'] = absint( $data['activations'] );
$license['limit'] = absint( $data['limit'] );
$license['unlimited'] = (bool) $data['unlimited'];
$license['expires'] = $data['expires'] && ! is_numeric( $data['expires'] ) ? strtotime( $data['expires'] ) : absint( $data['expires'] );
$license['updated_at'] = $data['updated_at'];
return $license;
}
/**
* Gets a form of `wp_hash()` specific to the plugin using license service.
*
* We cannot use `wp_hash()` because it is defined in `pluggable.php` which is not loaded until after plugins are loaded,
* which is too late to verify the recovery mode cookie.
*
* This tries to use the `AUTH` salts first, but if they aren't valid specific salts will be generated and stored.
*
* @param string $data Data to hash.
*
* @return string The hashed $data, or false on failure.
*
* @see wp_hash()
* @noinspection PhpUndefinedConstantInspection
*/
private function hash( string $data ): string {
if ( ! function_exists( 'wp_generate_password' ) ) {
require_once ABSPATH . WPINC . '/pluggable.php';
}
return wp_hash( $data, 'auth', 'sha256' );
}
public function migrate_from_freemius( string $freemius_license ) {
$this->updating_license( true );
$license = $this->get_license();
$license['license'] = $freemius_license;
$license['device_id'] = $this->client->get_device_id();
$license['freemius_license'] = true;
// Activate The License.
$response = $this->activate( $license );
if ( ! $response['success'] ) {
if ( $response['error'] ) {
return new WP_Error( 'error-activating-freemius-license-', $response['error'], $response );
} else {
return new WP_Error( 'error-activating-freemius-license-', __( 'Unknown error occurred.', 'storeengine-sdk' ), $response );
}
}
// Schedule before saving so the signature payload uses the same next-run timestamp as validation.
$this->schedule_license_check();
// Update license status.
$this->set_license( wp_parse_args( $response['data'], $license ) );
$this->updating_license( false );
$this->client->set_option( 'migrated_from_freemius', current_time( 'timestamp', 1 ) );
return true;
}
final public function __clone() {
trigger_error( 'Singleton. No cloning allowed!', E_USER_ERROR ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
}
/**
* Wakeup.
*/
final public function __wakeup() {
trigger_error( 'Singleton. No serialization allowed!', E_USER_ERROR ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
}
}
// End of file SE_License_SDK_License.php.