`, 30 requests per minute, filter `zadex/search/rate_limit`) → 429. * Filter `zadex/search/query_args` adjusts the WP_Query arguments, `zadex/search/result` each item. */ final class SearchController { public const NS = 'zadex/v1'; public const WINDOW = 60; /** Test seam: WP_Query factory `fn(array $args): object{posts, found_posts}`. */ public static $query = null; public function register(): void { add_action('rest_api_init', [$this, 'routes']); } public function routes(): void { register_rest_route(self::NS, '/search', [ 'methods' => 'GET', 'callback' => [$this, 'search'], // Public on purpose: the live-search widget is used by visitors. It is read-only and returns only // published, non-password-protected posts of public searchable post types (post_status/has_password in // search()); every argument is sanitized; requests are rate limited per IP. 'permission_callback' => '__return_true', 'args' => [ 'q' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field'], 'post_types' => ['type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_text_field'], 'limit' => ['type' => 'integer', 'required' => false, 'sanitize_callback' => 'absint'], ], ]); } public static function min_length(): int { return max(1, (int) apply_filters('zadex/search/min_length', 2)); } /** @return array searchable public post types */ public static function searchable_types(): array { $types = function_exists('get_post_types') ? array_values((array) get_post_types(['public' => true, 'exclude_from_search' => false])) : ['post', 'page']; return array_values(array_diff(array_map('strval', $types), ['attachment', 'zadex_template', 'zadex_library', 'zadex_popup'])); } /** Requested post types limited to the searchable ones (empty request = all of them). */ public static function post_types(string $requested): array { $allowed = self::searchable_types(); $wanted = array_filter(array_map(static fn($t) => sanitize_key(trim((string) $t)), explode(',', $requested))); if (!$wanted) { return $allowed; } return array_values(array_intersect($allowed, $wanted)); } /** True when the IP made too many requests in the current window (the request is counted). */ public static function rate_limited(string $ip): bool { $limit = (int) apply_filters('zadex/search/rate_limit', 30); if ($limit <= 0) { return false; } $key = 'zadex_search_rl_' . md5($ip !== '' ? $ip : 'unknown'); $state = get_transient($key); $now = time(); if (!is_array($state) || !isset($state['start'], $state['count']) || $now - (int) $state['start'] >= self::WINDOW) { $state = ['start' => $now, 'count' => 0]; } $state['count'] = (int) $state['count'] + 1; set_transient($key, $state, self::WINDOW); return $state['count'] > $limit; } public static function client_ip(): string { $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])) : ''; return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : ''; } /** @return WP_REST_Response|WP_Error */ public function search(WP_REST_Request $request) { $q = trim(preg_replace('/\s+/u', ' ', (string) $request['q']) ?? ''); $q = function_exists('mb_substr') ? mb_substr($q, 0, 100) : substr($q, 0, 100); $length = function_exists('mb_strlen') ? mb_strlen($q) : strlen($q); if ($length < self::min_length()) { return new WP_Error('zadex_search_short', __('Type a few more characters.', 'zadex'), ['status' => 400]); } if (self::rate_limited(self::client_ip())) { return new WP_Error('zadex_search_rate', __('Too many searches. Please wait a moment.', 'zadex'), ['status' => 429]); } $types = self::post_types((string) $request['post_types']); if (!$types) { return new WP_REST_Response(['results' => [], 'total' => 0, 'viewAll' => self::view_all($q, '')]); } $limit = (int) $request['limit']; $limit = $limit > 0 ? min(10, $limit) : 5; $args = apply_filters('zadex/search/query_args', [ 's' => $q, 'post_type' => $types, 'post_status' => 'publish', 'has_password' => false, 'posts_per_page' => $limit, 'ignore_sticky_posts' => true, 'no_found_rows' => false, 'orderby' => 'relevance', ], $q); // The query args could be widened by a filter: private content is never returned. $args['post_status'] = 'publish'; $args['has_password'] = false; $query = self::$query ? (self::$query)($args) : new \WP_Query($args); $results = []; foreach ((array) ($query->posts ?? []) as $post) { $post = is_object($post) ? $post : get_post((int) $post); if (!$post || ($post->post_status ?? '') !== 'publish' || !empty($post->post_password)) { continue; } $id = (int) $post->ID; $type_obj = function_exists('get_post_type_object') ? get_post_type_object((string) $post->post_type) : null; $thumb = function_exists('get_the_post_thumbnail_url') ? get_the_post_thumbnail_url($id, 'thumbnail') : ''; $item = [ 'id' => $id, 'title' => html_entity_decode(wp_strip_all_tags((string) get_the_title($id)), ENT_QUOTES, 'UTF-8'), 'url' => (string) get_permalink($id), 'thumbnail' => is_string($thumb) ? $thumb : '', 'type' => (string) $post->post_type, 'typeLabel' => is_object($type_obj) && isset($type_obj->labels->singular_name) ? (string) $type_obj->labels->singular_name : (string) $post->post_type, ]; $results[] = apply_filters('zadex/search/result', $item, $post); } $post_type_param = count($types) === 1 ? $types[0] : ''; return new WP_REST_Response([ 'results' => $results, 'total' => (int) ($query->found_posts ?? count($results)), 'viewAll' => self::view_all($q, $post_type_param), ]); } /** The normal WordPress search page for the query. */ public static function view_all(string $q, string $post_type): string { $args = ['s' => $q]; if ($post_type !== '') { $args['post_type'] = $post_type; } return (string) home_url('/') . '?' . http_build_query($args, '', '&', PHP_QUERY_RFC3986); } }