PluginProbe
ElasticPress / 4.2.0
ElasticPress v4.2.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.2.0, at includes/classes/Feature/Autosuggest/Autosuggest.php

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