PluginProbe
ElasticPress / 4.4.0
ElasticPress v4.4.0
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / includes / classes / Feature / Autosuggest / Autosuggest.php

Autosuggest.php in ElasticPress 4.4.0, at includes/classes/Feature/Autosuggest/Autosuggest.php

943 lines 30.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Autosuggest feature
4 *
5 * phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
6 *
7 * @package elasticpress
8 */
9
10 namespace ElasticPress\Feature\Autosuggest;
11
12 use ElasticPress\Feature as Feature;
13 use ElasticPress\Features as Features;
14 use ElasticPress\Utils as Utils;
15 use ElasticPress\FeatureRequirementsStatus as FeatureRequirementsStatus;
16 use ElasticPress\Indexables as Indexables;
17 use ElasticPress\Elasticsearch;
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit; // Exit if accessed directly.
21 }
22
23 /**
24 * Autosuggest feature class
25 */
26 class Autosuggest extends Feature {
27
28 /**
29 * Autosuggest query generated by intercept_search_request
30 *
31 * @var array
32 */
33 public $autosuggest_query = [];
34
35 /**
36 * Initialize feature setting it's config
37 *
38 * @since 3.0
39 */
40 public function __construct() {
41 $this->slug = 'autosuggest';
42
43 $this->title = esc_html__( 'Autosuggest', 'elasticpress' );
44
45 $this->summary = __( 'Suggest relevant content as text is entered into the search field.', 'elasticpress' );
46
47 $this->docs_url = __( 'https://elasticpress.zendesk.com/hc/en-us/articles/360050447492-Configuring-ElasticPress-via-the-Plugin-Dashboard#autosuggest', 'elasticpress' );
48
49 $this->requires_install_reindex = true;
50
51 $this->default_settings = [
52 'endpoint_url' => '',
53 'autosuggest_selector' => '',
54 'trigger_ga_event' => '0',
55 ];
56
57 $this->available_during_installation = true;
58
59 parent::__construct();
60 }
61
62 /**
63 * Output feature box long
64 *
65 * @since 2.4
66 */
67 public function output_feature_box_long() {
68 ?>
69 <p><?php esc_html_e( 'Input fields of type "search" or with the CSS class "search-field" or "ep-autosuggest" will be enhanced with autosuggest functionality. As text is entered into the search field, suggested content will appear below it, based on top search results for the text. Suggestions link directly to the content.', 'elasticpress' ); ?></p>
70 <?php
71 }
72
73 /**
74 * Setup feature functionality
75 *
76 * @since 2.4
77 */
78 public function setup() {
79 add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
80 add_filter( 'ep_post_mapping', [ $this, 'mapping' ] );
81 add_filter( 'ep_post_sync_args', [ $this, 'filter_term_suggest' ], 10 );
82 add_filter( 'ep_post_fuzziness_arg', [ $this, 'set_fuzziness' ], 10, 3 );
83 add_filter( 'ep_weighted_query_for_post_type', [ $this, 'adjust_fuzzy_fields' ], 10, 3 );
84 add_filter( 'ep_saved_weighting_configuration', [ $this, 'epio_send_autosuggest_public_request' ] );
85 add_filter( 'wp', [ $this, 'epio_send_autosuggest_allowed' ] );
86 add_filter( 'ep_pre_dashboard_index', [ $this, 'epio_send_autosuggest_public_request' ] );
87 add_filter( 'ep_wp_cli_pre_index', [ $this, 'epio_send_autosuggest_public_request' ] );
88
89 add_action( 'ep_cli_after_set_search_algorithm_version', [ $this, 'delete_cached_query' ] );
90 add_action( 'ep_wp_cli_after_index', [ $this, 'delete_cached_query' ] );
91 add_action( 'ep_after_dashboard_index', [ $this, 'delete_cached_query' ] );
92 add_action( 'ep_after_update_feature', [ $this, 'delete_cached_query' ] );
93 add_action( 'ep_cli_after_clear_index', [ $this, 'delete_cached_query' ] );
94 }
95
96 /**
97 * Display decaying settings on dashboard.
98 *
99 * @since 2.4
100 */
101 public function output_feature_box_settings() {
102 $settings = $this->get_settings();
103
104 if ( ! $settings ) {
105 $settings = [];
106 }
107
108 $settings = wp_parse_args( $settings, $this->default_settings );
109
110 ?>
111 <div class="field">
112 <div class="field-name status"><label for="feature_autosuggest_selector"><?php esc_html_e( 'Autosuggest Selector', 'elasticpress' ); ?></label></div>
113 <div class="input-wrap">
114 <input value="<?php echo empty( $settings['autosuggest_selector'] ) ? '.ep-autosuggest' : esc_attr( $settings['autosuggest_selector'] ); ?>" type="text" name="settings[autosuggest_selector]" id="feature_autosuggest_selector">
115 <p class="field-description"><?php esc_html_e( 'Input additional selectors where you would like to include autosuggest separated by a comma. Example: .custom-selector, #custom-id, input[type="text"]', 'elasticpress' ); ?></p>
116 </div>
117 </div>
118
119 <div class="field">
120 <div class="field-name status"><?php esc_html_e( 'Google Analytics Events', 'elasticpress' ); ?></div>
121 <div class="input-wrap">
122 <label><input name="settings[trigger_ga_event]" <?php checked( (bool) $settings['trigger_ga_event'] ); ?> type="radio" value="1"><?php esc_html_e( 'Enabled', 'elasticpress' ); ?></label><br>
123 <label><input name="settings[trigger_ga_event]" <?php checked( ! (bool) $settings['trigger_ga_event'] ); ?> type="radio" value="0"><?php esc_html_e( 'Disabled', 'elasticpress' ); ?></label>
124 <p class="field-description"><?php esc_html_e( 'When enabled, a gtag tracking event is fired when an autosuggest result is clicked.', 'elasticpress' ); ?></p>
125 </div>
126 </div>
127 <?php
128
129 if ( Utils\is_epio() ) {
130 $this->epio_allowed_parameters();
131 return;
132 }
133
134 $endpoint_url = ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) ? EP_AUTOSUGGEST_ENDPOINT : $settings['endpoint_url'];
135 ?>
136
137 <div class="field">
138 <div class="field-name status"><label for="feature_autosuggest_endpoint_url"><?php esc_html_e( 'Endpoint URL', 'elasticpress' ); ?></label></div>
139 <div class="input-wrap">
140 <input <?php disabled( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ); ?> value="<?php echo esc_url( $endpoint_url ); ?>" type="text" name="settings[endpoint_url]" id="feature_autosuggest_endpoint_url">
141
142 <?php if ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) : ?>
143 <p class="field-description"><?php esc_html_e( 'Your autosuggest endpoint is set in wp-config.php', 'elasticpress' ); ?></p>
144 <?php endif; ?>
145
146 <p class="field-description"><?php esc_html_e( 'This address will be exposed to the public.', 'elasticpress' ); ?></p>
147 </div>
148 </div>
149
150 <?php
151 }
152
153 /**
154 * Add mapping for suggest fields
155 *
156 * @param array $mapping ES mapping.
157 * @since 2.4
158 * @return array
159 */
160 public function mapping( $mapping ) {
161 $mapping['settings']['analysis']['analyzer']['edge_ngram_analyzer'] = array(
162 'type' => 'custom',
163 'tokenizer' => 'standard',
164 'filter' => array(
165 'lowercase',
166 'edge_ngram',
167 ),
168 );
169
170 if ( version_compare( Elasticsearch::factory()->get_elasticsearch_version(), '7.0', '<' ) ) {
171 $text_type = $mapping['mappings']['post']['properties']['post_content']['type'];
172
173 $mapping['mappings']['post']['properties']['post_title']['fields']['suggest'] = array(
174 'type' => $text_type,
175 'analyzer' => 'edge_ngram_analyzer',
176 'search_analyzer' => 'standard',
177 );
178
179 $mapping['mappings']['post']['properties']['term_suggest'] = array(
180 'type' => $text_type,
181 'analyzer' => 'edge_ngram_analyzer',
182 'search_analyzer' => 'standard',
183 );
184 } else {
185 $text_type = $mapping['mappings']['properties']['post_content']['type'];
186
187 $mapping['mappings']['properties']['post_title']['fields']['suggest'] = array(
188 'type' => $text_type,
189 'analyzer' => 'edge_ngram_analyzer',
190 'search_analyzer' => 'standard',
191 );
192
193 $mapping['mappings']['properties']['term_suggest'] = array(
194 'type' => $text_type,
195 'analyzer' => 'edge_ngram_analyzer',
196 'search_analyzer' => 'standard',
197 );
198 }
199
200 return $mapping;
201 }
202
203 /**
204 * Ensure both search and autosuggest use fuziness with type auto
205 *
206 * @param integer $fuzziness Fuzziness
207 * @param array $search_fields Search Fields
208 * @param array $args Array of ES args
209 * @return array
210 */
211 public function set_fuzziness( $fuzziness, $search_fields, $args ) {
212 if ( Utils\is_integrated_request( $this->slug, [ 'public' ] ) && ! empty( $args['s'] ) ) {
213 return 'auto';
214 }
215 return $fuzziness;
216 }
217
218 /**
219 * Handle ngram search fields for fuzziness fields
220 *
221 * @param array $query ES Query arguments
222 * @param string $post_type Post Type
223 * @param array $args WP_Query args
224 * @return array $query adjusted ES Query arguments
225 */
226 public function adjust_fuzzy_fields( $query, $post_type, $args ) {
227 if ( ! Utils\is_integrated_request( $this->slug, [ 'public' ] ) || empty( $args['s'] ) ) {
228 return $query;
229 }
230
231 if ( ! isset( $query['bool'] ) || ! isset( $query['bool']['must'] ) ) {
232 return $query;
233 }
234
235 /**
236 * Filter autosuggest ngram fields
237 *
238 * @hook ep_autosuggest_ngram_fields
239 * @param {array} $fields Fields available to ngram
240 * @return {array} New fields array
241 */
242 $ngram_fields = apply_filters(
243 'ep_autosuggest_ngram_fields',
244 [
245 'post_title' => 'post_title.suggest',
246 'terms\.(.+)\.name' => 'term_suggest',
247 ]
248 );
249
250 /**
251 * At this point, `$query` might look like this (using the 3.5 search algorithm):
252 *
253 * [
254 * [bool] => [
255 * [must] => [
256 * [0] => [
257 * [bool] => [
258 * [should] => [
259 * [0] => [
260 * [multi_match] => [
261 * [query] => ep_autosuggest_placeholder
262 * [type] => phrase
263 * [fields] => [
264 * [0] => post_title^1
265 * ...
266 * [n] => terms.category.name^27
267 * ]
268 * [boost] => 3
269 * ]
270 * ]
271 * [1] => [
272 * [multi_match] => [
273 * [query] => ep_autosuggest_placeholder
274 * [fields] => [ ... ]
275 * [type] => phrase
276 * [slop] => 5
277 * ]
278 * ]
279 * ]
280 * ]
281 * ]
282 * ]
283 * ]
284 * ...
285 * ]
286 *
287 * Also, note the usage of `&$must_query`. This means that by changing `$must_query`
288 * you will be actually changing `$query`.
289 */
290 foreach ( $query['bool']['must'] as &$must_query ) {
291 if ( ! isset( $must_query['bool'] ) || ! isset( $must_query['bool']['should'] ) ) {
292 continue;
293 }
294 foreach ( $must_query['bool']['should'] as &$current_bool_should ) {
295 if ( ! isset( $current_bool_should['multi_match'] ) || ! isset( $current_bool_should['multi_match']['fields'] ) ) {
296 continue;
297 }
298
299 /**
300 * `fuzziness` is used in the original algorithm.
301 * `slop` is used in `3.5`.
302 *
303 * @see \ElasticPress\Indexable\Post\Post::format_args()
304 */
305 if ( empty( $current_bool_should['multi_match']['fuzziness'] ) && empty( $current_bool_should['multi_match']['slop'] ) ) {
306 continue;
307 }
308
309 $fields_to_add = [];
310
311 /**
312 * If the regex used in `$ngram_fields` matches more than one field,
313 * like taxonomies, for example, we use the min value - 1.
314 */
315 foreach ( $current_bool_should['multi_match']['fields'] as $field ) {
316 foreach ( $ngram_fields as $regex => $ngram_field ) {
317 if ( preg_match( '/^(' . $regex . ')(\^(\d+))?$/', $field, $match ) ) {
318 $weight = 1;
319 if ( isset( $match[4] ) && $match[4] > 1 ) {
320 $weight = $match[4] - 1;
321 }
322
323 if ( isset( $fields_to_add[ $ngram_field ] ) ) {
324 $fields_to_add[ $ngram_field ] = min( $fields_to_add[ $ngram_field ], $weight );
325 } else {
326 $fields_to_add[ $ngram_field ] = $weight;
327 }
328 }
329 }
330 }
331
332 foreach ( $fields_to_add as $field => $weight ) {
333 $current_bool_should['multi_match']['fields'][] = "{$field}^{$weight}";
334 }
335 }
336 }
337
338 return $query;
339 }
340
341 /**
342 * Add term suggestions to be indexed
343 *
344 * @param array $post_args Array of ES args.
345 * @since 2.4
346 * @return array
347 */
348 public function filter_term_suggest( $post_args ) {
349 $suggest = [];
350
351 if ( ! empty( $post_args['terms'] ) ) {
352 foreach ( $post_args['terms'] as $taxonomy ) {
353 foreach ( $taxonomy as $term ) {
354 $suggest[] = $term['name'];
355 }
356 }
357 }
358
359 if ( ! empty( $suggest ) ) {
360 $post_args['term_suggest'] = $suggest;
361 }
362
363 return $post_args;
364 }
365
366 /**
367 * Enqueue our autosuggest script
368 *
369 * @since 2.4
370 */
371 public function enqueue_scripts() {
372 if ( Utils\is_indexing() ) {
373 return;
374 }
375
376 $host = Utils\get_host();
377 $endpoint_url = false;
378 $settings = $this->get_settings();
379
380 if ( defined( 'EP_AUTOSUGGEST_ENDPOINT' ) && EP_AUTOSUGGEST_ENDPOINT ) {
381 $endpoint_url = EP_AUTOSUGGEST_ENDPOINT;
382 } else {
383 if ( Utils\is_epio() ) {
384 $endpoint_url = trailingslashit( $host ) . Indexables::factory()->get( 'post' )->get_index_name() . '/autosuggest';
385 } else {
386 if ( ! $settings ) {
387 $settings = [];
388 }
389
390 $settings = wp_parse_args( $settings, $this->default_settings );
391
392 if ( empty( $settings['endpoint_url'] ) ) {
393 return;
394 }
395
396 $endpoint_url = $settings['endpoint_url'];
397 }
398 }
399
400 wp_enqueue_script(
401 'elasticpress-autosuggest',
402 EP_URL . 'dist/js/autosuggest-script.js',
403 Utils\get_asset_info( 'autosuggest-script', 'dependencies' ),
404 Utils\get_asset_info( 'autosuggest-script', 'version' ),
405 true
406 );
407
408 wp_set_script_translations( 'elasticpress-autosuggest', 'elasticpress' );
409
410 wp_enqueue_style(
411 'elasticpress-autosuggest',
412 EP_URL . 'dist/css/autosuggest-styles.css',
413 Utils\get_asset_info( 'autosuggest-styles', 'dependencies' ),
414 Utils\get_asset_info( 'autosuggest-styles', 'version' )
415 );
416
417 /** Features Class @var Features $features */
418 $features = Features::factory();
419
420 /** Search Feature @var Feature\Search\Search $search */
421 $search = $features->get_registered_feature( 'search' );
422
423 $post_types = $search->get_searchable_post_types();
424 $post_status = get_post_stati(
425 [
426 'public' => true,
427 'exclude_from_search' => false,
428 ]
429 );
430
431 $query = $this->generate_search_query();
432
433 $epas_options = [
434 'query' => $query['body'],
435 'placeholder' => $query['placeholder'],
436 'endpointUrl' => esc_url( untrailingslashit( $endpoint_url ) ),
437 'selector' => empty( $settings['autosuggest_selector'] ) ? 'ep-autosuggest' : esc_html( $settings['autosuggest_selector'] ),
438 /**
439 * Filter autosuggest default selectors.
440 *
441 * @hook ep_autosuggest_default_selectors
442 * @since 3.6.0
443 * @param {string} $selectors Default selectors used to attach autosuggest.
444 * @return {string} Selectors used to attach autosuggest.
445 */
446 'defaultSelectors' => apply_filters( 'ep_autosuggest_default_selectors', '.ep-autosuggest, input[type="search"], .search-field' ),
447 'action' => 'navigate',
448 'mimeTypes' => [],
449 /**
450 * Filter autosuggest HTTP headers
451 *
452 * @hook ep_autosuggest_http_headers
453 * @param {array} $headers Autosuggest HTTP headers in name => value format
454 * @return {array} HTTP headers
455 */
456 'http_headers' => apply_filters( 'ep_autosuggest_http_headers', [] ),
457 'triggerAnalytics' => ! empty( $settings['trigger_ga_event'] ),
458 'addSearchTermHeader' => false,
459 ];
460
461 if ( Utils\is_epio() ) {
462 $epas_options['addSearchTermHeader'] = true;
463 }
464
465 $search_settings = $search->get_settings();
466
467 if ( ! $search_settings ) {
468 $search_settings = [];
469 }
470
471 $search_settings = wp_parse_args( $search_settings, $search->default_settings );
472
473 if ( ! empty( $search_settings ) && $search_settings['highlight_enabled'] ) {
474 $epas_options['highlightingEnabled'] = true;
475 $epas_options['highlightingTag'] = apply_filters( 'ep_highlighting_tag', $search_settings['highlight_tag'] );
476 $epas_options['highlightingClass'] = apply_filters( 'ep_highlighting_class', 'ep-highlight' );
477 }
478
479 /**
480 * Output variables to use in Javascript
481 * index: the Elasticsearch index name
482 * endpointUrl: the Elasticsearch autosuggest endpoint url
483 * postType: which post types to use for suggestions
484 * action: the action to take when selecting an item. Possible values are "search" and "navigate".
485 */
486 wp_localize_script(
487 'elasticpress-autosuggest',
488 'epas',
489 /**
490 * Filter autosuggest JavaScript options
491 *
492 * @hook ep_autosuggest_options
493 * @param {array} $options Autosuggest options to be localized
494 * @return {array} New options
495 */
496 apply_filters(
497 'ep_autosuggest_options',
498 $epas_options
499 )
500 );
501 }
502
503 /**
504 * Build a default search request to pass to the autosuggest javascript.
505 * The request will include a placeholder that can then be replaced.
506 *
507 * @return array Generated ElasticSearch request array( 'placeholder'=> placeholderstring, 'body' => request body )
508 */
509 public function generate_search_query() {
510
511 /**
512 * Filter autosuggest query placeholder
513 *
514 * @hook ep_autosuggest_query_placeholder
515 * @param {string} $placeholder Autosuggest placeholder to be replaced later
516 * @return {string} New placeholder
517 */
518 $placeholder = apply_filters( 'ep_autosuggest_query_placeholder', 'ep_autosuggest_placeholder' );
519
520 /** Features Class @var Features $features */
521 $features = Features::factory();
522
523 $post_type = $features->get_registered_feature( 'search' )->get_searchable_post_types();
524
525 /**
526 * Filter post types available to autosuggest
527 *
528 * @hook ep_term_suggest_post_type
529 * @param {array} $post_types Post types
530 * @return {array} New post types
531 */
532 $post_type = apply_filters( 'ep_term_suggest_post_type', array_values( $post_type ) );
533
534 $post_status = get_post_stati(
535 [
536 'public' => true,
537 'exclude_from_search' => false,
538 ]
539 );
540
541 /**
542 * Filter post statuses available to autosuggest
543 *
544 * @hook ep_term_suggest_post_status
545 * @param {array} $post_statuses Post statuses
546 * @return {array} New post statuses
547 */
548 $post_status = apply_filters( 'ep_term_suggest_post_status', array_values( $post_status ) );
549
550 add_filter( 'ep_intercept_remote_request', '__return_true' );
551 add_filter( 'ep_weighting_configuration', [ $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ], 10, 1 );
552
553 add_filter( 'ep_do_intercept_request', [ $features->get_registered_feature( $this->slug ), 'intercept_search_request' ], 10, 4 );
554
555 add_filter( 'posts_pre_query', [ $features->get_registered_feature( $this->slug ), 'return_empty_posts' ], 100, 1 ); // after ES Query to ensure we are not falling back to DB in any case
556
557 new \WP_Query(
558 /**
559 * Filter WP Query args of the autosuggest query template.
560 *
561 * If you want to display 20 posts in autosuggest:
562 *
563 * ```
564 * add_filter(
565 * 'ep_autosuggest_query_args',
566 * function( $args ) {
567 * $args['posts_per_page'] = 20;
568 * return $args;
569 * }
570 * );
571 * ```
572 *
573 * @since 4.4.0
574 * @hook ep_autosuggest_query_args
575 * @param {array} $args Query args
576 * @return {array} New query args
577 */
578 apply_filters(
579 'ep_autosuggest_query_args',
580 [
581 'post_type' => $post_type,
582 'post_status' => $post_status,
583 's' => $placeholder,
584 'ep_integrate' => true,
585 ]
586 )
587 );
588
589 remove_filter( 'posts_pre_query', [ $features->get_registered_feature( $this->slug ), 'return_empty_posts' ], 100 );
590
591 remove_filter( 'ep_do_intercept_request', [ $features->get_registered_feature( $this->slug ), 'intercept_search_request' ] );
592
593 remove_filter( 'ep_weighting_configuration', [ $features->get_registered_feature( $this->slug ), 'apply_autosuggest_weighting' ] );
594
595 remove_filter( 'ep_intercept_remote_request', '__return_true' );
596
597 return [
598 'body' => $this->autosuggest_query,
599 'placeholder' => $placeholder,
600 ];
601 }
602
603 /**
604 * Ensure we do not fallback to WPDB query for this request
605 *
606 * @param array $posts array of post objects
607 * @return array $posts
608 */
609 public function return_empty_posts( $posts = [] ) {
610 return [];
611 }
612
613 /**
614 * Allow applying custom weighting configuration for autosuggest
615 *
616 * @param array $config current configuration
617 * @return array $config desired configuration
618 */
619 public function apply_autosuggest_weighting( $config = [] ) {
620 /**
621 * Filter autosuggest weighting configuration
622 *
623 * @hook ep_weighting_configuration_for_autosuggest
624 * @param {array} $config Configuration
625 * @return {array} New config
626 */
627 $config = apply_filters( 'ep_weighting_configuration_for_autosuggest', $config );
628 return $config;
629 }
630
631 /**
632 * Store intercepted request value and return (cached) request result
633 *
634 * @param object $response Response
635 * @param array $query Query
636 * @param array $args WP_Query Argument array
637 * @param int $failures Count of failures in request loop
638 * @return object $response Response
639 */
640 public function intercept_search_request( $response, $query = [], $args = [], $failures = 0 ) {
641 $this->autosuggest_query = $query['args']['body'];
642
643 // Let's make sure we also fire off the dummy request if settings have changed.
644 // But only fire this if we have object caching as otherwise this comes with a performance penalty.
645 // If we do not have object caching we cache only one value for 5 minutes in a transient.
646 if ( wp_using_ext_object_cache() ) {
647 $cache_key = md5( wp_json_encode( $query['url'] ) . wp_json_encode( $args ) );
648 $request = wp_cache_get( $cache_key, 'ep_autosuggest' );
649 if ( false === $request ) {
650 $request = wp_remote_request( $query['url'], $args );
651 if ( isset( $request->http_response ) && isset( $request->http_response->body ) ) {
652 $request->http_response->body = '';
653 }
654 wp_cache_set( $cache_key, $request, 'ep_autosuggest' );
655 }
656 } else {
657 $cache_key = 'ep_autosuggest_query_request_cache';
658 $request = get_transient( $cache_key );
659 if ( false === $request ) {
660 $request = wp_remote_request( $query['url'], $args );
661 if ( isset( $request->http_response ) && isset( $request->http_response->body ) ) {
662 $request->http_response->body = '';
663 }
664 set_transient( $cache_key, $request, 5 * MINUTE_IN_SECONDS );
665 }
666 }
667
668 return $request;
669 }
670
671 /**
672 * Delete the cached query for autosuggest.
673 *
674 * @since 3.5.5
675 */
676 public function delete_cached_query() {
677 global $wp_object_cache;
678 if ( wp_using_ext_object_cache() ) {
679 if ( function_exists( 'wp_cache_supports_group_flush' ) && wp_cache_supports_group_flush() ) {
680 wp_cache_flush_group( 'ep_autosuggest' );
681 } else {
682 // Try to delete the entire group.
683 // This may fail because the `$cache` property is not standardized.
684 unset( $wp_object_cache->cache['ep_autosuggest'] );
685 }
686 } else {
687 delete_transient( 'ep_autosuggest_query_request_cache' );
688 }
689 }
690
691 /**
692 * Tell user whether requirements for feature are met or not.
693 *
694 * @return array $status Status array
695 * @since 2.4
696 */
697 public function requirements_status() {
698 $status = new FeatureRequirementsStatus( 0 );
699
700 $status->message = [];
701
702 $status->message[] = esc_html__( 'This feature modifies the site’s default user experience by presenting a list of suggestions below detected search fields as text is entered into the field.', 'elasticpress' );
703
704 if ( ! Utils\is_epio() ) {
705 $status->code = 1;
706 $status->message[] = wp_kses_post( __( "You aren't using <a href='https://elasticpress.io'>ElasticPress.io</a> so we can't be sure your host is properly secured. Autosuggest requires a publicly accessible endpoint, which can expose private content and allow data modification if improperly configured.", 'elasticpress' ) );
707 }
708
709 return $status;
710 }
711
712 /**
713 * Do a non-blocking search query to force the autosuggest hash to update.
714 *
715 * This request has to happen in a public environment, so all code testing if `is_admin()`
716 * are properly executed.
717 *
718 * @param bool $blocking If the request should block the execution or not.
719 */
720 public function epio_send_autosuggest_public_request( $blocking = false ) {
721 if ( ! Utils\is_epio() ) {
722 return;
723 }
724
725 $url = add_query_arg(
726 [
727 's' => 'search test',
728 'ep_epio_set_autosuggest' => 1,
729 'ep_epio_nonce' => wp_create_nonce( 'ep-epio-set-autosuggest' ),
730 'nocache' => time(), // Here just to avoid the request hitting a CDN.
731 ],
732 home_url( '/' )
733 );
734
735 // Pass the same cookies, so the same authenticated user is used (and we can check the nonce).
736 $cookies = [];
737 foreach ( $_COOKIE as $name => $value ) {
738 $cookies[] = new \WP_Http_Cookie(
739 [
740 'name' => $name,
741 'value' => $value,
742 ]
743 );
744 }
745
746 wp_remote_get(
747 $url,
748 [
749 'cookies' => $cookies,
750 'blocking' => (bool) $blocking,
751 ]
752 );
753 }
754
755 /**
756 * Send the allowed parameters for autosuggest to ElasticPress.io.
757 */
758 public function epio_send_autosuggest_allowed() {
759 if ( empty( $_REQUEST['ep_epio_nonce'] ) || ! wp_verify_nonce( $_REQUEST['ep_epio_nonce'], 'ep-epio-set-autosuggest' ) ) {
760 return;
761 }
762 if ( empty( $_GET['ep_epio_set_autosuggest'] ) ) {
763 return;
764 }
765
766 /**
767 * Fires before the request is sent to EP.io to set Autosuggest allowed values.
768 *
769 * @hook ep_epio_pre_send_autosuggest_allowed
770 * @since 3.5.x
771 */
772 do_action( 'ep_epio_pre_send_autosuggest_allowed' );
773
774 /**
775 * The same ES query sent by autosuggest.
776 *
777 * Sometimes it'll be a string, sometimes it'll be already an array.
778 */
779 $es_search_query = $this->generate_search_query()['body'];
780 $es_search_query = ( is_array( $es_search_query ) ) ? $es_search_query : json_decode( $es_search_query, true );
781
782 /**
783 * Filter autosuggest ES query
784 *
785 * @since 3.5.x
786 * @hook ep_epio_autosuggest_es_query
787 * @param {array} The ES Query.
788 */
789 $es_search_query = apply_filters( 'ep_epio_autosuggest_es_query', $es_search_query );
790
791 /**
792 * Here is a chance to short-circuit the execution. Also, during the sync
793 * the query will be empty anyway.
794 */
795 if ( empty( $es_search_query ) ) {
796 return;
797 }
798
799 $index = Indexables::factory()->get( 'post' )->get_index_name();
800
801 add_filter( 'ep_format_request_headers', [ $this, 'add_ep_set_autosuggest_header' ] );
802
803 Elasticsearch::factory()->query( $index, 'post', $es_search_query, [] );
804
805 remove_filter( 'ep_format_request_headers', [ $this, 'add_ep_set_autosuggest_header' ] );
806
807 /**
808 * Fires after the request is sent to EP.io to set Autosuggest allowed values.
809 *
810 * @hook ep_epio_sent_autosuggest_allowed
811 * @since 3.5.x
812 */
813 do_action( 'ep_epio_sent_autosuggest_allowed' );
814 }
815
816 /**
817 * Set a header so EP.io servers know this request contains the values
818 * that should be stored as allowed.
819 *
820 * @since 3.5.x
821 * @param array $headers The Request Headers.
822 * @return array
823 */
824 public function add_ep_set_autosuggest_header( $headers ) {
825 $headers['EP-Set-Autosuggest'] = true;
826 return $headers;
827 }
828
829 /**
830 * Retrieve the allowed parameters for autosuggest from ElasticPress.io.
831 *
832 * @return array
833 */
834 public function epio_retrieve_autosuggest_allowed() {
835 $response = Elasticsearch::factory()->remote_request(
836 Indexables::factory()->get( 'post' )->get_index_name() . '/get-autosuggest-allowed'
837 );
838
839 $body = wp_remote_retrieve_body( $response, true );
840 return json_decode( $body, true );
841 }
842
843 /**
844 * Output the current allowed parameters for autosuggest stored in ElasticPress.io.
845 */
846 public function epio_allowed_parameters() {
847 global $wp_version;
848
849 $allowed_params = $this->epio_autosuggest_set_and_get();
850 if ( empty( $allowed_params ) ) {
851 return;
852 }
853 ?>
854 <div class="field js-toggle-feature" data-feature="<?php echo esc_attr( $this->slug ); ?>">
855 <div class="field-name status"><?php esc_html_e( 'Connection', 'elasticpress' ); ?></div>
856 <div class="input-wrap">
857 <?php
858 $epio_link = '';
859 $epio_autosuggest_kb_link = 'https://elasticpress.zendesk.com/hc/en-us/articles/360055402791';
860
861 // If WordPress 5.2+, show debug in Health Check. Otherwise, show it if WP_DEBUG is enabled.
862 if ( version_compare( $wp_version, '5.2', '>=' ) || 0 === stripos( $wp_version, '5.2-' ) ) {
863 printf(
864 /* translators: 1: <a> tag (ElasticPress.io); 2. </a>; 3: <a> tag (KB article); 4. </a>; 5: <a> tag (Site Health Debug Section); 6. </a>; */
865 esc_html__( 'You are directly connected to %1$sElasticPress.io%2$s, ensuring the most performant Autosuggest experience. %3$sLearn more about what this means%4$s or %5$sclick here for debug information%6$s.', 'elasticpress' ),
866 '<a href="' . esc_url( $epio_link ) . '">',
867 '</a>',
868 '<a href="' . esc_url( $epio_autosuggest_kb_link ) . '">',
869 '</a>',
870 '<a href="' . esc_url( admin_url( 'site-health.php?tab=debug' ) ) . '">',
871 '</a>'
872 );
873 } else {
874 printf(
875 /* translators: 1: <a> tag (ElasticPress.io); 2. </a>; 3: <a> tag (KB article); 4. </a>; */
876 esc_html__( 'You are directly connected to %1$sElasticPress.io%2$s, ensuring the most performant Autosuggest experience. %1$sLearn more about what this means%2$s.', 'elasticpress' ),
877 '<a href="' . esc_url( $epio_link ) . '">',
878 '</a>',
879 '<a href="' . esc_url( $epio_autosuggest_kb_link ) . '">',
880 '</a>'
881 );
882
883 if ( ( defined( 'WP_DEBUG' ) && WP_DEBUG ) || ( defined( 'WP_EP_DEBUG' ) && WP_EP_DEBUG ) ) {
884 ?>
885 <p><?php esc_html_e( 'These are the allowed parameters stored in ElasticPress.io', 'elasticpress' ); ?></p>
886 <?php
887 $allowed_params = wp_parse_args(
888 $allowed_params,
889 [
890 'postTypes' => [],
891 'postStatus' => [],
892 'searchFields' => [],
893 'returnFields' => '',
894 ]
895 );
896
897 $fields = [
898 wp_sprintf( esc_html__( 'Post Types: %l', 'elasticpress' ), $allowed_params['postTypes'] ),
899 wp_sprintf( esc_html__( 'Post Status: %l', 'elasticpress' ), $allowed_params['postStatus'] ),
900 wp_sprintf( esc_html__( 'Search Fields: %l', 'elasticpress' ), $allowed_params['searchFields'] ),
901 /* translators: List of files allowed to be returned wrapped by var_export() */
902 wp_sprintf( esc_html__( 'Returned Fields: %s', 'elasticpress' ), var_export( $allowed_params['returnFields'], true ) ), // phpcs:ignore
903 ];
904
905 echo implode( '<br>', $fields ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
906 }
907 }
908 ?>
909 <p>
910 <img width="150" src="<?php echo esc_url( plugins_url( '/images/logo-elasticpress-io.svg', EP_FILE ) ); ?>">
911 </p>
912 </div>
913 </div>
914 <?php
915 }
916
917 /**
918 * Try to get the allowed parameters. If they are not set, set it and try to get them again.
919 *
920 * @since 3.5.x
921 * @return array
922 */
923 public function epio_autosuggest_set_and_get() {
924 $allowed_params = [];
925 $errors_count = 1;
926 for ( $i = 0; $i <= $errors_count; $i++ ) {
927 $allowed_params = $this->epio_retrieve_autosuggest_allowed();
928
929 if ( is_wp_error( $allowed_params ) || ( isset( $allowed_params['status'] ) && 200 !== $allowed_params['status'] ) ) {
930 $allowed_params = [];
931 break;
932 }
933
934 if ( empty( $allowed_params ) ) {
935 $this->epio_send_autosuggest_public_request( true );
936 }
937 }
938
939 return $allowed_params;
940 }
941
942 }
943