'GET', 'callback' => array( $this, 'search' ), 'permission_callback' => '__return_true', 'args' => array( 'q' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), 'kb' => array( 'type' => 'integer', 'default' => 0, ), 'cat' => array( 'type' => 'integer', 'default' => 0, ), ), ) ); register_rest_route( Routes::NS, '/feedback', array( 'methods' => 'POST', 'callback' => array( $this, 'feedback' ), 'args' => array( 'article' => Args::id(), 'vote' => Args::enum( array( 'yes', 'no' ), true ), 'message' => Args::textarea( 2000 ), 'reason' => Args::text( 40 ), 'email' => array( 'type' => 'string', 'maxLength' => 191, 'sanitize_callback' => 'sanitize_email', ), ), 'permission_callback' => '__return_true', ) ); register_rest_route( Routes::NS, '/view', array( 'methods' => 'POST', 'callback' => array( $this, 'view' ), 'args' => array( 'article' => Args::id() ), 'permission_callback' => '__return_true', ) ); } /** * Visitor fingerprint for rate limiting (not stored in raw form). * * @return string */ public static function visitor_hash() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; return md5( $ip . '|' . $ua . '|' . wp_salt( 'nonce' ) ); } /** * Whether the current visitor looks like a bot. * * @return bool */ private function is_bot() { $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) ) : ''; return '' === $ua || (bool) preg_match( '/bot|crawl|spider|slurp|preview|headless|lighthouse/', $ua ); } /** * Whether an article is publicly viewable for the current user. * * @param int $id Article id. * @return \WP_Post|null */ private function viewable_article( $id ) { $post = get_post( (int) $id ); if ( ! $post || PostTypes::ARTICLE !== $post->post_type || 'publish' !== $post->post_status || ! empty( $post->post_password ) ) { return null; } return apply_filters( 'kdocs_can_view_article', true, $post ) ? $post : null; } /** * Instant search. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function search( $request ) { $term = trim( (string) $request['q'] ); $kb_id = (int) $request['kb']; if ( mb_strlen( $term ) < 2 ) { return rest_ensure_response( array( 'results' => array(), 'categories' => array(), ) ); } /** * Lets an external engine (Pro: Algolia) answer the search instead of WP_Query. * * Return null to fall through. Otherwise return a list of results in the same * shape as below: id, title, excerpt (escaped HTML, only ), link, kb, * category. Results must already exclude articles the visitor cannot view. * * @param array|null $results Results or null. * @param string $term Search term. * @param int $kb_id KB id (0 = all). */ $override = apply_filters( 'kdocs_search_override', null, $term, $kb_id ); if ( is_array( $override ) ) { /** This filter is documented below. */ $override = apply_filters( 'kdocs_search_results', $override, $term, $kb_id ); do_action( 'kdocs_search_performed', $term, $kb_id, count( $override ) ); return rest_ensure_response( array( 'results' => $override, 'categories' => array(), ) ); } $extra = array(); if ( Index::ready() ) { // Section index: ranked, linked to the best section, typo tolerant, with facets. $found = Engine::search( $term, array( 'kb' => $kb_id, 'category' => (int) $request['cat'], ) ); $results = $found['results']; $extra = array( 'total' => $found['total'], 'facets' => $found['facets'], 'suggestion' => $found['suggestion'], 'corrected' => $found['corrected'], ); } else { $results = $this->query_search( $term, $kb_id ); } /** * Filters the article results (Pro: pinned results and synonyms). * * @param array[] $results Results (see result()). * @param string $term Search term. * @param int $kb_id KB id (0 = all). */ $results = apply_filters( 'kdocs_search_results', $results, $term, $kb_id ); $categories = array(); $cat_args = array( 'taxonomy' => Taxonomies::CATEGORY, 'search' => $term, 'number' => 4, 'hide_empty' => true, ); if ( $kb_id ) { $cat_args['meta_key'] = 'kdocs_kb'; // phpcs:ignore WordPress.DB.SlowDBQuery $cat_args['meta_value'] = $kb_id; // phpcs:ignore WordPress.DB.SlowDBQuery } $cats = get_terms( $cat_args ); if ( ! is_wp_error( $cats ) ) { foreach ( $cats as $cat ) { $categories[] = array( 'id' => $cat->term_id, 'title' => html_entity_decode( $cat->name, ENT_QUOTES, 'UTF-8' ), 'link' => get_term_link( $cat ), 'count' => (int) $cat->count, ); } } /** * Fires after every search (Pro uses it for search analytics). * * @param string $term Term. * @param int $kb_id KB id. * @param int $results Number of results. */ do_action( 'kdocs_search_performed', $term, $kb_id, count( $results ) ); return rest_ensure_response( array( 'results' => $results, 'categories' => $categories, ) + $extra ); } /** * WP_Query search, used until the section index has been built. * * @param string $term Term. * @param int $kb_id KB id. * @return array[] */ private function query_search( $term, $kb_id ) { $args = array( 'post_type' => PostTypes::ARTICLE, 'post_status' => 'publish', 's' => $term, 'posts_per_page' => 12, 'has_password' => false, ); if ( $kb_id ) { $args['tax_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery array( 'taxonomy' => Taxonomies::KB, 'terms' => $kb_id, ), ); } /** * Filters search query args (e.g. to plug in an external search engine). * * @param array $args Query args. * @param string $term Search term. * @param int $kb_id KB id. */ $args = apply_filters( 'kdocs_search_args', $args, $term, $kb_id ); $query = new \WP_Query( $args ); $results = array(); foreach ( $query->posts as $post ) { if ( ! apply_filters( 'kdocs_can_view_article', true, $post ) ) { continue; } $results[] = self::result( $post, $term ); } return $results; } /** * One search result. * * @param \WP_Post $post Article. * @param string $term Search term (highlighted in the excerpt). * @return array */ public static function result( $post, $term ) { $kb = Taxonomies::article_kb( $post ); $cat = Taxonomies::article_category( $post ); return array( 'id' => $post->ID, 'title' => html_entity_decode( get_the_title( $post ), ENT_QUOTES, 'UTF-8' ), 'excerpt' => self::snippet( $post, $term ), 'link' => get_permalink( $post ), 'kb' => $kb ? html_entity_decode( $kb->name, ENT_QUOTES, 'UTF-8' ) : '', 'category' => $cat ? html_entity_decode( $cat->name, ENT_QUOTES, 'UTF-8' ) : '', ); } /** * Builds a highlighted excerpt around the first match. * * @param \WP_Post $post Post. * @param string $term Term. * @return string Safe HTML (only ). */ private static function snippet( $post, $term ) { // Tags become spaces so adjacent blocks don't run together. $text = $post->post_excerpt ? $post->post_excerpt : wp_strip_all_tags( preg_replace( '/<[^>]+>/', ' ', strip_shortcodes( excerpt_remove_blocks( $post->post_content ) ) ) ); $text = trim( preg_replace( '/\s+/', ' ', html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ) ) ); $pos = mb_stripos( $text, $term ); $from = false === $pos ? 0 : max( 0, $pos - 60 ); $cut = mb_substr( $text, $from, 180 ); $cut = ( $from > 0 ? '…' : '' ) . $cut . ( mb_strlen( $text ) > $from + 180 ? '…' : '' ); $safe = esc_html( $cut ); $words = array_filter( array_map( static function ( $w ) { return preg_quote( $w, '/' ); }, preg_split( '/\s+/', esc_html( $term ) ) ) ); if ( $words ) { $safe = preg_replace( '/(' . implode( '|', $words ) . ')/iu', '$1', $safe ); } return $safe; } /** * Records a helpful / not helpful vote. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function feedback( $request ) { $post = $this->viewable_article( $request->get_param( 'article' ) ); $vote = $request->get_param( 'vote' ); if ( ! $post || ! in_array( $vote, array( 'yes', 'no' ), true ) ) { return new \WP_Error( 'kdocs_invalid', __( 'Invalid feedback.', 'karma-docs' ), array( 'status' => 400 ) ); } $message = sanitize_textarea_field( (string) $request->get_param( 'message' ) ); $email = sanitize_email( (string) $request->get_param( 'email' ) ); $reason = sanitize_key( (string) $request->get_param( 'reason' ) ); $reason = 'no' === $vote && isset( \KarmaDocs\Frontend\View::feedback_reasons()[ $reason ] ) ? $reason : ''; $lock = 'kdocs_fb_' . md5( self::visitor_hash() . $post->ID ); // A reason, counted once per reader and article. if ( $reason ) { $reason_lock = 'kdocs_fbr_' . md5( self::visitor_hash() . $post->ID ); if ( get_transient( $reason_lock ) ) { $reason = ''; } else { set_transient( $reason_lock, 1, DAY_IN_SECONDS ); $key = '_kdocs_reason_' . $reason; update_post_meta( $post->ID, $key, (int) get_post_meta( $post->ID, $key, true ) + 1 ); } } // The vote itself; a follow-up (reason / message) is sent separately. if ( '' === $message && '' === $reason && ! $request->get_param( 'reason' ) ) { if ( get_transient( $lock ) ) { return rest_ensure_response( array( 'recorded' => false, 'duplicate' => true, ) ); } set_transient( $lock, $vote, DAY_IN_SECONDS ); $key = 'yes' === $vote ? '_kdocs_helpful_yes' : '_kdocs_helpful_no'; update_post_meta( $post->ID, $key, (int) get_post_meta( $post->ID, $key, true ) + 1 ); } /** * Fires when feedback is submitted (Pro stores messages and sends notifications). * * @param int $article_id Article. * @param string $vote yes|no. * @param string $message Optional message. * @param string $email Optional email. * @param string $reason Optional reason (see View::feedback_reasons()). */ do_action( 'kdocs_feedback_submitted', $post->ID, $vote, $message, $email, $reason ); return rest_ensure_response( array( 'recorded' => true ) ); } /** * Counts a view (called by JS so page caching doesn't skip it). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function view( $request ) { $post = $this->viewable_article( $request->get_param( 'article' ) ); if ( ! $post || $this->is_bot() ) { return rest_ensure_response( array( 'recorded' => false ) ); } /** * Whether to count views from logged in editors. * * @param bool $count Count. */ if ( current_user_can( 'edit_post', $post->ID ) && ! apply_filters( 'kdocs_count_editor_views', false ) ) { return rest_ensure_response( array( 'recorded' => false ) ); } update_post_meta( $post->ID, '_kdocs_views', (int) get_post_meta( $post->ID, '_kdocs_views', true ) + 1 ); /** * Fires after a view was counted. * * @param int $article_id Article. */ do_action( 'kdocs_article_viewed', $post->ID ); return rest_ensure_response( array( 'recorded' => true ) ); } }