this site). The brain never keeps our secret key * (it stores only a hash), so it has nothing to present to us. This class * mints a dedicated catalog token, registers it with the brain * (authenticated by the secret key, outbound), and serves * GET /wp-json/aisthetix/v1/catalog to requests presenting that token. * The token's scope is product listings only — a brain-side leak exposes * the shop window, not orders or privacy endpoints. This is the plugin's * FIRST inbound REST route: the permission callback is a constant-time * compare against the stored token and involves no WordPress auth state. * * 2. PLACEMENT RECEIPTS (this site -> brain). WooCommerce has no theme * manifest the brain could inspect, so "the widget is live" is proven by * a receipt: when the storefront actually emits the trigger markup on a * real product page, we report it (throttled, non-blocking). The brain * cross-checks the receipt's origin against the connected site, which is * what keeps a cloned staging database (same keys, different URL) from * ever claiming activation. * * @package Aisthetix\TryOn */ namespace Aisthetix\TryOn; defined( 'ABSPATH' ) || exit; /** * Catalog connector + placement receipts. */ class Connector { /** * Option holding the catalog token (server-only, like the secret key). * * @var string */ const OPTION_CATALOG_TOKEN = 'aisthetix_tryon_catalog_token'; /** * Option holding the signature of the last successful registration, so we * re-push only when the token, site URL, or plugin version changed. * * @var string */ const OPTION_REGISTERED = 'aisthetix_tryon_catalog_registered'; /** * Transient throttling registration retries after a failure. * * @var string */ const TRANSIENT_REGISTER_RETRY = 'aisthetix_tryon_catalog_register_retry'; /** * Transient throttling placement receipts. * * @var string */ const TRANSIENT_PLACEMENT = 'aisthetix_tryon_placement_ping'; /** * Seconds between placement receipts. Short enough that the dashboard's * activation check (which polls while the merchant visits their product * page) sees a fresh receipt within one page view. * * @var int */ const PLACEMENT_THROTTLE_SECONDS = 300; /** * Maximum products served by the catalog endpoint. * * @var int */ const CATALOG_LIMIT = 50; /** * Register hooks. * * @return void */ public function init() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_action( 'admin_init', array( $this, 'maybe_register_token' ) ); } /** * Register the catalog REST route. * * @return void */ public function register_routes() { register_rest_route( 'aisthetix/v1', '/catalog', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_catalog' ), 'permission_callback' => array( $this, 'verify_catalog_request' ), ) ); } /** * Permission callback: constant-time compare of the presented token * against the stored one. No cookies, no nonces, no WP users involved. * * @param \WP_REST_Request $request Incoming request. * @return bool */ public function verify_catalog_request( $request ) { $stored = (string) get_option( self::OPTION_CATALOG_TOKEN, '' ); if ( '' === $stored || ! Plugin::is_connected() ) { return false; } $presented = (string) $request->get_header( 'X-Aisthetix-Catalog-Token' ); if ( '' === $presented ) { return false; } return hash_equals( $stored, $presented ); } /** * Serve the catalogue: up to CATALOG_LIMIT published products, newest * first, with the real price/currency and NO placeholder images (a * missing image must read as missing so the brain can rank it * ineligible instead of demoing a placeholder). * * @return \WP_REST_Response|array */ public function handle_catalog( $request = null ) { // The token permission callback applies to search and ID hydration alike. if ( null === $request || null === $request->get_param( 'page' ) ) { return rest_ensure_response( $this->catalog_payload() ); } $page = filter_var( $request->get_param( 'page' ), FILTER_VALIDATE_INT ); $search = $request->get_param( 'search' ); $ids = $request->get_param( 'include' ); if ( false === $page || $page < 1 || $page > 10000 || ( null !== $search && ( ! is_string( $search ) || strlen( $search ) > 200 ) ) ) { return new \WP_Error( 'invalid_query', 'Invalid catalog query.', array( 'status' => 400 ) ); } $include = array(); if ( null !== $ids && '' !== $ids ) { if ( ! is_string( $ids ) || strlen( $ids ) > 1700 || ! preg_match( '/^[1-9][0-9]{0,15}(,[1-9][0-9]{0,15})*$/D', $ids ) || count( explode( ',', $ids ) ) > 100 ) { return new \WP_Error( 'invalid_query', 'Invalid product IDs.', array( 'status' => 400 ) ); } $include = array_map( 'intval', explode( ',', $ids ) ); } // WP_Query's search is parameterized by WordPress and excludes variations. $query = new \WP_Query( array( 'post_type' => 'product', 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), 'posts_per_page' => empty( $include ) ? 25 : 100, 'paged' => $page, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ids', 's' => is_string( $search ) ? $search : '', 'post__in' => $include, ) ); $products = array_filter( array_map( 'wc_get_product', $query->posts ) ); $payload = $this->catalog_payload( $products ); $payload['pagination'] = array( 'page' => $page, 'hasNextPage' => $page < $query->max_num_pages ); return rest_ensure_response( $payload ); } /** * Build the catalog envelope. * * @param array|null $products Optional product list override (tests). * @return array */ public function catalog_payload( $products = null ) { if ( null === $products ) { $products = function_exists( 'wc_get_products' ) ? wc_get_products( array( 'status' => 'publish', 'limit' => self::CATALOG_LIMIT, 'orderby' => 'date', 'order' => 'DESC', ) ) : array(); } $serialized = array(); foreach ( $products as $product ) { $row = $this->serialize_product( $product ); if ( null !== $row ) { $serialized[] = $row; } } return array( 'site' => home_url(), 'pluginVersion' => AISTHETIX_TRYON_VERSION, 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : null, 'placementMode' => Plugin::get_setting( 'placement', 'after_add_to_cart' ), 'products' => $serialized, ); } /** * Serialize one product for the brain. * * Price rules: a simple product reports its price; a variable product * reports its MINIMUM variation price (mirrors the storefront "from" * price); an empty price reports null, never 0 — the brain's simulation * renders nothing rather than a fake number. * * @param object $product WC_Product (duck-typed for tests). * @return array|null */ public function serialize_product( $product ) { if ( ! is_object( $product ) || ! method_exists( $product, 'get_id' ) ) { return null; } $image_id = (int) $product->get_image_id(); $image_url = null; if ( $image_id > 0 ) { $url = wp_get_attachment_image_url( $image_id, 'large' ); if ( is_string( $url ) && '' !== $url ) { $image_url = $url; } } $price = null; if ( $product->is_type( 'variable' ) && method_exists( $product, 'get_variation_price' ) ) { $min = $product->get_variation_price( 'min' ); if ( '' !== (string) $min ) { $price = (string) $min; } } else { $raw = $product->get_price(); if ( '' !== (string) $raw ) { $price = (string) $raw; } } $categories = array(); $terms = get_the_terms( $product->get_id(), 'product_cat' ); if ( is_array( $terms ) ) { foreach ( $terms as $term ) { $categories[] = array( 'id' => isset( $term->term_id ) ? (int) $term->term_id : 0, 'name' => (string) $term->name, 'slug' => (string) $term->slug, ); } } $tags = array(); $tag_terms = get_the_terms( $product->get_id(), 'product_tag' ); if ( is_array( $tag_terms ) ) { foreach ( $tag_terms as $term ) { $tags[] = (string) $term->name; } } $permalink = get_permalink( $product->get_id() ); $variants = $this->serialize_size_variants( $product ); return array( 'id' => (int) $product->get_id(), 'name' => (string) $product->get_name(), 'slug' => (string) $product->get_slug(), 'type' => (string) $product->get_type(), 'status' => (string) $product->get_status(), 'price' => $price, 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : null, 'image' => null === $image_url ? null : array( 'url' => $image_url ), 'permalink' => is_string( $permalink ) ? $permalink : null, 'categories' => $categories, 'tags' => $tags, 'variants' => $variants, ); } /** * Export only variations whose size attribute can be identified. The brain * uses this advisory catalogue data for the same guide/label coverage check * available to Shopify merchants; the live storefront still sends its own * current variation list when a shopper asks for a recommendation. * * @param object $product WC_Product. * @return array */ private function serialize_size_variants( $product ) { if ( ! $product->is_type( 'variable' ) || ! method_exists( $product, 'get_children' ) || ! function_exists( 'wc_get_product' ) ) { return array(); } $size_names = array( 'size', 'sizes', 'taglia', 'taglie', 'talla', 'tallas', 'taille', 'tailles', 'groesse', 'grosse', 'tamanho', 'maat' ); $out = array(); foreach ( $product->get_children() as $variation_id ) { $variation = wc_get_product( $variation_id ); if ( ! is_object( $variation ) || ! method_exists( $variation, 'get_attributes' ) ) { continue; } $label = ''; foreach ( $variation->get_attributes() as $attribute => $value ) { $raw = str_replace( array( 'attribute_', 'pa_' ), '', (string) $attribute ); $normalised = function_exists( 'sanitize_title' ) ? sanitize_title( $raw ) : strtolower( trim( $raw ) ); if ( in_array( $normalised, $size_names, true ) ) { $label = (string) $value; break; } } if ( '' === $label ) { continue; } $out[] = array( 'variant_id' => (string) $variation_id, 'size_label' => $label, 'available' => method_exists( $variation, 'is_in_stock' ) && method_exists( $variation, 'is_purchasable' ) ? $variation->is_in_stock() && $variation->is_purchasable() : true, ); } return $out; } /** * The stored catalog token, minting one on first use. * * @return string */ public function ensure_token() { $token = (string) get_option( self::OPTION_CATALOG_TOKEN, '' ); if ( '' !== $token ) { return $token; } $token = 'ct_' . wp_generate_password( 40, false, false ); update_option( self::OPTION_CATALOG_TOKEN, $token, false ); return $token; } /** * The signature a successful registration stores: re-push happens only * when the token, the site URL, or the plugin version changes. * * @param string $token Catalog token. * @return string */ public function registration_signature( $token ) { return md5( $token . '|' . home_url() . '|' . AISTHETIX_TRYON_VERSION ); } /** * Whether a registration push is due. * * @param string $token Catalog token. * @return bool */ public function registration_due( $token ) { if ( ! Plugin::is_connected() ) { return false; } if ( get_transient( self::TRANSIENT_REGISTER_RETRY ) ) { return false; } return get_option( self::OPTION_REGISTERED, '' ) !== $this->registration_signature( $token ); } /** * Push the catalog token to the brain when unregistered/changed. * * Runs on admin_init: cheap when up to date (one option compare), and a * failed push retries on a later admin load (throttled). This makes an * EXISTING connected merchant onboarding-capable right after updating * the plugin — no re-connect required. * * @return void */ public function maybe_register_token() { if ( ! Plugin::is_connected() ) { return; } $token = $this->ensure_token(); if ( ! $this->registration_due( $token ) ) { return; } $response = wp_remote_post( Plugin::brain_url() . '/api/connect/catalog-token', array( 'timeout' => 8, 'headers' => array( 'Content-Type' => 'application/json', 'X-Aisthetix-Secret' => Plugin::secret_key(), ), 'body' => wp_json_encode( array( 'catalogToken' => $token, 'catalogUrl' => rest_url( 'aisthetix/v1/catalog' ), 'site' => home_url(), 'pluginVersion' => AISTHETIX_TRYON_VERSION, ) ), ) ); if ( ! is_wp_error( $response ) && 200 === (int) wp_remote_retrieve_response_code( $response ) ) { update_option( self::OPTION_REGISTERED, $this->registration_signature( $token ), false ); return; } // Failed (older brain, network...): retry later, never spam. set_transient( self::TRANSIENT_REGISTER_RETRY, 1, 15 * MINUTE_IN_SECONDS ); } /** * Report that the widget trigger markup was actually emitted on a * frontend product page. Throttled and non-blocking: a receipt is a * heartbeat, never a request the shopper waits on. * * @param string $surface One of auto|shortcode|gutenberg_block|elementor. * @param int $product_id Product the markup rendered on. * @return void */ public function report_placement( $surface, $product_id ) { if ( ! Plugin::is_connected() ) { return; } if ( get_transient( self::TRANSIENT_PLACEMENT ) ) { return; } set_transient( self::TRANSIENT_PLACEMENT, 1, self::PLACEMENT_THROTTLE_SECONDS ); wp_remote_post( Plugin::brain_url() . '/api/storefront/placement', array( 'timeout' => 5, 'blocking' => false, 'headers' => array( 'Content-Type' => 'application/json', 'X-Aisthetix-Secret' => Plugin::secret_key(), ), 'body' => wp_json_encode( array( 'site' => home_url(), 'surface' => (string) $surface, 'productId' => (int) $product_id, 'pluginVersion' => AISTHETIX_TRYON_VERSION, ) ), ) ); } /** * Remove everything this class stores. Called on disconnect and from * uninstall: the token belongs to the CONNECTION — a reconnect mints a * fresh one and registers it against the fresh keys. * * @return void */ public static function clear() { delete_option( self::OPTION_CATALOG_TOKEN ); delete_option( self::OPTION_REGISTERED ); delete_transient( self::TRANSIENT_REGISTER_RETRY ); delete_transient( self::TRANSIENT_PLACEMENT ); } }