can_manage_plugin() ) {
wp_die( esc_html__( 'You are not allowed to manage this plugin.', 'adknot-connect' ) );
}
$connected = $this->is_connected();
$api_token = (string) get_option( self::OPTION_API_TOKEN, '' );
$masked_api_token = $this->mask_token( $api_token );
$connected_at = (string) get_option( self::OPTION_CONNECTED_AT, '' );
$notice = $this->consume_notice();
$has_connected_key = (int) get_option( self::OPTION_WC_KEY_ID, 0 ) > 0;
?>
can_manage_plugin() ) {
wp_die( esc_html__( 'You are not allowed to perform this action.', 'adknot-connect' ) );
}
check_admin_referer( 'adknot_connect_store' );
$redirect_url = admin_url( 'admin.php?page=' . self::PAGE_SLUG );
$api_token = isset( $_POST['adknot_api_token'] ) ? sanitize_text_field( wp_unslash( $_POST['adknot_api_token'] ) ) : '';
if ( '' === $api_token ) {
$this->set_notice( 'error', __( 'API token is required.', 'adknot-connect' ) );
wp_safe_redirect( $redirect_url );
exit;
}
if ( ! class_exists( 'WooCommerce' ) ) {
$this->set_notice( 'error', __( 'WooCommerce must be active before connecting.', 'adknot-connect' ) );
wp_safe_redirect( $redirect_url );
exit;
}
$key_data = $this->create_wc_api_key( get_current_user_id(), 'read' );
if ( is_wp_error( $key_data ) ) {
$this->set_notice( 'error', $key_data->get_error_message() );
wp_safe_redirect( $redirect_url );
exit;
}
$payload = $this->build_payload( $api_token, $key_data );
$response = $this->post_to_backend( self::CONNECT_PATH, $payload, 60 );
if ( is_wp_error( $response ) ) {
$this->revoke_wc_api_key( (int) $key_data['key_id'] );
$this->set_notice( 'error', sprintf( __( 'Connection failed: %s', 'adknot-connect' ), $response->get_error_message() ) );
wp_safe_redirect( $redirect_url );
exit;
}
$response_code = (int) wp_remote_retrieve_response_code( $response );
if ( $response_code < 200 || $response_code >= 300 ) {
$this->revoke_wc_api_key( (int) $key_data['key_id'] );
$this->set_notice( 'error', $this->parse_backend_error( $response ) );
wp_safe_redirect( $redirect_url );
exit;
}
$old_key_id = (int) get_option( self::OPTION_WC_KEY_ID, 0 );
if ( $old_key_id > 0 && $old_key_id !== (int) $key_data['key_id'] ) {
$this->revoke_wc_api_key( $old_key_id );
}
update_option( self::OPTION_API_TOKEN, $api_token );
update_option( self::OPTION_STATUS, 'connected' );
update_option( self::OPTION_CONNECTED_AT, current_time( 'mysql' ) );
update_option( self::OPTION_WC_KEY_ID, (int) $key_data['key_id'] );
delete_option( self::OPTION_API_BASE );
$this->set_notice( 'success', __( 'Store connected successfully.', 'adknot-connect' ) );
wp_safe_redirect( $redirect_url );
exit;
}
/**
* Handle disconnect form submit.
*
* @return void
*/
public function handle_disconnect() {
if ( ! $this->can_manage_plugin() ) {
wp_die( esc_html__( 'You are not allowed to perform this action.', 'adknot-connect' ) );
}
check_admin_referer( 'adknot_disconnect_store' );
$key_id = (int) get_option( self::OPTION_WC_KEY_ID, 0 );
$disconnect_payload = $this->build_disconnect_payload();
$response = $this->post_to_backend( self::DISCONNECT_PATH, $disconnect_payload, 30 );
if ( $key_id > 0 ) {
$this->revoke_wc_api_key( $key_id );
}
update_option( self::OPTION_STATUS, 'disconnected' );
update_option( self::OPTION_CONNECTED_AT, '' );
update_option( self::OPTION_WC_KEY_ID, 0 );
update_option( self::OPTION_API_TOKEN, '' );
delete_option( self::OPTION_API_BASE );
if ( is_wp_error( $response ) ) {
$this->set_notice( 'info', __( 'Store disconnected locally. Remote service could not be reached.', 'adknot-connect' ) );
} elseif ( (int) wp_remote_retrieve_response_code( $response ) < 200 || (int) wp_remote_retrieve_response_code( $response ) >= 300 ) {
$this->set_notice( 'info', __( 'Store disconnected locally. Remote service returned an error status.', 'adknot-connect' ) );
} else {
$this->set_notice( 'info', __( 'Store disconnected and WooCommerce key revoked.', 'adknot-connect' ) );
}
wp_safe_redirect( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) );
exit;
}
/**
* Create WooCommerce API key pair.
*
* @param int $user_id User ID for ownership.
* @param string $permissions Key permissions.
* @return array|WP_Error
*/
private function create_wc_api_key( $user_id, $permissions = 'read' ) {
global $wpdb;
if ( ! function_exists( 'wc_rand_hash' ) || ! function_exists( 'wc_api_hash' ) ) {
return new WP_Error( 'adknot_wc_missing_functions', __( 'WooCommerce API helpers are unavailable.', 'adknot-connect' ) );
}
$table_name = $wpdb->prefix . 'woocommerce_api_keys';
$table_check = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
if ( $table_name !== $table_check ) {
return new WP_Error( 'adknot_wc_table_missing', __( 'WooCommerce API table was not found.', 'adknot-connect' ) );
}
$allowed_permissions = array( 'read', 'write', 'read_write' );
$permissions = in_array( $permissions, $allowed_permissions, true ) ? $permissions : 'read';
$consumer_key = 'ck_' . wc_rand_hash();
$consumer_secret = 'cs_' . wc_rand_hash();
$description = sprintf(
/* translators: %s date string. */
__( 'AdKnot Connect - API key (%s)', 'adknot-connect' ),
gmdate( 'Y-m-d H:i:s' )
);
$inserted = $wpdb->insert(
$table_name,
array(
'user_id' => (int) $user_id,
'description' => sanitize_text_field( $description ),
'permissions' => $permissions,
'consumer_key' => wc_api_hash( $consumer_key ),
'consumer_secret' => $consumer_secret,
'truncated_key' => substr( $consumer_key, -7 ),
),
array( '%d', '%s', '%s', '%s', '%s', '%s' )
);
if ( false === $inserted || ! $wpdb->insert_id ) {
return new WP_Error( 'adknot_wc_key_error', __( 'Failed to generate WooCommerce API keys.', 'adknot-connect' ) );
}
return array(
'key_id' => (int) $wpdb->insert_id,
'permissions' => $permissions,
'consumer_key' => $consumer_key,
'consumer_secret' => $consumer_secret,
);
}
/**
* Revoke WooCommerce API key by ID.
*
* @param int $key_id Key ID.
* @return bool
*/
private function revoke_wc_api_key( $key_id ) {
if ( $key_id <= 0 ) {
return false;
}
global $wpdb;
$deleted = $wpdb->delete(
$wpdb->prefix . 'woocommerce_api_keys',
array( 'key_id' => (int) $key_id ),
array( '%d' )
);
return false !== $deleted;
}
/**
* Build backend payload.
*
* @param string $api_token API token from merchant.
* @param array $key_data Generated WooCommerce key data.
* @return array
*/
private function build_payload( $api_token, array $key_data ) {
$timezone = wp_timezone_string();
if ( '' === $timezone ) {
$timezone = 'UTC';
}
return array(
'apiToken' => $api_token,
'api_key' => $api_token,
'api' => $api_token,
'accessToken' => $api_token,
'shopDomain' => home_url(),
'shop_domain' => home_url(),
'storeUrl' => home_url( '/' ),
'platform' => 'woocommerce',
'consumer_key' => $key_data['consumer_key'],
'consumer_secret' => $key_data['consumer_secret'],
'permissions' => $key_data['permissions'],
'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '',
'timezone' => $timezone,
'siteName' => get_bloginfo( 'name' ),
'pluginVersion' => self::VERSION,
);
}
/**
* Build disconnect payload.
*
* @return array
*/
private function build_disconnect_payload() {
$api_token = (string) get_option( self::OPTION_API_TOKEN, '' );
return array(
'apiToken' => $api_token,
'api_key' => $api_token,
'accessToken' => $api_token,
'shopDomain' => home_url(),
'shop_domain' => home_url(),
'storeUrl' => home_url( '/' ),
'platform' => 'woocommerce',
);
}
/**
* Parse backend response body and return the most useful error text.
*
* @param array $response wp_remote_post response.
* @return string
*/
private function parse_backend_error( array $response ) {
$default_message = sprintf(
/* translators: %d is HTTP status code. */
__( 'Connection failed. Service returned HTTP %d.', 'adknot-connect' ),
(int) wp_remote_retrieve_response_code( $response )
);
$body = wp_remote_retrieve_body( $response );
if ( empty( $body ) || ! is_string( $body ) ) {
return $default_message;
}
$data = json_decode( $body, true );
if ( ! is_array( $data ) ) {
return $default_message;
}
$possible_message_keys = array( 'message', 'error', 'detail' );
foreach ( $possible_message_keys as $message_key ) {
if ( isset( $data[ $message_key ] ) && is_string( $data[ $message_key ] ) && '' !== trim( $data[ $message_key ] ) ) {
return sanitize_text_field( $data[ $message_key ] );
}
}
return $default_message;
}
/**
* Post payload to backend with render fallback support.
*
* @param string $path Endpoint path.
* @param array $payload Request payload.
* @param int $timeout Timeout in seconds.
* @return array|WP_Error
*/
private function post_to_backend( $path, array $payload, $timeout = 30 ) {
$timeout = max( 5, (int) $timeout );
$args = array(
'headers' => array(
'Content-Type' => 'application/json; charset=' . get_bloginfo( 'charset' ),
),
'body' => wp_json_encode( $payload ),
'timeout' => $timeout,
);
$api_bases = $this->get_api_base_candidates();
$last_error = null;
$last_response = null;
foreach ( $api_bases as $api_base ) {
$endpoint = $this->build_endpoint_url( $api_base, $path );
$response = $this->post_with_retry( $endpoint, $args );
if ( is_wp_error( $response ) ) {
$last_error = $response;
continue;
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( $code >= 200 && $code < 300 ) {
return $response;
}
if ( $this->should_fallback_to_next_backend( $code ) ) {
$last_response = $response;
continue;
}
return $response;
}
if ( null !== $last_response ) {
return $last_response;
}
if ( null !== $last_error ) {
return $last_error;
}
return new WP_Error( 'adknot_backend_request_failed', __( 'Connection service is unavailable.', 'adknot-connect' ) );
}
/**
* Post request with one retry for transient timeout/network errors.
*
* @param string $endpoint Request endpoint URL.
* @param array $args wp_remote_post args.
* @return array|WP_Error
*/
private function post_with_retry( $endpoint, array $args ) {
$response = wp_remote_post( $endpoint, $args );
if ( ! is_wp_error( $response ) ) {
return $response;
}
if ( ! $this->should_retry_backend_request( $response ) ) {
return $response;
}
return wp_remote_post( $endpoint, $args );
}
/**
* Decide if response status should trigger fallback to next backend.
*
* @param int $code HTTP status code.
* @return bool
*/
private function should_fallback_to_next_backend( $code ) {
$code = (int) $code;
if ( $code >= 500 && $code < 600 ) {
return true;
}
return in_array( $code, array( 404, 408, 429 ), true );
}
/**
* Check if a WP HTTP error should be retried once.
*
* @param WP_Error $error Request error.
* @return bool
*/
private function should_retry_backend_request( WP_Error $error ) {
$message = strtolower( $error->get_error_message() );
return false !== strpos( $message, 'curl error 28' )
|| false !== strpos( $message, 'timed out' )
|| false !== strpos( $message, 'connection timed out' )
|| false !== strpos( $message, 'failed to connect' )
|| false !== strpos( $message, 'could not resolve host' );
}
/**
* Resolve candidate backend API bases in order.
*
* @return array
*/
private function get_api_base_candidates() {
$primary = $this->get_api_base();
$bases = array(
$primary,
self::FALLBACK_API_BASE,
);
if ( $this->is_local_environment() ) {
$local_base = apply_filters( 'adknot_connect_local_api_base', self::LOCAL_API_BASE );
if ( is_string( $local_base ) && '' !== trim( $local_base ) ) {
array_unshift( $bases, trim( $local_base ) );
}
}
$bases = apply_filters( 'adknot_connect_api_base_candidates', $bases, $primary );
if ( ! is_array( $bases ) ) {
$bases = array( $primary, self::FALLBACK_API_BASE );
}
$clean_bases = array();
foreach ( $bases as $base ) {
if ( ! is_string( $base ) ) {
continue;
}
$base = trim( $base );
if ( '' === $base || ! wp_http_validate_url( $base ) ) {
continue;
}
$clean_bases[] = untrailingslashit( esc_url_raw( $base ) );
}
if ( empty( $clean_bases ) ) {
$clean_bases[] = self::DEFAULT_API_BASE;
}
return array_values( array_unique( $clean_bases ) );
}
/**
* Check whether the current site is running in a local/development environment.
*
* @return bool
*/
private function is_local_environment() {
if ( function_exists( 'wp_get_environment_type' ) ) {
$environment = wp_get_environment_type();
if ( in_array( $environment, array( 'local', 'development' ), true ) ) {
return true;
}
}
$host = wp_parse_url( home_url(), PHP_URL_HOST );
if ( ! is_string( $host ) || '' === $host ) {
return false;
}
$host = strtolower( $host );
return in_array( $host, array( 'localhost', '127.0.0.1' ), true )
|| false !== strpos( $host, '.local' )
|| false !== strpos( $host, '.test' );
}
/**
* Get API base URL with filter override.
*
* @return string
*/
private function get_api_base() {
$api_base = apply_filters( 'adknot_connect_api_base', self::DEFAULT_API_BASE );
$api_base = is_string( $api_base ) ? trim( $api_base ) : self::DEFAULT_API_BASE;
if ( '' === $api_base || ! wp_http_validate_url( $api_base ) ) {
$api_base = self::DEFAULT_API_BASE;
}
return untrailingslashit( esc_url_raw( $api_base ) );
}
/**
* Get connect endpoint URL.
*
* @param string $api_base API base URL.
* @return string
*/
private function get_connect_endpoint( $api_base = '' ) {
$base_url = is_string( $api_base ) && '' !== $api_base ? $api_base : $this->get_api_base();
$url = $this->build_endpoint_url( $base_url, self::CONNECT_PATH );
$url = apply_filters( 'adknot_connect_backend_endpoint', $url );
return esc_url_raw( apply_filters( 'adknot_connect_endpoint', $url ) );
}
/**
* Get disconnect endpoint URL.
*
* @param string $api_base API base URL.
* @return string
*/
private function get_disconnect_endpoint( $api_base = '' ) {
$base_url = is_string( $api_base ) && '' !== $api_base ? $api_base : $this->get_api_base();
$url = $this->build_endpoint_url( $base_url, self::DISCONNECT_PATH );
return esc_url_raw( apply_filters( 'adknot_connect_disconnect_endpoint', $url ) );
}
/**
* Build endpoint URL from API base or full endpoint.
*
* @param string $base_or_endpoint API base or full endpoint URL.
* @param string $path Expected route path.
* @return string
*/
private function build_endpoint_url( $base_or_endpoint, $path ) {
$url = esc_url_raw( (string) $base_or_endpoint );
$parsed = wp_parse_url( $url );
$existing_path = isset( $parsed['path'] ) ? '/' . ltrim( (string) $parsed['path'], '/' ) : '';
if ( '' !== $existing_path ) {
$target_length = strlen( $path );
$existing_tail = $target_length > 0 ? substr( $existing_path, -1 * $target_length ) : '';
if ( $existing_path === $path || $existing_tail === $path ) {
return $url;
}
}
return untrailingslashit( $url ) . $path;
}
/**
* Check connected status.
*
* @return bool
*/
private function is_connected() {
return 'connected' === get_option( self::OPTION_STATUS, 'disconnected' ) && '' !== get_option( self::OPTION_API_TOKEN, '' );
}
/**
* Permission check.
*
* @return bool
*/
private function can_manage_plugin() {
return current_user_can( 'manage_woocommerce' ) || current_user_can( 'manage_options' );
}
/**
* Store one-time admin notice.
*
* @param string $type Notice type.
* @param string $message Notice message.
* @return void
*/
private function set_notice( $type, $message ) {
$allowed_types = array( 'success', 'error', 'info' );
$type = in_array( $type, $allowed_types, true ) ? $type : 'info';
set_transient(
self::NOTICE_TRANSIENT_PREFIX . get_current_user_id(),
array(
'type' => $type,
'message' => $message,
),
5 * MINUTE_IN_SECONDS
);
}
/**
* Read and clear one-time admin notice.
*
* @return array|array
*/
private function consume_notice() {
$notice_key = self::NOTICE_TRANSIENT_PREFIX . get_current_user_id();
$notice = get_transient( $notice_key );
if ( false !== $notice ) {
delete_transient( $notice_key );
}
if ( ! is_array( $notice ) || empty( $notice['message'] ) ) {
return array();
}
return array(
'type' => isset( $notice['type'] ) ? sanitize_key( $notice['type'] ) : 'info',
'message' => sanitize_text_field( $notice['message'] ),
);
}
/**
* Mask API token for status card.
*
* @param string $token Full token.
* @return string
*/
private function mask_token( $token ) {
if ( '' === $token ) {
return __( 'Not set', 'adknot-connect' );
}
$length = strlen( $token );
if ( $length <= 8 ) {
return str_repeat( '*', $length );
}
return str_repeat( '*', $length - 4 ) . substr( $token, -4 );
}
}
register_activation_hook( __FILE__, array( 'AdKnot_Connect', 'activate' ) );
AdKnot_Connect::instance();