'POST', 'callback' => [$this, 'search'], // Public product search: no authentication, but every input is typed and bounded. 'permission_callback' => '__return_true', 'args' => self::searchArgs(), ]); } /** Request limits for /facetcore/v1/search; violations are rejected by core with HTTP 400. */ private static function searchArgs(): array { return apply_filters('facetcore_rest_search_args', [ 'query' => ['type' => 'string', 'maxLength' => 200], 'page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 1000000], 'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], // Facet ID => value ID; Finder supports at most ten simultaneous facets. 'filters' => ['type' => 'object', 'maxProperties' => 10, 'additionalProperties' => ['type' => 'integer', 'minimum' => 1]], ]); } public function search(\WP_REST_Request $request) { $filters = $request->get_param('filters'); $filters = is_array($filters) ? $filters : []; $page = is_scalar($request->get_param('page')) ? max(1, min(1000000, (int) $request->get_param('page'))) : 1; $perPage = is_scalar($request->get_param('per_page')) ? (int) $request->get_param('per_page') : 20; $query = $request->get_param('query'); if ($query !== null && !is_string($query)) { return new \WP_Error('facetcore_invalid_search', __('Enter a valid search phrase.', 'facetcore'), ['status' => 400]); } $query = mb_substr(sanitize_text_field($query ?? ''), 0, 200); global $wpdb; $previousErrors = $wpdb->suppress_errors(true); try { $repo = new Repository(); foreach ([$repo->facetTable(), $repo->valueTable(), $repo->productValueTable()] as $table) { if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) !== $table) { throw new \RuntimeException('Index unavailable.'); } } $config = TableConfiguration::effective(array_diff_key(TableConfiguration::defaults(), array_flip(TableConfiguration::extensionKeys()))); $resolved = null; $rawId = $request->get_param('table_id'); if ($rawId !== null) { if (!is_scalar($rawId) || !preg_match('/^[1-9][0-9]*$/D', (string) $rawId)) { return new \WP_Error('facetcore_invalid_table', __('Finder unavailable.', 'facetcore'), ['status' => 404]); } $config = TableConfiguration::get((int) $rawId); if (!$config) { return new \WP_Error('facetcore_invalid_table', __('Finder unavailable.', 'facetcore'), ['status' => 404]); } $resolved = TableConfiguration::resolve($config); $perPage = $config['per_page']; } if ($resolved && !$config['enable_search']) { $query = ''; } $filters = apply_filters('facetcore_request_filters', $filters, $request, $resolved, $repo); $result = (new Finder(null, null, null, $resolved['facets'] ?? null, $resolved ? TableConfiguration::categoryScope($config) : null, $query))->search($filters, $page, $perPage); $result['query'] = $query; $result['settings'] = array_intersect_key($config, array_flip(['show_counts', 'enable_search', 'views', 'default_view', 'allow_switch', 'grid_sku'])); if ($resolved !== null) { $result['columns'] = $resolved['columns']; $result['settings'] = array_intersect_key($config, array_flip(['show_counts', 'enable_search', 'views', 'default_view', 'allow_switch', 'grid_sku'])); $result['grid_attributes'] = $resolved['grid_attributes']; if (!$resolved['settings']['hide_impossible']) { $allValues = (new Repository())->valuesForFacets(array_column($result['facets'], 'facet_id')); foreach ($result['facets'] as &$facet) { $present = array_column($facet['values'], 'id'); foreach ($allValues[$facet['facet_id']] ?? [] as $value) { if (!in_array((int) $value['id'], $present, true)) { $facet['values'][] = ['id' => (int) $value['id'], 'label' => $value['label'], 'products' => 0]; } } } unset($facet); } } $result = apply_filters('facetcore_search_result', $result, $resolved); $result['products'] = $this->products($result['ids'], $result['facets'], array_merge($resolved['columns'] ?? [], $resolved['grid_attributes'] ?? [])); unset($result['ids']); return rest_ensure_response($result); } catch (\Throwable $e) { return new \WP_Error('facetcore_search_failed', __('Unable to load products.', 'facetcore'), ['status' => 500]); } finally { $wpdb->suppress_errors($previousErrors); } } private function products(array $ids, array $facets, array $columns = []): array { $products = []; // Prime native caches once for the bounded result page, before Woo product hydration. if ($ids) { _prime_post_caches($ids, true, true); } $facetIds = array_map(static function (array $facet): int { return (int) $facet['facet_id']; }, $facets); $parameters = (new Repository())->labelsForProducts($ids, $facetIds); $attributeRows = apply_filters('facetcore_product_attributes', [], $ids, $columns); foreach ($ids as $id) { $product = wc_get_product($id); if (!$product || !$product->is_visible()) { continue; } $attributes = []; foreach ($columns as $column) { if (isset($column['taxonomy'])) { $attributes[$column['key']] = $attributeRows[$id][$column['key']] ?? []; } } $products[] = [ 'attributes' => $attributes, 'id' => $product->get_id(), 'name' => $product->get_name(), 'sku' => $product->get_sku(), // The client inserts WooCommerce price markup as HTML, including filtered prices. 'price_html' => wp_kses_post($product->get_price_html()), 'stock_status' => $product->get_stock_status(), // JSON consumers escape attributes; reject unsafe URL protocols here as well. 'permalink' => esc_url_raw($product->get_permalink()), 'image' => esc_url_raw(wp_get_attachment_image_url($product->get_image_id(), 'woocommerce_thumbnail') ?: wc_placeholder_img_src('woocommerce_thumbnail')), 'add_to_cart_url' => esc_url_raw($product->is_type('simple') && $product->is_purchasable() && $product->is_in_stock() ? add_query_arg('add-to-cart', $product->get_id(), $product->get_permalink()) : $product->get_permalink()), // WooCommerce supplies (and translates) the label where its own text matches: simple purchasable in-stock // products and purchasable variable products ("Select options"). Other cases keep FacetCore's labels. 'add_to_cart_text' => $product->is_type('variable') ? ($product->is_purchasable() ? $product->add_to_cart_text() : __('Select options', 'facetcore')) : ($product->is_type('simple') && $product->is_purchasable() && $product->is_in_stock() ? $product->add_to_cart_text() : __('View product', 'facetcore')), 'parameters' => $parameters[$product->get_id()] ?? [], ]; } return $products; } }