/api/storefront/embed/*`). * Bearer key in the Authorization header only — never in the URL, never logged. * * @package AooShop */ defined( 'ABSPATH' ) || exit; class AooShop_Api_Client { const TIMEOUT = 8; /** @var AooShop_Settings */ private $settings; public function __construct( AooShop_Settings $settings ) { $this->settings = $settings; } /** * GET an embed endpoint. * * @param string $endpoint 'page' | 'ping' | 'cards' | 'sitemap' | 'llms' | 'summary' | 'data/*' | 'cart-token//status' | * 'admin/products' | 'admin/orders' | 'admin/orders/' (segments encoded one by one). * @param array $query Query args (raw values — encoded here). * @param string|null $etag Sent as If-None-Match. * @return array{code:int, data:mixed, etag:string, raw:string}|WP_Error code 304 → data null. * Non-2xx/304 → WP_Error with data {status:int, retryable:bool}. */ public function get( $endpoint, array $query = array(), $etag = null ) { if ( ! $this->settings->is_configured() ) { return new WP_Error( 'aoo_not_configured', __( 'The AOO API key is not set.', 'aoo-shop' ), array( 'status' => 0, 'retryable' => false ) ); } $moved = $this->moved_error(); if ( $moved ) { return $moved; } $url = $this->url( $endpoint ); $q = array(); foreach ( $query as $k => $v ) { if ( null === $v || '' === $v ) { continue; } $q[] = rawurlencode( (string) $k ) . '=' . rawurlencode( (string) $v ); } if ( $q ) { $url .= '?' . implode( '&', $q ); } $headers = array( 'Authorization' => 'Bearer ' . $this->settings->api_key(), 'Accept' => 'application/json', ); if ( $etag ) { $headers['If-None-Match'] = $etag; } $response = wp_remote_get( $url, array( 'timeout' => self::TIMEOUT, 'redirection' => 0, 'headers' => $headers, 'user-agent' => 'aoo-shop/' . AOOSHOP_VERSION . '; ' . home_url( '/' ), ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'aoo_http', $response->get_error_message(), array( 'status' => 0, 'retryable' => true ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $raw = (string) wp_remote_retrieve_body( $response ); $tag = (string) wp_remote_retrieve_header( $response, 'etag' ); if ( 304 === $code ) { return array( 'code' => 304, 'data' => null, 'etag' => $tag ? $tag : (string) $etag, 'raw' => '' ); } $ctype = (string) wp_remote_retrieve_header( $response, 'content-type' ); $data = ( false !== strpos( $ctype, 'json' ) || '' === $ctype ) ? json_decode( $raw, true ) : $raw; if ( $code < 200 || $code >= 300 ) { $err_code = is_array( $data ) && ! empty( $data['error'] ) ? (string) $data['error'] : 'http_' . $code; $message = is_array( $data ) && ! empty( $data['message'] ) ? (string) $data['message'] : $this->http_message( $code ); // Feature answers (aoo is up, its DB lacks the RPC yet) are not outages → never open the cache circuit. $feature = in_array( $err_code, array( 'summary_unavailable', 'summary_disabled', 'orders_disabled', 'order_not_found' ), true ); return new WP_Error( 'aoo_' . sanitize_key( $err_code ), $message, array( 'status' => $code, 'retryable' => ! $feature && ( $code >= 500 || 429 === $code ) ) ); } if ( false !== strpos( $ctype, 'json' ) && ! is_array( $data ) ) { return new WP_Error( 'aoo_bad_json', __( 'AOO returned data that could not be read.', 'aoo-shop' ), array( 'status' => $code, 'retryable' => true ) ); } return array( 'code' => $code, 'data' => $data, 'etag' => $tag, 'raw' => $raw ); } /** * A public site whose aoo address is a developer machine (copied from Local with the dev constant) — never call * it: every request would hang for TIMEOUT seconds. Callers fall back to the stale copy (class-cache.php). * * @return WP_Error|null */ private function moved_error() { if ( 'local_api' !== $this->settings->moved_reason() ) { return null; } return new WP_Error( 'aoo_site_moved', $this->settings->moved_message(), array( 'status' => 0, 'retryable' => false ) ); } /** * Plain Thai for an HTTP status without an aoo JSON error body. 404 = the address answers but is not aoo's API * (a wrong AOOSHOP_API_BASE, or a site/proxy in between). */ private function http_message( $code ) { if ( 404 === (int) $code ) { return sprintf( /* translators: %s: production aoo address */ __( 'This domain is not the AOO API. Use %s', 'aoo-shop' ), AooShop_Settings::DEFAULT_API_BASE ); } return sprintf( 'HTTP %d', (int) $code ); } /** * Connect with a 6-character pairing code made in aoo (AOO › ตั้งค่า › หน้าร้าน › WordPress). No key needed — * aoo checks the code (10 min · single use · rate-limited per IP) and returns a brand-new `aoo_wp_…` key once. * * @param string $code What the owner typed. * @return array{key:string, label:string}|WP_Error */ public function pair( $code ) { $moved = $this->moved_error(); if ( $moved ) { return $moved; } $response = wp_remote_post( $this->settings->api_base() . '/api/storefront/embed/pair', array( 'timeout' => 15, 'redirection' => 0, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array( 'code' => (string) $code, 'site_url' => home_url( '/' ), 'plugin_version' => AOOSHOP_VERSION, ) ), 'user-agent' => 'aoo-shop/' . AOOSHOP_VERSION . '; ' . home_url( '/' ), ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'aoo_http', sprintf( /* translators: %s: error */ __( 'Could not reach AOO: %s', 'aoo-shop' ), $response->get_error_message() ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); $key = is_array( $data ) && isset( $data['key'] ) && is_string( $data['key'] ) ? $data['key'] : ''; if ( 200 === $status && preg_match( AooShop_Settings::KEY_PATTERN, $key ) ) { return array( 'key' => $key, 'label' => isset( $data['label'] ) ? sanitize_text_field( (string) $data['label'] ) : '', ); } $message = is_array( $data ) && ! empty( $data['message'] ) ? sanitize_text_field( (string) $data['message'] ) : $this->http_message( $status ); return new WP_Error( 'aoo_pair_' . ( is_array( $data ) && ! empty( $data['error'] ) ? sanitize_key( (string) $data['error'] ) : $status ), $message ); } /** Endpoint URL — each path segment encoded on its own (so `cart-token//status` keeps its slashes). */ private function url( $endpoint ) { $parts = array_map( 'rawurlencode', explode( '/', trim( (string) $endpoint, '/' ) ) ); return $this->settings->api_base() . '/api/storefront/embed/' . implode( '/', $parts ); } /** * POST JSON to an embed endpoint. Unlike get(), a non-2xx answer is NOT an error here — callers need the * body (cart-token 422 carries `dropped`). WP_Error only for "no answer at all" (network / not configured). * * @param string $endpoint e.g. 'cart-token'. * @param array $body JSON body. * @return array{code:int, data:mixed}|WP_Error */ public function post( $endpoint, array $body ) { if ( ! $this->settings->is_configured() ) { return new WP_Error( 'aoo_not_configured', __( 'The AOO API key is not set.', 'aoo-shop' ), array( 'status' => 0, 'retryable' => false ) ); } $moved = $this->moved_error(); if ( $moved ) { return $moved; } $response = wp_remote_post( $this->url( $endpoint ), array( 'timeout' => self::TIMEOUT, 'redirection' => 0, 'headers' => array( 'Authorization' => 'Bearer ' . $this->settings->api_key(), 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $body ), 'user-agent' => 'aoo-shop/' . AOOSHOP_VERSION . '; ' . home_url( '/' ), ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'aoo_http', $response->get_error_message(), array( 'status' => 0, 'retryable' => true ) ); } return array( 'code' => (int) wp_remote_retrieve_response_code( $response ), 'data' => json_decode( (string) wp_remote_retrieve_body( $response ), true ), ); } /** * Ping aoo, remember the result (company + WP config aoo uses to build links). * * @return array|WP_Error Ping payload. */ public function ping() { $res = $this->get( 'ping' ); $remote = $this->settings->remote(); if ( is_wp_error( $res ) ) { $remote['ok'] = false; $remote['at'] = time(); $remote['error'] = $res->get_error_message(); $this->settings->save_remote( $remote ); do_action( 'aooshop_pinged', array(), false ); return $res; } $data = is_array( $res['data'] ) ? $res['data'] : array(); $this->settings->save_remote( array( 'ok' => ! empty( $data['ok'] ), 'at' => time(), 'api_version' => isset( $data['api_version'] ) ? (int) $data['api_version'] : 0, 'company' => isset( $data['company'] ) && is_array( $data['company'] ) ? $data['company'] : array(), 'wordpress' => isset( $data['wordpress'] ) && is_array( $data['wordpress'] ) ? $data['wordpress'] : array(), 'accepting_orders' => ! empty( $data['accepting_orders'] ), // [{slug,label,title,min,max}] — whitelist for `?price=` and the budget pages (AooShop_Settings::price_buckets()). 'price_buckets' => isset( $data['price_buckets'] ) && is_array( $data['price_buckets'] ) ? array_values( $data['price_buckets'] ) : array(), ) ); if ( ! empty( $data['ok'] ) && isset( $data['wordpress'] ) && is_array( $data['wordpress'] ) ) { $this->settings->adopt_remote_paths( $data['wordpress'] ); } // Installs from before 0.6 have no remembered address — the first good ping sets it (no false "moved" alarm). if ( ! empty( $data['ok'] ) && '' === $this->settings->connected_home() ) { $this->settings->remember_home(); } // class-purge.php stores `purge_secret` from here (own option, never in `aooshop_remote`). do_action( 'aooshop_pinged', $data, ! empty( $data['ok'] ) ); return $data; } }