|price_min&price_max&q&sort&limit(≤48)&page * → {items:[{name,slug,url,price,price_max,compare_at,image,image_thumb,in_stock,sold_badge,rating}],total,page} * rating = {avg,count} of approved reviews · null = no reviews yet * GET /facets same filters → {total,categories:[{name,slug,n,url}],brands,collections:[{…,group}],price:[{slug,label,title,min,max,n,url}]} * 503 `facets_unavailable` while aoo can't count yet (never guessed numbers) * GET /collections ?group= → {items:[{name,slug,group,description,image,image_thumb,next_date,repeat_yearly,date_note, * days_left,count,url}]} — days_left computed HERE at response time (Asia/Bangkok), never cached * GET /suggest ?q=(≤40) → {categories:[{name,count|null,url}],collections:[{name,count,url}],products:[≤5 {name,url,price,image_thumb}]} * GET /reviews ?product= (none = latest of the whole shop) &min_rating=1–5 &limit(≤24 · 6) &page * → {summary:{avg,count,distribution[1★..5★]},items:[{rating,body,author,verified,date,images[{url,thumb}], * videos[],reply,product:{name,slug,url,image_thumb}|null}],page,has_more} — approved reviews only, no buyer data * * - every call proxies aoo `embed/data/*` with the Bearer key (server side only) through AooShop_Cache (type 'data': * aoo TTL 600 s · 7-day stale copy · purged by every aoo event) * - params are whitelisted/clamped BEFORE the cache key (junk never creates entries): slugs cleaned · price = a bucket * slug from the ping, or numeric price_min/price_max 0–10,000,000 · sort whitelist · limit ≤48 · page ≤1000 · q ≤40 * - URLs are localized to this site's active routes (AooShop_Renderer::localize — preview prefix, dev host) * - output fields are whitelisted again here: never a key, never an internal id * - rate limit per IP: 120/min (suggest 60/min) — `aooshop_data_rate_limit` filter * - preview mode: answers only users who may preview (admins; REST needs the X-WP-Nonce the plugin JS sends) — 404 * for everyone else, exactly like the shop routes. PHP helpers (functions.php) follow the same rule. * * @package AooShop */ defined( 'ABSPATH' ) || exit; class AooShop_Data_Api { const LIMIT = 120; const SUGGEST_LIMIT = 60; const PRODUCTS_MAX = 48; const PRODUCTS_DEF = 12; const Q_MAX = 40; const PRICE_MAX = 10000000; const PAGE_MAX = 1000; const GROUP_MAX = 60; const REVIEWS_MAX = 24; const REVIEWS_DEF = 6; const FACETS_NA = 'aooshop_facets_na'; const FACETS_NA_TTL = 300; const TZ = 'Asia/Bangkok'; /** @var AooShop_Settings */ private $settings; /** @var AooShop_Cache */ private $cache; /** @var AooShop_Renderer */ private $renderer; public function __construct( AooShop_Settings $settings, AooShop_Cache $cache, AooShop_Renderer $renderer ) { $this->settings = $settings; $this->cache = $cache; $this->renderer = $renderer; } public function hooks() { add_action( 'aooshop_rest_routes', array( $this, 'routes' ) ); add_filter( 'aooshop_purge_list_types', array( $this, 'purge_types' ) ); add_action( 'aooshop_collections_imported', array( $this, 'after_import' ) ); } /** Collections imported from Woo (class-woo-collections.php) → drop data + listing caches (fresh copy only). */ public function after_import() { delete_transient( self::FACETS_NA ); $this->cache->purge( function ( $entry ) { return isset( $entry['type'] ) && in_array( $entry['type'], array( 'data', 'index', 'collection', 'budget', 'cards' ), true ); }, true ); } public function purge_types( $types ) { $types = (array) $types; $types[] = 'data'; return array_values( array_unique( $types ) ); } // ── who may read ────────────────────────────────────────────────── /** Live: everyone · preview: users who may preview (admins / self-check) + editors inside an editor. */ public function can_serve() { if ( ! $this->settings->is_preview() ) { return true; } if ( $this->renderer->can_preview() ) { return true; } return current_user_can( 'edit_posts' ) && AooShop_Cards::in_editor(); } // ── normalize (whitelist + clamp — before anything reaches the cache key) ───── /** Free-text query: trimmed, whitespace collapsed, ≤ 40 chars. */ public static function clean_q( $raw ) { if ( ! is_scalar( $raw ) ) { return ''; } $q = trim( preg_replace( '/\s+/u', ' ', wp_strip_all_tags( wp_check_invalid_utf8( (string) $raw ) ) ) ); return function_exists( 'mb_substr' ) ? mb_substr( $q, 0, self::Q_MAX ) : substr( $q, 0, self::Q_MAX ); } private static function clean_price_number( $raw ) { if ( ! is_scalar( $raw ) || '' === trim( (string) $raw ) || ! is_numeric( $raw ) ) { return ''; } $n = (float) $raw; if ( $n < 0 || $n > self::PRICE_MAX ) { return ''; } return (string) ( floor( $n ) == $n ? (int) $n : round( $n, 2 ) ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual } /** * Shared filters of /products and /facets → args for aoo (only set keys). * * @param array $raw {collection?, category?, brand?, price?, price_min?, price_max?, q?}. */ public function normalize_filters( array $raw ) { $out = array(); foreach ( array( 'collection', 'category', 'brand' ) as $k ) { $v = isset( $raw[ $k ] ) && is_scalar( $raw[ $k ] ) ? AooShop_Cards::clean_slug( rawurldecode( (string) $raw[ $k ] ) ) : ''; if ( '' !== $v && strlen( $v ) <= 200 ) { $out[ $k ] = $v; } } $price = isset( $raw['price'] ) && is_scalar( $raw['price'] ) ? $this->settings->valid_price( $raw['price'] ) : ''; if ( '' !== $price ) { $out['price'] = $price; } else { $min = isset( $raw['price_min'] ) ? self::clean_price_number( $raw['price_min'] ) : ''; $max = isset( $raw['price_max'] ) ? self::clean_price_number( $raw['price_max'] ) : ''; if ( '' !== $min && '' !== $max && (float) $min > (float) $max ) { list( $min, $max ) = array( $max, $min ); } if ( '' !== $min ) { $out['price_min'] = $min; } if ( '' !== $max ) { $out['price_max'] = $max; } } $q = isset( $raw['q'] ) ? self::clean_q( $raw['q'] ) : ''; if ( '' !== $q ) { $out['q'] = $q; } return $out; } /** /products args: filters + sort + limit + page. */ public function normalize_products( array $raw ) { $out = $this->normalize_filters( $raw ); $sort = isset( $raw['sort'] ) ? AooShop_Settings::valid_sort( $raw['sort'], isset( $out['collection'] ) ? 'collection' : '' ) : ''; if ( '' !== $sort ) { $out['sort'] = $sort; } $limit = isset( $raw['limit'] ) && is_numeric( $raw['limit'] ) ? (int) $raw['limit'] : self::PRODUCTS_DEF; $out['limit'] = max( 1, min( self::PRODUCTS_MAX, $limit ) ); $page = isset( $raw['page'] ) && is_numeric( $raw['page'] ) ? (int) $raw['page'] : 1; if ( $page > 1 ) { $out['page'] = min( self::PAGE_MAX, $page ); } return $out; } public static function clean_group( $raw ) { if ( ! is_scalar( $raw ) ) { return ''; } $g = trim( preg_replace( '/\s+/u', ' ', sanitize_text_field( (string) $raw ) ) ); return function_exists( 'mb_substr' ) ? mb_substr( $g, 0, self::GROUP_MAX ) : substr( $g, 0, self::GROUP_MAX ); } // ── data (array | WP_Error) — used by REST, shortcodes and functions.php ── private function url( $u ) { return is_string( $u ) && '' !== $u ? esc_url_raw( $this->renderer->localize( $u ) ) : ''; } private static function str( $v ) { return is_scalar( $v ) && null !== $v ? (string) $v : ''; } private static function num( $v ) { return is_numeric( $v ) ? $v + 0 : null; } private static function img( $v ) { return is_string( $v ) && preg_match( '#^https?://#i', $v ) ? esc_url_raw( $v ) : null; } /** {avg, count} of approved reviews · null = none (or junk). */ private static function rating( $v ) { if ( ! is_array( $v ) || ! isset( $v['avg'], $v['count'] ) || ! is_numeric( $v['avg'] ) || (int) $v['count'] < 1 ) { return null; } return array( 'avg' => max( 0, min( 5, round( (float) $v['avg'], 1 ) ) ), 'count' => (int) $v['count'], ); } /** /reviews args: product slug · min_rating 1–5 · limit ≤24 · page (only set keys). */ public function normalize_reviews( array $raw ) { $out = array(); $slug = isset( $raw['product'] ) && is_scalar( $raw['product'] ) ? AooShop_Cards::clean_slug( rawurldecode( (string) $raw['product'] ) ) : ''; if ( '' !== $slug && strlen( $slug ) <= 200 ) { $out['product'] = $slug; } $min = isset( $raw['min_rating'] ) && is_numeric( $raw['min_rating'] ) ? (int) $raw['min_rating'] : 0; if ( $min >= 1 && $min <= 5 ) { $out['min_rating'] = $min; } $limit = isset( $raw['limit'] ) && is_numeric( $raw['limit'] ) ? (int) $raw['limit'] : self::REVIEWS_DEF; $out['limit'] = max( 1, min( self::REVIEWS_MAX, $limit ) ); $page = isset( $raw['page'] ) && is_numeric( $raw['page'] ) ? (int) $raw['page'] : 1; if ( $page > 1 ) { $out['page'] = min( self::PAGE_MAX, $page ); } return $out; } /** * Approved product reviews — one product (`product` slug) or the latest of the whole shop. * * @param array $raw See normalize_reviews(). * @return array{summary:array, items:array, page:int, has_more:bool}|WP_Error */ public function reviews( array $raw ) { $args = $this->normalize_reviews( $raw ); $res = $this->cache->get_data( 'data/reviews', $args ); if ( is_wp_error( $res ) ) { return $res; } $p = $res['payload']; $sum = isset( $p['summary'] ) && is_array( $p['summary'] ) ? $p['summary'] : array(); $dist = array( 0, 0, 0, 0, 0 ); if ( isset( $sum['distribution'] ) && is_array( $sum['distribution'] ) ) { foreach ( array_slice( array_values( $sum['distribution'] ), 0, 5 ) as $i => $n ) { $dist[ $i ] = max( 0, (int) $n ); } } $items = array(); foreach ( isset( $p['items'] ) && is_array( $p['items'] ) ? $p['items'] : array() as $r ) { if ( ! is_array( $r ) || ! isset( $r['rating'] ) ) { continue; } $images = array(); foreach ( isset( $r['images'] ) && is_array( $r['images'] ) ? array_slice( $r['images'], 0, 5 ) : array() as $im ) { $url = is_array( $im ) ? self::img( isset( $im['url'] ) ? $im['url'] : null ) : null; if ( $url ) { $images[] = array( 'url' => $url, 'thumb' => self::img( isset( $im['thumb'] ) ? $im['thumb'] : null ), ); } } $videos = array(); foreach ( isset( $r['videos'] ) && is_array( $r['videos'] ) ? array_slice( $r['videos'], 0, 2 ) : array() as $v ) { if ( is_string( $v ) && preg_match( '#^https://#i', $v ) ) { $videos[] = esc_url_raw( $v ); } } $prod = null; if ( isset( $r['product'] ) && is_array( $r['product'] ) && ! empty( $r['product']['slug'] ) ) { $prod = array( 'name' => self::str( isset( $r['product']['name'] ) ? $r['product']['name'] : '' ), 'slug' => rawurldecode( self::str( $r['product']['slug'] ) ), 'url' => $this->url( isset( $r['product']['url'] ) ? $r['product']['url'] : '' ), 'image_thumb' => self::img( isset( $r['product']['image_thumb'] ) ? $r['product']['image_thumb'] : null ), ); } $items[] = array( 'rating' => max( 1, min( 5, (int) $r['rating'] ) ), 'body' => self::str( isset( $r['body'] ) ? $r['body'] : '' ), 'author' => self::str( isset( $r['author'] ) ? $r['author'] : '' ), 'verified' => ! empty( $r['verified'] ), 'date' => self::str( isset( $r['date'] ) ? $r['date'] : '' ), 'images' => $images, 'videos' => $videos, 'reply' => isset( $r['reply'] ) && is_string( $r['reply'] ) && '' !== $r['reply'] ? $r['reply'] : null, 'product' => $prod, ); } return array( 'summary' => array( 'avg' => isset( $sum['avg'] ) && is_numeric( $sum['avg'] ) ? round( (float) $sum['avg'], 2 ) : 0, 'count' => isset( $sum['count'] ) ? max( 0, (int) $sum['count'] ) : 0, 'distribution' => $dist, ), 'items' => $items, 'page' => isset( $p['page'] ) ? max( 1, (int) $p['page'] ) : 1, 'has_more' => ! empty( $p['has_more'] ), ); } /** * Products. * * @param array $raw See normalize_products(). * @return array{items:array, total:int, page:int}|WP_Error */ public function products( array $raw ) { $args = $this->normalize_products( $raw ); $res = $this->cache->get_data( 'data/products', $args ); if ( is_wp_error( $res ) ) { return $res; } $p = $res['payload']; $items = array(); foreach ( isset( $p['items'] ) && is_array( $p['items'] ) ? $p['items'] : array() as $it ) { if ( ! is_array( $it ) || empty( $it['slug'] ) ) { continue; } $items[] = array( 'name' => self::str( isset( $it['name'] ) ? $it['name'] : '' ), 'slug' => rawurldecode( self::str( $it['slug'] ) ), 'url' => $this->url( isset( $it['url'] ) ? $it['url'] : '' ), 'price' => self::num( isset( $it['price'] ) ? $it['price'] : null ), 'price_max' => self::num( isset( $it['price_max'] ) ? $it['price_max'] : null ), 'compare_at' => self::num( isset( $it['compare_at'] ) ? $it['compare_at'] : null ), 'image' => self::img( isset( $it['image'] ) ? $it['image'] : null ), 'image_thumb' => self::img( isset( $it['image_thumb'] ) ? $it['image_thumb'] : null ), 'in_stock' => ! empty( $it['in_stock'] ), 'sold_badge' => isset( $it['sold_badge'] ) && is_string( $it['sold_badge'] ) && '' !== $it['sold_badge'] ? $it['sold_badge'] : null, 'rating' => self::rating( isset( $it['rating'] ) ? $it['rating'] : null ), ); } return array( 'items' => $items, 'total' => isset( $p['total'] ) ? max( 0, (int) $p['total'] ) : count( $items ), 'page' => isset( $p['page'] ) ? max( 1, (int) $p['page'] ) : 1, ); } /** * Filter counts. WP_Error `facets_unavailable` while aoo can't count (remembered 5 min — no hammering). * * @param array $raw See normalize_filters(). * @return array|WP_Error */ public function facets( array $raw ) { if ( get_transient( self::FACETS_NA ) ) { return new WP_Error( 'facets_unavailable', __( 'Filter counts are not available yet.', 'aoo-shop' ), array( 'status' => 503 ) ); } $args = $this->normalize_filters( $raw ); $res = $this->cache->get_data( 'data/facets', $args ); if ( is_wp_error( $res ) ) { if ( 'aoo_facets_unavailable' === $res->get_error_code() ) { set_transient( self::FACETS_NA, 1, self::FACETS_NA_TTL ); return new WP_Error( 'facets_unavailable', __( 'Filter counts are not available yet.', 'aoo-shop' ), array( 'status' => 503 ) ); } return $res; } $p = $res['payload']; $term = function ( $list, $with_group = false ) { $out = array(); foreach ( is_array( $list ) ? $list : array() as $x ) { if ( ! is_array( $x ) || empty( $x['slug'] ) ) { continue; } $row = array( 'name' => self::str( isset( $x['name'] ) ? $x['name'] : '' ), 'slug' => rawurldecode( self::str( $x['slug'] ) ), 'n' => isset( $x['n'] ) ? max( 0, (int) $x['n'] ) : 0, 'url' => $this->url( isset( $x['url'] ) ? $x['url'] : '' ), ); if ( $with_group ) { $row['group'] = isset( $x['group'] ) && is_string( $x['group'] ) && '' !== $x['group'] ? $x['group'] : null; } $out[] = $row; } return $out; }; $price = array(); foreach ( isset( $p['price'] ) && is_array( $p['price'] ) ? $p['price'] : array() as $b ) { if ( ! is_array( $b ) || empty( $b['slug'] ) || ! preg_match( '/^\d+-\d*$/', self::str( $b['slug'] ) ) ) { continue; } $price[] = array( 'slug' => self::str( $b['slug'] ), 'label' => self::str( isset( $b['label'] ) ? $b['label'] : $b['slug'] ), 'title' => self::str( isset( $b['title'] ) ? $b['title'] : '' ), 'min' => self::num( isset( $b['min'] ) ? $b['min'] : 0 ), 'max' => self::num( isset( $b['max'] ) ? $b['max'] : null ), 'n' => isset( $b['n'] ) ? max( 0, (int) $b['n'] ) : 0, 'url' => $this->url( isset( $b['url'] ) ? $b['url'] : '' ), ); } return array( 'total' => isset( $p['total'] ) ? max( 0, (int) $p['total'] ) : 0, 'categories' => $term( isset( $p['categories'] ) ? $p['categories'] : array() ), 'brands' => $term( isset( $p['brands'] ) ? $p['brands'] : array() ), 'collections' => $term( isset( $p['collections'] ) ? $p['collections'] : array(), true ), 'price' => $price, ); } /** * Collections (+ days_left at call time — the cached payload only holds next_date / repeat_yearly). * * @param string $group Exact group label ('' = all). * @return array{items:array}|WP_Error */ public function collections( $group = '' ) { $group = self::clean_group( $group ); $res = $this->cache->get_data( 'data/collections', '' !== $group ? array( 'group' => $group ) : array() ); if ( is_wp_error( $res ) ) { return $res; } $today = self::today(); $items = array(); foreach ( isset( $res['payload']['items'] ) && is_array( $res['payload']['items'] ) ? $res['payload']['items'] : array() as $c ) { if ( ! is_array( $c ) || empty( $c['slug'] ) ) { continue; } $date = isset( $c['next_date'] ) && is_string( $c['next_date'] ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $c['next_date'] ) ? $c['next_date'] : null; $repeat = ! empty( $c['repeat_yearly'] ); $items[] = array( 'name' => self::str( isset( $c['name'] ) ? $c['name'] : '' ), 'slug' => rawurldecode( self::str( $c['slug'] ) ), 'group' => isset( $c['group'] ) && is_string( $c['group'] ) && '' !== $c['group'] ? $c['group'] : null, 'description' => isset( $c['description'] ) && is_string( $c['description'] ) && '' !== $c['description'] ? $c['description'] : null, 'image' => self::img( isset( $c['image'] ) ? $c['image'] : null ), 'image_thumb' => self::img( isset( $c['image_thumb'] ) ? $c['image_thumb'] : null ), 'next_date' => $date, 'repeat_yearly' => $repeat, 'date_note' => isset( $c['date_note'] ) && is_string( $c['date_note'] ) && '' !== $c['date_note'] ? $c['date_note'] : null, 'next_occurrence' => self::next_occurrence( $date, $repeat, $today ), 'days_left' => self::days_left( $date, $repeat, $today ), 'count' => isset( $c['count'] ) ? max( 0, (int) $c['count'] ) : 0, 'url' => $this->url( isset( $c['url'] ) ? $c['url'] : '' ), ); } return array( 'items' => $items ); } /** * Autocomplete. * * @return array{categories:array, collections:array, products:array}|WP_Error */ public function suggest( $q ) { $q = self::clean_q( $q ); if ( '' === $q ) { return array( 'categories' => array(), 'collections' => array(), 'products' => array() ); } $res = $this->cache->get_data( 'data/suggest', array( 'q' => $q ) ); if ( is_wp_error( $res ) ) { return $res; } $p = $res['payload']; $out = array( 'categories' => array(), 'collections' => array(), 'products' => array() ); foreach ( array( 'categories', 'collections' ) as $k ) { foreach ( isset( $p[ $k ] ) && is_array( $p[ $k ] ) ? $p[ $k ] : array() as $x ) { if ( ! is_array( $x ) || empty( $x['url'] ) ) { continue; } $out[ $k ][] = array( 'name' => self::str( isset( $x['name'] ) ? $x['name'] : '' ), 'count' => isset( $x['count'] ) && is_numeric( $x['count'] ) ? (int) $x['count'] : null, 'url' => $this->url( $x['url'] ), ); } } foreach ( isset( $p['products'] ) && is_array( $p['products'] ) ? array_slice( $p['products'], 0, 5 ) : array() as $x ) { if ( ! is_array( $x ) || empty( $x['url'] ) ) { continue; } $out['products'][] = array( 'name' => self::str( isset( $x['name'] ) ? $x['name'] : '' ), 'url' => $this->url( $x['url'] ), 'price' => self::num( isset( $x['price'] ) ? $x['price'] : null ), 'image_thumb' => self::img( isset( $x['image_thumb'] ) ? $x['image_thumb'] : null ), ); } return $out; } // ── festival countdown (same rule as aoo lib/collections.ts nextOccurrence / daysLeft) ── /** Today in Thailand (YYYY-MM-DD) — aoo counts in Thai time whatever the site timezone is. */ public static function today() { $now = new DateTimeImmutable( 'now', new DateTimeZone( self::TZ ) ); return $now->format( 'Y-m-d' ); } private static function ymd_parts( $s ) { if ( ! is_string( $s ) || ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $s, $m ) ) { return null; } $y = (int) $m[1]; $mo = (int) $m[2]; $d = (int) $m[3]; if ( $mo < 1 || $mo > 12 || $d < 1 || $d > 31 ) { return null; } return array( $y, $mo, $d ); } private static function ymd( $y, $m, $d ) { $dim = (int) gmdate( 't', gmmktime( 0, 0, 0, $m, 1, $y ) ); return sprintf( '%04d-%02d-%02d', $y, $m, min( $d, $dim ) ); } /** * Next festival date on/after today: the date itself if not past · repeat → roll forward whole years * (29 Feb → 28 Feb in non-leap years) · non-repeat in the past → null. */ public static function next_occurrence( $next_date, $repeat, $today = null ) { $base = self::ymd_parts( $next_date ); $t = self::ymd_parts( null === $today ? self::today() : $today ); if ( ! $base || ! $t ) { return null; } $first = self::ymd( $base[0], $base[1], $base[2] ); $now = sprintf( '%04d-%02d-%02d', $t[0], $t[1], $t[2] ); if ( $first >= $now ) { return $first; } if ( ! $repeat ) { return null; } for ( $y = max( $base[0] + 1, $t[0] ); $y <= $t[0] + 1; $y++ ) { $cand = self::ymd( $y, $base[1], $base[2] ); if ( $cand >= $now ) { return $cand; } } return null; } /** Days until the next occurrence (0 = today) · null = no countdown. */ public static function days_left( $next_date, $repeat, $today = null ) { $today = null === $today ? self::today() : $today; $next = self::ymd_parts( self::next_occurrence( $next_date, $repeat, $today ) ); $t = self::ymd_parts( $today ); if ( ! $next || ! $t ) { return null; } return (int) round( ( gmmktime( 0, 0, 0, $next[1], $next[2], $next[0] ) - gmmktime( 0, 0, 0, $t[1], $t[2], $t[0] ) ) / DAY_IN_SECONDS ); } /** "อีก N วัน" / "วันนี้" / '' (no countdown). */ public static function countdown_label( $days ) { if ( null === $days ) { return ''; } return 0 === (int) $days ? __( 'Today', 'aoo-shop' ) /* translators: %s: number of days */ : sprintf( _n( 'In %s day', 'In %s days', (int) $days, 'aoo-shop' ), number_format_i18n( (int) $days ) ); } // ── REST ───────────────────────────────────────────────────────── public function routes( $ns ) { $public = '__return_true'; // Public by design — guarded by the preview gate + IP rate limit + whitelists. $text = array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ); $slug = array( 'type' => 'string', 'sanitize_callback' => array( __CLASS__, 'sanitize_slug_param' ), ); $int = array( 'type' => 'integer', 'minimum' => 0, 'sanitize_callback' => 'absint', ); $num = array( 'type' => 'number', 'minimum' => 0, ); $filter = array( 'collection' => $slug, 'category' => $slug, 'brand' => $slug, 'price' => $text, // Whitelisted against the shop's price buckets in normalize(). 'price_min' => $num, 'price_max' => $num, 'q' => $text, ); $args = array( 'products' => $filter + array( 'sort' => $text, // Whitelisted by AooShop_Settings::valid_sort(). 'limit' => $int, 'page' => $int, ), 'facets' => $filter, 'collections' => array( 'group' => $text ), 'suggest' => array( 'q' => $text ), 'reviews' => array( 'product' => $slug, 'min_rating' => $int, 'limit' => $int, 'page' => $int, ), ); foreach ( $args as $name => $route_args ) { register_rest_route( $ns, '/' . $name, array( 'methods' => 'GET', 'callback' => array( $this, 'rest_' . $name ), 'permission_callback' => $public, 'args' => $route_args, ) ); } } private static function reply( $data, $code = 200, $max_age = 0 ) { $r = new WP_REST_Response( $data, $code ); $r->header( 'Cache-Control', $max_age > 0 ? 'public, max-age=' . (int) $max_age : 'no-store' ); $r->header( 'X-Robots-Tag', 'noindex' ); return $r; } private static function fail( $code, $error, $message ) { return self::reply( array( 'error' => $error, 'message' => $message ), $code ); } /** Gate + rate limit. null = go on · WP_REST_Response = stop. */ private function guard( $bucket, $limit ) { if ( ! $this->can_serve() ) { return self::fail( 404, 'not_found', __( 'Not found.', 'aoo-shop' ) ); } $limit = (int) apply_filters( 'aooshop_data_rate_limit', $limit, $bucket ); if ( $limit > 0 && AooShop_Rest::rate_limited( $bucket, $limit ) ) { return self::fail( 429, 'rate_limited', __( 'Too many requests. Please wait a moment.', 'aoo-shop' ) ); } return null; } /** Browser cache for live mode (preview answers are per-admin → no-store). */ private function max_age( $seconds ) { return $this->settings->is_preview() ? 0 : $seconds; } private function error_reply( WP_Error $e ) { if ( 'facets_unavailable' === $e->get_error_code() ) { return self::fail( 503, 'facets_unavailable', $e->get_error_message() ); } // The aoo error text may mention the key / config — admins only. $msg = current_user_can( 'manage_options' ) ? $e->get_error_message() : __( 'Shop data is temporarily unavailable.', 'aoo-shop' ); return self::fail( 502, 'aoo_unavailable', $msg ); } /** Slug parameter: same Thai-safe cleaning as normalize_filters() (decoded first so %E0… slugs survive). */ public static function sanitize_slug_param( $value ) { return AooShop_Cards::clean_slug( rawurldecode( sanitize_text_field( (string) $value ) ) ); } private static function params( WP_REST_Request $request, array $keys ) { $out = array(); foreach ( $keys as $k ) { $v = $request->get_param( $k ); if ( null !== $v && is_scalar( $v ) ) { $out[ $k ] = $v; } } return $out; } const FILTER_PARAMS = array( 'collection', 'category', 'brand', 'price', 'price_min', 'price_max', 'q' ); public function rest_products( WP_REST_Request $request ) { $stop = $this->guard( 'dp', self::LIMIT ); if ( $stop ) { return $stop; } $data = $this->products( self::params( $request, array_merge( self::FILTER_PARAMS, array( 'sort', 'limit', 'page' ) ) ) ); return is_wp_error( $data ) ? $this->error_reply( $data ) : self::reply( $data, 200, $this->max_age( 300 ) ); } public function rest_facets( WP_REST_Request $request ) { $stop = $this->guard( 'df', self::LIMIT ); if ( $stop ) { return $stop; } $data = $this->facets( self::params( $request, self::FILTER_PARAMS ) ); return is_wp_error( $data ) ? $this->error_reply( $data ) : self::reply( $data, 200, $this->max_age( 300 ) ); } public function rest_collections( WP_REST_Request $request ) { $stop = $this->guard( 'dc', self::LIMIT ); if ( $stop ) { return $stop; } $p = self::params( $request, array( 'group' ) ); $data = $this->collections( isset( $p['group'] ) ? $p['group'] : '' ); // days_left changes at Thai midnight — keep browser caching short. return is_wp_error( $data ) ? $this->error_reply( $data ) : self::reply( $data, 200, $this->max_age( 300 ) ); } public function rest_suggest( WP_REST_Request $request ) { $stop = $this->guard( 'ds', self::SUGGEST_LIMIT ); if ( $stop ) { return $stop; } $p = self::params( $request, array( 'q' ) ); $data = $this->suggest( isset( $p['q'] ) ? $p['q'] : '' ); return is_wp_error( $data ) ? $this->error_reply( $data ) : self::reply( $data, 200, $this->max_age( 120 ) ); } public function rest_reviews( WP_REST_Request $request ) { $stop = $this->guard( 'dr', self::LIMIT ); if ( $stop ) { return $stop; } $data = $this->reviews( self::params( $request, array( 'product', 'min_rating', 'limit', 'page' ) ) ); return is_wp_error( $data ) ? $this->error_reply( $data ) : self::reply( $data, 200, $this->max_age( 300 ) ); } }