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();