listening_stats = new Listening_Statistics(); // Register AJAX hooks. add_action( 'wp_ajax_accessibility_audio_tts_track_listening', array( $this, 'handle_track_listening' ) ); add_action( 'wp_ajax_nopriv_accessibility_audio_tts_track_listening', array( $this, 'handle_track_listening' ) ); add_action( 'wp_ajax_accessibility_audio_tts_get_post_stats', array( $this, 'handle_get_post_stats' ) ); add_action( 'wp_ajax_nopriv_accessibility_audio_tts_get_post_stats', array( $this, 'handle_get_post_stats' ) ); } /** * Handle AJAX request to track listening event. * * @return void */ public function handle_track_listening() { // Check if request method is POST. if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) { wp_send_json_error( __( 'Invalid request method', 'accessibility-audio-tts-text-to-speech-for-articles' ), 405 ); return; } // Check for required CSRF token. if ( ! check_ajax_referer( 'accessibility_audio_tts_listening_nonce', 'nonce', false ) ) { wp_send_json_error( array( 'error' => __( 'Security check failed', 'accessibility-audio-tts-text-to-speech-for-articles' ) ), 403 ); return; } // Optional analytics is disabled by default and requires a consent declaration from the CMP bridge. if ( 'consented' !== \Accessibility_Audio_TTS\Core\Privacy_Settings::get( 'analytics_mode' ) || '1' !== ( $_POST['analytics_consent'] ?? '' ) ) { wp_send_json_error( array( 'error' => __( 'Listening statistics require enabled analytics and visitor consent.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ), 403 ); return; } // Sanitize and validate input. $post_id = filter_var( $_POST['post_id'] ?? '', FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ); $listen_duration = filter_var( $_POST['listen_duration'] ?? '', FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 5, 'max_range' => 21600 ) ) ); $audio_duration = isset( $_POST['audio_duration'] ) ? filter_var( $_POST['audio_duration'], FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1, 'max_range' => 21600 ) ) ) : null; if ( false === $post_id || false === $listen_duration || false === $audio_duration || ( null !== $audio_duration && $listen_duration > $audio_duration + 1 ) ) { wp_send_json_error( array( 'error' => __( 'Invalid listening duration or post ID', 'accessibility-audio-tts-text-to-speech-for-articles' ) ), 400 ); return; } if ( 0 >= $post_id ) { wp_send_json_error( array( 'error' => __( 'Invalid post ID', 'accessibility-audio-tts-text-to-speech-for-articles' ) ) ); return; } if ( $listen_duration < 5 ) { wp_send_json_error( array( 'error' => __( 'Listen duration must be at least 5 seconds', 'accessibility-audio-tts-text-to-speech-for-articles' ) ) ); return; } // Verify post exists and has audio. if ( ! $this->post_has_audio( $post_id ) ) { wp_send_json_error( array( 'error' => __( 'Post does not have audio or does not exist', 'accessibility-audio-tts-text-to-speech-for-articles' ) ) ); return; } // The client cannot choose the recording's trusted length. $file = \Accessibility_Audio_TTS\Core\Audio_Access::resolve_audio_file( $post_id ); if ( ! $file ) { wp_send_json_error( array( 'error' => 'Recording is unavailable.' ), 400 ); return; } $duration_key = 'aat_audio_length_' . hash( 'sha256', $file['path'] . ':' . filemtime( $file['path'] ) . ':' . filesize( $file['path'] ) ); $trusted_duration = get_transient( $duration_key ); if ( false === $trusted_duration ) { require_once ABSPATH . 'wp-admin/includes/media.php'; $metadata = wp_read_audio_metadata( $file['path'] ); $trusted_duration = is_array( $metadata ) && isset( $metadata['length'] ) ? (float) $metadata['length'] : 0; if ( $trusted_duration > 0 ) { set_transient( $duration_key, $trusted_duration, DAY_IN_SECONDS ); } } if ( $trusted_duration <= 0 || $listen_duration > min( 21600, (int) ceil( $trusted_duration ) + 1 ) ) { wp_send_json_error( array( 'error' => 'Listening duration exceeds the recording length.' ), 400 ); return; } $audio_duration = (int) ceil( $trusted_duration ); $client = hash_hmac( 'sha256', (string) ( $_SERVER['REMOTE_ADDR'] ?? '' ), wp_salt( 'nonce' ) ); $bucket_key = hash( 'sha256', $client . ':' . (int) floor( time() / 60 ) ); if ( ! $this->listening_stats->reserve_request_slot( $bucket_key, time() + 120, 600 ) ) { wp_send_json_error( array( 'error' => 'Too many listening events.' ), 429 ); return; } $event = $_POST['event_id'] ?? ''; if ( ! is_string( $event ) || ! preg_match( '/^[a-zA-Z0-9-]{16,64}$/D', $event ) ) { wp_send_json_error( array( 'error' => 'Invalid event identifier.' ), 400 ); return; } $event_key = hash( 'sha256', $post_id . ':' . $event ); // Repeated event IDs carry cumulative progress; the service adds only deltas. $result = $this->listening_stats->record_listening_event( $post_id, $listen_duration, $audio_duration, $event_key, true ); if ( $result ) { // Log safely. accessibility_audio_tts_debug_log( sprintf( 'AJAX: Listening tracked for post %d, duration %ds', absint( $post_id ), absint( $listen_duration ) ) ); wp_send_json_success( array( 'message' => __( 'Listening event recorded successfully', 'accessibility-audio-tts-text-to-speech-for-articles' ), 'post_id' => $post_id, 'duration' => $listen_duration, ) ); } elseif ( $this->listening_stats->event_exists( $event_key ) ) { wp_send_json_success( array( 'post_id' => $post_id, 'duplicate' => true ) ); } else { wp_send_json_error( array( 'error' => __( 'Failed to record listening event', 'accessibility-audio-tts-text-to-speech-for-articles' ) ) ); } } /** * Handle AJAX request to get post statistics. * * @return void */ public function handle_get_post_stats() { // Check if request method is POST. if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || 'POST' !== sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) { wp_send_json_error( __( 'Invalid request method', 'accessibility-audio-tts-text-to-speech-for-articles' ), 405 ); return; } // Check for required CSRF token. if ( ! check_ajax_referer( 'accessibility_audio_tts_listening_nonce', 'nonce', false ) ) { wp_send_json_error( array( 'error' => __( 'Security check failed', 'accessibility-audio-tts-text-to-speech-for-articles' ) ), 403 ); return; } $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; if ( 0 >= $post_id ) { wp_send_json_error( array( 'error' => __( 'Invalid post ID', 'accessibility-audio-tts-text-to-speech-for-articles' ) ) ); return; } if ( ! $this->post_has_audio( $post_id ) ) { wp_send_json_error( array( 'error' => 'Recording is unavailable.' ), 403 ); return; } // Get statistics for the post. $stats = $this->listening_stats->get_post_listening_stats( $post_id ); wp_send_json_success( array( 'stats' => $stats, 'post_id' => $post_id, ) ); } /** * Verify if post exists and has audio. * * @param int $post_id Post ID. * @return bool True if audio exists for the post. */ private function post_has_audio( $post_id ) { $post_id = absint( $post_id ); $post = get_post( $post_id ); if ( \Accessibility_Audio_TTS\Core\Audio_Access::is_restricted( $post ) ) { return false; } // Check if post has generated audio. $audio_url = get_post_meta( $post_id, 'accessibility_audio_tts_url', true ); return ! empty( $audio_url ); } /** * Get listening statistics for admin dashboard. * * @return array Dashboard statistics. */ public function get_dashboard_stats() { return array( 'total_audio_requests' => $this->listening_stats->get_total_audio_requests(), 'recent_audio_requests' => $this->listening_stats->get_recent_audio_requests(), 'audio_request_trend' => $this->listening_stats->get_audio_request_trend(), 'total_active_listens' => $this->listening_stats->get_total_active_listens(), 'recent_active_listens' => $this->listening_stats->get_recent_active_listens(), 'listening_trend' => $this->listening_stats->get_listening_trend(), ); } }