'GET', 'callback' => [ $this, 'get_settings' ], 'permission_callback' => '__return_true', ] ); // Live suggestions for the search box block (v1.33.0). register_rest_route( self::NAMESPACE, '/suggest', [ 'methods' => 'GET', 'callback' => [ $this, 'get_suggestions' ], 'permission_callback' => '__return_true', 'args' => [ 'q' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'mode' => [ 'type' => 'string', 'default' => 'keyword', 'enum' => [ 'keyword', 'full' ], ], 'post_type' => [ 'type' => 'string', 'default' => '', ], 'limit' => [ 'type' => 'integer', 'default' => 6, ], ], ] ); // Force-refresh the quota cache (v1.46.2). Needed when a merchant // upgrades plans mid-session: the 6h quota transient keeps the plugin // on the pre-upgrade plan until the UI's Refresh button is clicked, // but the Fast Search gate reads from the same transient and had no // remote fix path. Callable with an application password that owns // `manage_options`, so support can unstick an install without the // merchant having to click through the admin. register_rest_route( self::NAMESPACE, '/refresh-quota', [ 'methods' => 'POST', 'callback' => [ $this, 'refresh_quota' ], 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, ] ); } /** * REST callback: drop the cached quota and fetch a fresh copy from the * service. Returns the resolved plan key so the caller can confirm the * upgrade took effect. * * @since 1.46.2 */ public function refresh_quota() { if ( ! class_exists( 'AI_Search_Quota_Manager' ) ) { return new WP_Error( 'quota_manager_missing', 'Quota manager not loaded', [ 'status' => 500 ] ); } delete_transient( 'ai_search_quota_data' ); $qm = new AI_Search_Quota_Manager(); $data = $qm->get_quota_data( true ); if ( null === $data ) { return new WP_Error( 'quota_fetch_failed', 'Could not fetch fresh quota from the AI Search Service.', [ 'status' => 502 ] ); } $plan_key = isset( $data['plan']['key'] ) ? (string) $data['plan']['key'] : null; $plan_label = isset( $data['plan']['label'] ) ? (string) $data['plan']['label'] : null; return [ 'success' => true, 'plan_key' => $plan_key, 'plan_label' => $plan_label, 'fast_search_available' => class_exists( 'AI_Search_Fast_Search' ) && AI_Search_Fast_Search::is_available_for_plan(), 'fast_search_enabled' => class_exists( 'AI_Search_Fast_Search' ) && AI_Search_Fast_Search::is_enabled(), ]; } /** * Minimum query length before we do any work. * * @since 1.33.0 */ const SUGGEST_MIN_CHARS = 3; /** * Suggestion requests allowed per IP per minute. * * @since 1.33.0 */ const SUGGEST_RATE_LIMIT = 30; /** * Hard daily cap on `mode=full` calls across the whole site. * * v1.44.1 security: the per-IP minute throttle only rate-limits one * attacker; a small botnet varying `q` on every call could drain the * site's monthly embedding quota in hours. This site-wide ceiling * demotes `mode=full` to `mode=keyword` for the rest of the UTC day * once reached, so the search dropdown keeps working (keyword hits) * but no further paid embeddings are spent on anonymous callers. * Filterable with `ai_search_suggest_full_daily_cap` for sites that * know their traffic and want to lift it. * * @since 1.44.1 */ const SUGGEST_FULL_DAILY_CAP = 500; /** * Build the transient key for a suggestion response. * * Pure helper so the caching contract can be unit-tested: the same * query in the same mode and post type must reuse one cache entry, * and different ones must never collide. * * @since 1.33.0 * * @param string $query Search query. * @param string $mode 'keyword' or 'full'. * @param string $post_type Post type filter, or ''. * @param int $limit Result cap. * @return string */ public static function suggest_cache_key( $query, $mode, $post_type, $limit ) { return 'ai_search_sug_' . md5( strtolower( trim( (string) $query ) ) . '|' . $mode . '|' . $post_type . '|' . (int) $limit ); } /** * Whether a query is worth running at all. * * @since 1.33.0 * * @param string $query Raw query. * @return bool */ public static function is_suggestable( $query ) { $query = trim( (string) $query ); return mb_strlen( $query ) >= self::SUGGEST_MIN_CHARS; } /** * Live search suggestions for the search box block. * * Two modes, so typing stays free and only a deliberate pause costs * an embedding: * * - `keyword`: WordPress' own matching plus the plugin's exact SKU and * title tier. The semantic filter is detached for the call, so no * embedding is requested at all. * - `full`: the complete tiered search (exact, keyword, AI), which * costs exactly one embedding. * * Results are shaped identically in both modes so the client can * replace one with the other without re-rendering the shell. * * @since 1.33.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function get_suggestions( WP_REST_Request $request ) { $query = (string) $request->get_param( 'q' ); $mode = (string) $request->get_param( 'mode' ); $post_type = (string) $request->get_param( 'post_type' ); $limit = max( 1, min( 12, (int) $request->get_param( 'limit' ) ) ); if ( ! self::is_suggestable( $query ) ) { return rest_ensure_response( [ 'query' => $query, 'mode' => $mode, 'results' => [] ] ); } if ( ! $this->check_suggest_rate_limit() ) { return new WP_REST_Response( [ 'query' => $query, 'mode' => $mode, 'results' => [], 'throttled' => true ], 429 ); } // v1.44.1 security: before touching the (expensive) embedding path, // enforce the site-wide daily cap on `mode=full`. If exceeded, serve // the request as `mode=keyword` instead — the dropdown still shows // results, no paid embedding is spent, and the attacker gets the // same cached shape regardless of how many unique queries they fire. if ( 'full' === $mode && ! $this->full_mode_within_daily_cap() ) { $mode = 'keyword'; } $cache_key = self::suggest_cache_key( $query, $mode, $post_type, $limit ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return rest_ensure_response( $cached ); } $args = [ 's' => $query, 'posts_per_page' => $limit, 'post_status' => 'publish', 'ignore_sticky_posts' => true, 'no_found_rows' => true, ]; if ( '' !== $post_type && post_type_exists( $post_type ) ) { $args['post_type'] = $post_type; } $ai = class_exists( 'AI_Search' ) ? AI_Search::get_instance() : null; // Keyword mode: detach the semantic filter so this costs nothing. if ( 'keyword' === $mode && $ai ) { remove_filter( 'posts_results', [ $ai, 'filter_search_results' ], 10 ); } // The search filter reads the terms from the query it is given, so // a secondary WP_Query gets the full tier treatment (since 1.28.1). $search = new WP_Query( $args ); if ( 'keyword' === $mode && $ai ) { add_filter( 'posts_results', [ $ai, 'filter_search_results' ], 10, 2 ); } $posts = self::cap_results( $search->posts, $limit ); $response = [ 'query' => $query, 'mode' => $mode, 'results' => array_map( function ( $post ) use ( $mode ) { return $this->shape_suggestion( $post, $mode ); }, $posts ), ]; set_transient( $cache_key, $response, 5 * MINUTE_IN_SECONDS ); return rest_ensure_response( $response ); } /** * Cap the result set the tier system returned. * * `filter_search_results` replaces the whole result set in * `posts_results` so that `found_posts` can be corrected, which means * the query's own `posts_per_page` no longer limits what comes back. A * dropdown asking for six rows would otherwise receive every match in * the catalogue. * * @since 1.33.1 * * @param array $posts Results from the query. * @param int $limit Maximum to return. * @return array */ public static function cap_results( $posts, $limit ) { if ( ! is_array( $posts ) ) { return []; } $limit = (int) $limit; if ( $limit < 1 ) { return []; } return array_slice( $posts, 0, $limit ); } /** * Shape one post for the suggestions dropdown. * * @since 1.33.0 * * @param WP_Post $post Result post. * @return array */ private function shape_suggestion( $post, $mode = 'full' ) { // In keyword mode no semantic pass ran, so nothing here is an AI // result - labelling it as one would be a lie in the dropdown. $tier = 'keyword' === $mode ? 'keyword' : 'ai'; if ( class_exists( 'AI_Search' ) ) { if ( AI_Search::is_exact_match( $post->ID ) ) { $tier = 'exact'; } elseif ( 'full' === $mode && method_exists( 'AI_Search', 'is_lexical_match' ) && AI_Search::is_lexical_match( $post->ID ) ) { $tier = 'keyword'; } } // Plain formatted price. get_price_html() carries "Original price // was..." screen-reader markup that reads as noise once stripped. $price = ''; if ( 'product' === $post->post_type && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $post->ID ); if ( $product ) { $amount = $product->get_price(); if ( '' !== $amount && null !== $amount && function_exists( 'wc_price' ) ) { $price = html_entity_decode( wp_strip_all_tags( wc_price( $amount ) ), ENT_QUOTES, 'UTF-8' ); } } } $thumb = get_the_post_thumbnail_url( $post->ID, 'thumbnail' ); return [ 'id' => (int) $post->ID, // wptexturize turns quotes and dashes into entities. The dropdown // inserts titles as text, so an undecoded title would render as // a literal `Aurora 34″ Ultrawide`. 'title' => html_entity_decode( wp_strip_all_tags( get_the_title( $post ) ), ENT_QUOTES, 'UTF-8' ), 'url' => get_permalink( $post ), 'image' => $thumb ? $thumb : '', 'price' => $price, 'tier' => $tier, ]; } /** * Simple per-IP rate limit so a public endpoint cannot be hammered. * * @since 1.33.0 * * @return bool True when the request may proceed. */ private function check_suggest_rate_limit() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : ''; $key = 'ai_search_sug_rl_' . md5( $ip ); $hits = (int) get_transient( $key ); if ( $hits >= self::SUGGEST_RATE_LIMIT ) { return false; } set_transient( $key, $hits + 1, MINUTE_IN_SECONDS ); return true; } /** * Site-wide daily ceiling on `mode=full` suggest calls. Returns true if * the request is within the cap and bumps the counter; false if the * cap is exhausted (the caller then auto-demotes to `mode=keyword`). * * @since 1.44.1 * * @return bool */ private function full_mode_within_daily_cap() { $cap = (int) apply_filters( 'ai_search_suggest_full_daily_cap', self::SUGGEST_FULL_DAILY_CAP ); if ( $cap <= 0 ) { return false; } $key = 'ai_search_sug_full_' . gmdate( 'Ymd' ); $used = (int) get_transient( $key ); if ( $used >= $cap ) { return false; } set_transient( $key, $used + 1, DAY_IN_SECONDS ); return true; } /** * Get plugin diagnostic settings (public endpoint). * * v1.44.1 security: the endpoint used to return WordPress + PHP version * strings (stack fingerprint for CVE scanners), the full list of custom * meta_keys indexed per post type (schema disclosure — a merchant that * indexed `_vendor_contact` or `_supplier_cost` would broadcast the * key name to the world), the WooCommerce field config, and a * `SELECT COUNT(*)` on wp_postmeta that was itself a cheap DoS * amplifier on large sites. The response now carries only boolean / * low-entropy flags that debuggers actually need, with no fingerprints * and no schema. The admin-only equivalent lives in the Insights tab. * * @return WP_REST_Response */ public function get_settings() { $settings = [ 'plugin_version' => AI_SEARCH_VERSION, 'woocommerce_active' => class_exists( 'WooCommerce' ), 'provider' => get_option( 'ai_search_provider', 'ai_service' ), 'has_api_key' => ! empty( get_option( 'ai_search_api_key', '' ) ), 'has_service_token' => ! empty( get_option( 'ai_search_service_token', '' ) ), 'openai_model' => get_option( 'ai_search_openai_model', 'text-embedding-3-small' ), 'similarity_threshold' => (float) get_option( 'ai_search_similarity_threshold', 0.30 ), 'badge_public' => (bool) get_option( 'ai_search_badge_public', true ), 'setup_completed' => (bool) get_option( 'ai_search_setup_completed', false ), ]; return rest_ensure_response( $settings ); } }