| @@ -20,8 +20,34 @@ | ||
| 20 | 20 | */ |
| 21 | 21 | class REST_API { |
| 22 | 22 | |
| 23 | 23 | /** |
| 24 | + * Upper bound for the search endpoint's postPerPage. Matches the max of the | |
| 25 | + * Search block's "Show Initial Post" slider, so no UI value is ever capped. | |
| 26 | + * This is the same max range of the slider in gutenburg editor. | |
| 27 | + * 100 is enough for search bar result. | |
| 28 | + * | |
| 29 | + * @since v.5.0.35 | |
| 30 | + */ | |
| 31 | + const SEARCH_POSTS_PER_PAGE_MAX = 100; | |
| 32 | + | |
| 33 | + /** | |
| 34 | + * Longest accepted search term. Longer strings only widen the LIKE pattern | |
| 35 | + * scanned against every row; no real query needs more. | |
| 36 | + * | |
| 37 | + * @since v.5.0.35 | |
| 38 | + */ | |
| 39 | + const SEARCH_TEXT_MAX_LENGTH = 200; | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * Upper bound for excerptLimit. Also keeps $num_words + 1 inside integer range | |
| 43 | + * in wp_trim_words(), which otherwise overflows to float on a huge value. | |
| 44 | + * | |
| 45 | + * @since v.5.0.35 | |
| 46 | + */ | |
| 47 | + const SEARCH_EXCERPT_WORDS_MAX = 400; | |
| 48 | + | |
| 49 | + /** | |
| 24 | 50 | * Setup class. |
| 25 | 51 | * |
| 26 | 52 | * @since v.1.0.0 |
| 27 | 53 | */ |
| @@ -633,28 +659,61 @@ | ||
| 633 | 659 | $post = $server->get_params(); |
| 634 | 660 | $searchText = isset( $post['searchText'] ) ? ultimate_post()->ultp_rest_sanitize_params( $post['searchText'] ) : ''; |
| 635 | 661 | $paged = isset( $post['paged'] ) ? ultimate_post()->ultp_rest_sanitize_params( $post['paged'] ) : ''; |
| 636 | 662 | $postPerPage = isset( $post['postPerPage'] ) ? ultimate_post()->ultp_rest_sanitize_params( $post['postPerPage'] ) : ''; |
| 637 | - $query_args = array( | |
| 663 | + | |
| 664 | + // Public route: cap posts_per_page so -1 can never mean "unlimited". | |
| 665 | + // 0 survives the cap, and WP_Query resolves it to the posts_per_page option. | |
| 666 | + $postPerPage = min( absint( $postPerPage ), self::SEARCH_POSTS_PER_PAGE_MAX ); | |
| 667 | + // is_scalar first: searchText arrives as an array if the caller sends one, and | |
| 668 | + // casting that to string is an "Array to string conversion" warning. | |
| 669 | + $searchText = is_scalar( $searchText ) ? mb_substr( (string) $searchText, 0, self::SEARCH_TEXT_MAX_LENGTH ) : ''; | |
| 670 | + | |
| 671 | + $query_args = array( | |
| 638 | 672 | 's' => $searchText, |
| 639 | 673 | 'paged' => $paged, |
| 640 | - 'compare' => 'LIKE', | |
| 641 | 674 | 'orderby' => 'relevance', |
| 642 | 675 | 'posts_per_page' => $postPerPage, |
| 643 | 676 | ); |
| 644 | 677 | if ( isset( $post['exclude'] ) && is_array( $post['exclude'] ) && count( $post['exclude'] ) > 0 ) { |
| 645 | - $post['exclude'] = ultimate_post()->ultp_rest_sanitize_params( $post['exclude'] ); | |
| 646 | - $post_exclude = array(); | |
| 647 | - foreach ( $post['exclude'] as $data ) { | |
| 648 | - $post_exclude[ $data['title'] ] = $data['title']; | |
| 678 | + $all_types = get_post_types( array( 'public' => true ), 'names' ); | |
| 679 | + $post_exclude = array(); | |
| 680 | + | |
| 681 | + // Bound before sanitising: this route is public, and sanitising an array | |
| 682 | + // whose length the caller picks is itself the attack. Excluding more post | |
| 683 | + // types than exist is meaningless, so nothing valid is dropped. | |
| 684 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Length is bounded here only; every element is sanitized below before use. | |
| 685 | + $exclude_input = array_slice( $post['exclude'], 0, count( $all_types ) ); | |
| 686 | + | |
| 687 | + foreach ( $exclude_input as $data ) { | |
| 688 | + // A flat array of strings would index a string here and warn. | |
| 689 | + if ( ! is_array( $data ) || ! isset( $data['title'] ) || ! is_scalar( $data['title'] ) ) { | |
| 690 | + continue; | |
| 691 | + } | |
| 692 | + $exclude_type = sanitize_text_field( $data['title'] ); | |
| 693 | + $post_exclude[ $exclude_type ] = $exclude_type; | |
| 649 | 694 | } |
| 650 | - $all_types = get_post_types( array( 'public' => true ), 'names' ); | |
| 651 | - $post_type = array_diff_key( $all_types, $post_exclude ); | |
| 652 | - $query_args['post_type'] = $post_type; | |
| 695 | + | |
| 696 | + $query_args['post_type'] = array_diff_key( $all_types, $post_exclude ); | |
| 653 | 697 | } |
| 654 | 698 | $output = ''; |
| 655 | 699 | $query_result = new \WP_Query( $query_args ); |
| 656 | 700 | |
| 701 | + /** | |
| 702 | + * Resolved once here rather than guarded at each use, since the request may | |
| 703 | + * omit any of them. The loose == is deliberate: the two callers disagree on | |
| 704 | + * type -- the editor sends real booleans, the frontend sends 1/0 parsed out | |
| 705 | + * of data attributes -- and both must keep working. | |
| 706 | + */ | |
| 707 | + $show_image = isset( $post['image'] ) && $post['image'] == 1; | |
| 708 | + $show_category = isset( $post['category'] ) && $post['category'] == 1; | |
| 709 | + $show_author = isset( $post['author'] ) && $post['author'] == 1; | |
| 710 | + $show_date = isset( $post['date'] ) && $post['date'] == 1; | |
| 711 | + $show_excerpt = isset( $post['excerpt'] ) && $post['excerpt'] == 1; | |
| 712 | + $excerpt_limit = isset( $post['excerptLimit'] ) | |
| 713 | + ? min( absint( $post['excerptLimit'] ), self::SEARCH_EXCERPT_WORDS_MAX ) | |
| 714 | + : 55; | |
| 715 | + | |
| 657 | 716 | if ( $query_result->have_posts() ) { |
| 658 | 717 | while ( $query_result->have_posts() ) { |
| 659 | 718 | $query_result->the_post(); |
| 660 | 719 | $post_id = get_the_ID(); |
| @@ -660,40 +719,59 @@ | ||
| 660 | 719 | $post_id = get_the_ID(); |
| 661 | 720 | $title = get_the_title(); |
| 662 | 721 | |
| 663 | 722 | $output .= '<div class="ultp-search-result__item">'; |
| 664 | - if ( $post['image'] == 1 && has_post_thumbnail() ) { | |
| 665 | - $thumb_id = get_post_thumbnail_id( $post_id ); | |
| 666 | - $output .= '<img class="ultp-searchresult-image" src=' . wp_get_attachment_image_src( $thumb_id, 'thumbnail', false )[0] . ' alt="' . $title . '"/>'; | |
| 723 | + | |
| 724 | + if ( $show_image && has_post_thumbnail() ) { | |
| 725 | + // has_post_thumbnail() only proves the _thumbnail_id meta exists. | |
| 726 | + // If the attachment behind it was deleted, src() returns false. | |
| 727 | + $thumb = wp_get_attachment_image_src( get_post_thumbnail_id( $post_id ), 'thumbnail', false ); | |
| 728 | + if ( ! empty( $thumb[0] ) ) { | |
| 729 | + $output .= '<img class="ultp-searchresult-image" src="' . esc_url( $thumb[0] ) . '" alt="' . esc_attr( $title ) . '"/>'; | |
| 730 | + } | |
| 667 | 731 | } |
| 668 | - $output .= '<div class="ultp-searchresult-content">'; | |
| 669 | - $output .= '<div class="ultp-rescontent-meta">'; | |
| 670 | - // Category. | |
| 671 | - $post_cat = get_the_terms( $post_id, 'category' ); | |
| 672 | - if ( $post['category'] == 1 && $post_cat && count( $post_cat ) ) { | |
| 732 | + | |
| 733 | + $output .= '<div class="ultp-searchresult-content">'; | |
| 734 | + $output .= '<div class="ultp-rescontent-meta">'; | |
| 735 | + | |
| 736 | + // Category. | |
| 737 | + $post_cat = get_the_terms( $post_id, 'category' ); | |
| 738 | + if ( $show_category && $post_cat && ! is_wp_error( $post_cat ) ) { | |
| 673 | 739 | $output .= '<div class="ultp-searchresult-category">'; |
| 674 | 740 | foreach ( $post_cat as $cat ) { |
| 675 | - $output .= '<a href="' . get_term_link( $cat->term_id ) . '">' . $cat->name . '</a>'; | |
| 741 | + $term_link = get_term_link( $cat->term_id ); | |
| 742 | + if ( is_wp_error( $term_link ) ) { | |
| 743 | + continue; | |
| 744 | + } | |
| 745 | + $output .= '<a href="' . esc_url( $term_link ) . '">' . esc_html( $cat->name ) . '</a>'; | |
| 676 | 746 | } |
| 677 | - $output .= '</div>'; | |
| 747 | + $output .= '</div>'; | |
| 678 | 748 | } |
| 679 | - // Author. | |
| 680 | - if ( $post['author'] == 1 ) { | |
| 749 | + | |
| 750 | + // Author. | |
| 751 | + if ( $show_author ) { | |
| 681 | 752 | $user_id = get_the_author_meta( 'ID' ); |
| 682 | - $output .= '<a href="' . get_author_posts_url( $user_id ) . '" class="ultp-searchresult-author">' . get_the_author_meta( 'display_name' ) . '</a>'; | |
| 753 | + $output .= '<a href="' . esc_url( get_author_posts_url( $user_id ) ) . '" class="ultp-searchresult-author">' . esc_html( get_the_author_meta( 'display_name' ) ) . '</a>'; | |
| 683 | 754 | } |
| 684 | - // Date. | |
| 685 | - if ( $post['date'] == 1 ) { | |
| 686 | - $output .= '<div class="ultp-searchresult-publishdate">' . get_the_date( 'F j, Y' ) . '</div>'; | |
| 755 | + | |
| 756 | + // Date. | |
| 757 | + if ( $show_date ) { | |
| 758 | + $output .= '<div class="ultp-searchresult-publishdate">' . esc_html( get_the_date( 'F j, Y' ) ) . '</div>'; | |
| 687 | 759 | } |
| 688 | - $output .= '</div>'; | |
| 689 | - $output .= '<a href="' . get_permalink() . '" class="ultp-searchresult-title">' . $title . '</a>'; | |
| 690 | - if ( $post['excerpt'] == 1 ) { | |
| 691 | - $output .= '<div class="ultp-searchresult-excerpt">' . wp_trim_words( get_the_excerpt(), isset( $post['excerptLimit'] ) ? ultimate_post()->ultp_rest_sanitize_params( $post['excerptLimit'] ) : 55 ) . '</div>'; | |
| 760 | + | |
| 761 | + $output .= '</div>'; | |
| 762 | + $output .= '<a href="' . esc_url( get_permalink() ) . '" class="ultp-searchresult-title">' . esc_html( $title ) . '</a>'; | |
| 763 | + | |
| 764 | + if ( $show_excerpt ) { | |
| 765 | + // wp_trim_words() strips tags, so what comes back is plain text. | |
| 766 | + $output .= '<div class="ultp-searchresult-excerpt">' . esc_html( wp_trim_words( get_the_excerpt(), $excerpt_limit ) ) . '</div>'; | |
| 692 | 767 | } |
| 693 | - $output .= '</div>'; | |
| 694 | - $output .= '</div>'; | |
| 768 | + | |
| 769 | + $output .= '</div>'; | |
| 770 | + $output .= '</div>'; | |
| 695 | 771 | } |
| 772 | + // the_post() overwrote the global $post; hand it back. | |
| 773 | + wp_reset_postdata(); | |
| 696 | 774 | } |
| 697 | 775 | |
| 698 | 776 | return array( |
| 699 | 777 | 'post_data' => $output, |