content = $content; $this->cache = $cache; add_action( 'wp_ajax_ai_narration_session', array( $this, 'create_session' ) ); add_action( 'wp_ajax_nopriv_ai_narration_session', array( $this, 'create_session' ) ); add_action( 'wp_ajax_ai_narration_voice_preview', array( $this, 'serve_voice_preview' ) ); } public function create_bootstrap_token( int $post_id ): string { $post = get_post( $post_id ); if ( ! $post instanceof WP_Post ) { return ''; } $message = $post_id . '|' . $post->post_modified_gmt; return hash_hmac( 'sha256', $message, wp_salt( 'nonce' ) ); } public function create_session(): void { $reference = $this->new_reference(); $this->require_same_origin( $reference ); // This public playback endpoint uses the signed article bootstrap token below rather than a user nonce. // phpcs:disable WordPress.Security.NonceVerification.Missing $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; $bootstrap = isset( $_POST['bootstrap'] ) ? sanitize_text_field( wp_unslash( $_POST['bootstrap'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! $post_id || ! hash_equals( $this->create_bootstrap_token( $post_id ), $bootstrap ) ) { $this->send_error( 'invalid_article', $reference, 403 ); } $settings = AI_Narration_Admin::get_settings(); $profile_id = 'a'; if ( ! $this->content->is_eligible( $post_id, $settings ) ) { $this->send_error( 'unavailable', $reference, 403 ); } $cache_manifest = $this->cache->get_available_manifest( $post_id ); if ( null === $cache_manifest ) { $this->send_error( 'audio_unavailable', $reference, 404 ); } try { $prepared = $this->content->prepare( $post_id ); } catch ( RuntimeException $exception ) { $this->log( $reference, 'content_prepare_failed', array( 'post' => $post_id, 'detail' => $exception->getMessage() ) ); $this->send_error( 'article_unavailable', $reference, 422 ); } $ip_hash = $this->ip_hash(); $session_id = bin2hex( random_bytes( 12 ) ); $expires = time() + self::SESSION_LIFETIME; $session_chunk_count = count( $cache_manifest['chunks'] ); $payload = array( 'v' => 1, 'post' => $post_id, 'hash' => $prepared['hash'], 'chunks' => $session_chunk_count, 'exp' => $expires, 'sid' => $session_id, 'ip' => $ip_hash, 'profile' => $profile_id, ); $token = $this->sign_payload( $payload ); wp_send_json_success( array( 'session' => $token, 'chunks' => $session_chunk_count, 'cache' => $cache_manifest['chunks'], 'words' => $prepared['words'], 'expires' => $expires, 'reference' => strtoupper( substr( $session_id, 0, 10 ) ), ), 200 ); } public function serve_voice_preview(): void { $reference = $this->new_reference(); if ( ! current_user_can( 'manage_options' ) ) { $this->send_error( 'preview_forbidden', $reference, 403 ); } $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'ai_narration_voice_preview' ) ) { $this->send_error( 'preview_expired', $reference, 403 ); } $this->require_same_origin( $reference ); if ( ! AI_Narration_Admin::has_api_key() ) { $this->send_error( 'openai_key_missing', $reference, 400 ); } $user_id = get_current_user_id(); $bucket = 'voice-preview-hour-' . $user_id; if ( ! $this->consume_limit( $bucket, 30, HOUR_IN_SECONDS ) ) { $this->send_error( 'preview_rate_limited', $reference, 429 ); } $model = isset( $_POST['model'] ) ? sanitize_text_field( wp_unslash( $_POST['model'] ) ) : ''; $voice = isset( $_POST['voice'] ) ? sanitize_text_field( wp_unslash( $_POST['voice'] ) ) : ''; $model = sanitize_key( $model ); $voice = sanitize_key( $voice ); if ( ! array_key_exists( $model, AI_Narration_Admin::models() ) || ! array_key_exists( $voice, AI_Narration_Admin::voices() ) ) { $this->send_error( 'invalid_preview_selection', $reference, 400 ); } $instructions = isset( $_POST['instructions'] ) ? sanitize_textarea_field( wp_unslash( $_POST['instructions'] ) ) : ''; $instructions = $this->limit_string( $instructions, 4096 ); $preview_settings = array( 'model' => $model, 'voice' => $voice, 'instructions' => $instructions, 'read_h2_headings' => ! empty( $_POST['read_h2_headings'] ), ); $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; if ( $post_id > 0 ) { try { $prepared = $this->content->prepare( $post_id, $preview_settings ); $preview_text = $this->preview_excerpt( (string) $prepared['text'] ); } catch ( RuntimeException $exception ) { $this->log( $reference, 'article_preview_failed', array( 'post' => $post_id, 'detail' => $exception->getMessage() ) ); $this->send_error( 'preview_article_unavailable', $reference, 422 ); } } else { $preview_text = __( 'Welcome to AI Narration. This is a short preview of the selected voice, pacing and speaking style.', 'ai-narration' ); } $mime = 'audio/aac'; $filename = 'voice-preview.aac'; $audio = $this->request_openai_audio( $preview_text, 0, 0, $reference, $preview_settings, 'voice_preview' ); if ( 0 === $post_id ) { AI_Narration_Admin::mark_voice_previewed(); } nocache_headers(); header( 'Cache-Control: no-store, no-cache, must-revalidate, private, max-age=0' ); header( 'Pragma: no-cache' ); header( 'Expires: 0' ); header( 'Content-Type: ' . $mime ); header( 'Content-Disposition: inline; filename="' . $filename . '"' ); header( 'X-Content-Type-Options: nosniff' ); header( 'X-AI-Narration-Reference: ' . $reference ); echo $audio; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary preview-audio response. wp_die( '', '', array( 'response' => 200 ) ); } private function limit_string( string $text, int $limit ): string { if ( function_exists( 'mb_substr' ) ) { return mb_substr( $text, 0, $limit, 'UTF-8' ); } return substr( $text, 0, $limit ); } private function limit_utf8_bytes( string $text, int $limit ): string { if ( strlen( $text ) <= $limit ) { return $text; } $candidate = substr( $text, 0, $limit ); while ( '' !== $candidate && 1 !== preg_match( '//u', $candidate ) ) { $candidate = substr( $candidate, 0, -1 ); } return $candidate; } private function preview_excerpt( string $text ): string { $text = trim( $text ); $limit = 1100; $length = function_exists( 'mb_strlen' ) ? mb_strlen( $text, 'UTF-8' ) : strlen( $text ); if ( $length <= $limit ) { return $text; } $excerpt = function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $limit, 'UTF-8' ) : substr( $text, 0, $limit ); $sentence_end = -1; foreach ( array( '.', '!', '?' ) as $punctuation ) { $position = function_exists( 'mb_strrpos' ) ? mb_strrpos( $excerpt, $punctuation, 0, 'UTF-8' ) : strrpos( $excerpt, $punctuation ); if ( false !== $position ) { $sentence_end = max( $sentence_end, (int) $position ); } } if ( $sentence_end >= 500 ) { $excerpt = function_exists( 'mb_substr' ) ? mb_substr( $excerpt, 0, $sentence_end + 1, 'UTF-8' ) : substr( $excerpt, 0, $sentence_end + 1 ); } return trim( $excerpt ); } /** * @param array|null $settings_override Validated settings for an administrator preview. */ private function request_openai_audio( string $text, int $post_id, int $index, string $reference, ?array $settings_override = null, string $source = 'article', string $profile_id = 'a' ): string { $settings = is_array( $settings_override ) ? $settings_override : AI_Narration_Admin::get_settings(); if ( ! is_array( $settings_override ) ) { $settings = $this->article_profile_settings( $settings, $profile_id ); } $model = isset( $settings['model'] ) && array_key_exists( $settings['model'], AI_Narration_Admin::models() ) ? (string) $settings['model'] : 'gpt-4o-mini-tts'; $voice = isset( $settings['voice'] ) && array_key_exists( $settings['voice'], AI_Narration_Admin::voices() ) ? (string) $settings['voice'] : 'cedar'; $body = array( 'model' => $model, 'input' => $text, 'voice' => $voice, 'response_format' => 'aac', ); $instructions = trim( (string) $settings['instructions'] ); if ( str_starts_with( $model, 'gpt-4o-mini-tts' ) && '' !== $instructions ) { $body['instructions'] = $instructions; } $response = wp_remote_post( self::OPENAI_ENDPOINT, array( 'timeout' => 60, 'redirection' => 0, 'sslverify' => true, 'limit_response_size' => 8 * MB_IN_BYTES, 'headers' => array( 'Authorization' => 'Bearer ' . AI_Narration_Admin::get_api_key(), 'Content-Type' => 'application/json', 'Accept' => 'audio/aac', ), 'body' => wp_json_encode( $body ), 'data_format' => 'body', ) ); if ( is_wp_error( $response ) ) { $this->log( $reference, 'openai_transport_error', array( 'source' => $source, 'post' => $post_id, 'chunk' => $index, 'code' => $response->get_error_code(), ) ); $this->send_error( 'openai_transport', $reference, 502 ); } $status = (int) wp_remote_retrieve_response_code( $response ); $response_body = (string) wp_remote_retrieve_body( $response ); $request_id = sanitize_text_field( (string) wp_remote_retrieve_header( $response, 'x-request-id' ) ); if ( 200 !== $status || '' === $response_body || str_starts_with( ltrim( $response_body ), '{' ) ) { $error_code = ''; $decoded = json_decode( $response_body, true ); if ( is_array( $decoded ) && isset( $decoded['error']['code'] ) ) { $error_code = sanitize_key( (string) $decoded['error']['code'] ); } elseif ( is_array( $decoded ) && isset( $decoded['error']['type'] ) ) { $error_code = sanitize_key( (string) $decoded['error']['type'] ); } $this->log( $reference, 'openai_http_error', array( 'source' => $source, 'post' => $post_id, 'chunk' => $index, 'http_status' => $status, 'openai_code' => $error_code, 'openai_request_id' => $request_id, ) ); if ( 401 === $status || 403 === $status ) { $this->send_error( 'openai_auth', $reference, 502 ); } if ( 429 === $status ) { $this->send_error( 'openai_rate_limit', $reference, 503 ); } $this->send_error( 'openai_error', $reference, 502 ); } return $response_body; } /** * Apply an article profile's voice and instructions to the established * OpenAI on-demand settings shape without changing its shared model. * * @param array $settings Saved narration settings. * @return array */ private function article_profile_settings( array $settings, string $profile_id ): array { return $settings; } private function require_same_origin( string $reference ): void { $source = ''; if ( isset( $_SERVER['HTTP_ORIGIN'] ) ) { $source = esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ); } elseif ( isset( $_SERVER['HTTP_REFERER'] ) ) { $source = esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ); } $source_host = strtolower( (string) wp_parse_url( $source, PHP_URL_HOST ) ); $home_host = strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) ); if ( '' === $source_host || '' === $home_host || ! hash_equals( $home_host, $source_host ) ) { $this->send_error( 'origin_rejected', $reference, 403 ); } } /** * @return array|null */ private function verify_payload( string $token ): ?array { $parts = explode( '.', $token, 2 ); if ( 2 !== count( $parts ) ) { return null; } $expected = hash_hmac( 'sha256', $parts[0], wp_salt( 'auth' ) ); if ( ! hash_equals( $expected, $parts[1] ) ) { return null; } $json = $this->base64url_decode( $parts[0] ); if ( false === $json ) { return null; } $payload = json_decode( $json, true ); return is_array( $payload ) ? $payload : null; } /** * @param array $payload Session payload. */ private function sign_payload( array $payload ): string { $encoded = $this->base64url_encode( (string) wp_json_encode( $payload ) ); $signature = hash_hmac( 'sha256', $encoded, wp_salt( 'auth' ) ); return $encoded . '.' . $signature; } private function base64url_encode( string $value ): string { return rtrim( strtr( base64_encode( $value ), '+/', '-_' ), '=' ); } /** * @return string|false */ private function base64url_decode( string $value ) { $padding = strlen( $value ) % 4; if ( $padding ) { $value .= str_repeat( '=', 4 - $padding ); } return base64_decode( strtr( $value, '-_', '+/' ), true ); } private function consume_limit( string $bucket, int $limit, int $ttl ): bool { $key = 'ai_narration_' . hash( 'sha256', $bucket ); $current = (int) get_transient( $key ); if ( $current >= $limit ) { return false; } set_transient( $key, $current + 1, $ttl ); return true; } /** * Count a narration chunk once per signed session. Native media engines can * retry the same URL (including Safari byte-range probes), so those retries * must not exhaust the broader visitor limit. The separate per-chunk limit * still caps the number of billable generation attempts. * * @param array $payload Verified session payload. */ private function consume_audio_limit_once( array $payload, int $index, string $limit_scope, int $audio_limit ): bool { $session_id = isset( $payload['sid'] ) ? sanitize_key( (string) $payload['sid'] ) : ''; $dedupe_key = 'audio-counted-' . $session_id . '-' . $index; $transient = 'ai_narration_' . hash( 'sha256', $dedupe_key ); if ( '' !== $session_id && false !== get_transient( $transient ) ) { return true; } if ( ! $this->consume_limit( 'audio-10m-' . $limit_scope, $audio_limit, 10 * MINUTE_IN_SECONDS ) ) { return false; } if ( '' !== $session_id ) { set_transient( $transient, 1, self::SESSION_LIFETIME ); } return true; } private function ip_hash(): string { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; return hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) ); } private function new_reference(): string { try { return strtoupper( substr( bin2hex( random_bytes( 8 ) ), 0, 10 ) ); } catch ( Exception $exception ) { return strtoupper( substr( wp_generate_password( 10, false, false ), 0, 10 ) ); } } private function send_error( string $code, string $reference, int $status ): void { wp_send_json_error( array( 'code' => sanitize_key( $code ), 'reference' => sanitize_text_field( $reference ), ), $status ); } /** * @param array $context Non-sensitive diagnostic context. */ private function log( string $reference, string $event, array $context = array() ): void { $parts = array( '[AI Narration]', 'reference=' . sanitize_text_field( $reference ), 'event=' . sanitize_key( $event ) ); foreach ( $context as $key => $value ) { $value = is_scalar( $value ) ? (string) $value : wp_json_encode( $value ); $value = preg_replace( '/[\r\n]+/', ' ', (string) $value ); $parts[] = sanitize_key( (string) $key ) . '=' . substr( (string) $value, 0, 180 ); } // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Deliberate non-sensitive operational logging. error_log( implode( ' ', $parts ) ); } }