settings = $settings; $this->ai_helper = new AIHelper( $settings ); // Register AJAX handlers add_action( 'wp_ajax_betterdocs_generate_article_summary', [ $this, 'generate_article_summary_callback' ] ); add_action( 'wp_ajax_nopriv_betterdocs_generate_article_summary', [ $this, 'generate_article_summary_callback' ] ); // Clear summary when post is updated add_action( 'post_updated', [ $this, 'clear_article_summary_on_update' ], 10, 3 ); } /** * Check if article summary feature is enabled * * @return bool */ public function is_enabled() { return $this->settings->get( 'enable_article_summary', false ); } /** * Get OpenAI API key from settings * * @return string */ public function get_api_key() { return $this->ai_helper->get_api_key(); } /** * AJAX callback for generating article summary */ public function generate_article_summary_callback() { // Check if Article Summary feature is enabled if ( ! $this->is_enabled() ) { wp_send_json_error( 'AI Doc Summarizer feature is not enabled.' ); wp_die(); } // Verify the nonce $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'betterdocs_article_summary_nonce' ) ) { wp_send_json_error( 'Invalid nonce' ); wp_die(); } $post_id = isset( $_POST['post_id'] ) ? intval( wp_unslash( $_POST['post_id'] ) ) : 0; //phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. // A valid documentation post is required. The summary is always derived from // the canonical post record below, never from anything in the request body. if ( $post_id <= 0 ) { wp_send_json_error( 'Invalid document.' ); wp_die(); } $post = get_post( $post_id ); // Validate that this is a docs post type. if ( ! $post || $post->post_type !== 'docs' ) { wp_send_json_error( 'Doc Summarizer is only available for documentation posts.' ); wp_die(); } // Only summarize content the requester is actually allowed to view, so an // unauthenticated caller can't trigger summaries of drafts/private docs. if ( 'publish' !== $post->post_status && ! current_user_can( 'read_post', $post_id ) ) { wp_send_json_error( 'Doc Summarizer is only available for published documentation posts.' ); wp_die(); } // Check if post is password protected and user hasn't provided correct password. if ( post_password_required( $post_id ) ) { wp_send_json_error( 'This document is password protected. Please enter the correct password to access the summary.' ); wp_die(); } // SECURITY (fbs-82814): never trust client-supplied title/content. Load the // canonical values server-side so prompt-injection payloads in the request // body cannot reach the AI model or influence the cache key. $post_title = $post->post_title; $post_content = wp_strip_all_tags( $post->post_content ); if ( empty( $post_content ) ) { wp_send_json_error( 'No content provided for summary generation.' ); wp_die(); } // Check if summary already exists in post meta if ( $post_id > 0 ) { $existing_summary = get_post_meta( $post_id, '_betterdocs_article_summary', true ); $content_hash = md5( $post_content ); $stored_hash = get_post_meta( $post_id, '_betterdocs_article_summary_hash', true ); // Return existing summary if content hasn't changed if ( ! empty( $existing_summary ) && $content_hash === $stored_hash ) { // Clean existing summary in case it has old formatting, and run it // through wp_kses_post() so any previously-stored poisoned markup is // neutralized before it is returned to the browser. (fbs-82814) $cleaned_existing = wp_kses_post( $this->clean_summary_content( $existing_summary ) ); wp_send_json_success( $cleaned_existing ); wp_die(); } } // Generate new summary using OpenAI $summary = $this->generate_summary( $post_title, $post_content ); if ( is_wp_error( $summary ) ) { wp_send_json_error( $summary->get_error_message() ); wp_die(); } // Count only fresh generations (cache hits returned earlier). AIUsage::record( 'article_summary', $post_id ); // Clean up the summary content and sanitize the AI output with wp_kses_post() // before it is stored or returned. This strips dangerous attributes/tags // (onerror,