| @@ -114,11 +114,13 @@ | ||
| 114 | 114 | 'total' => count( $questions ), |
| 115 | 115 | 'questions' => $questions, |
| 116 | 116 | ); |
| 117 | 117 | |
| 118 | + $intro = sanitize_textarea_field( (string) ( $decoded['intro'] ?? '' ) ); | |
| 119 | + | |
| 118 | 120 | return array( |
| 119 | 121 | 'type' => 'quiz', |
| 120 | - 'message' => $decoded['intro'] ?? __( 'Quick quiz started. Answer each question to continue.', 'learnpress' ), | |
| 122 | + 'message' => ! empty( $intro ) ? $intro : __( 'Quick quiz started. Answer each question to continue.', 'learnpress' ), | |
| 121 | 123 | 'quiz' => $quiz_state, |
| 122 | 124 | ); |
| 123 | 125 | } |
| 124 | 126 | |
| @@ -339,30 +341,26 @@ | ||
| 339 | 341 | |
| 340 | 342 | /** |
| 341 | 343 | * Sanitize and normalize generated quiz questions from model output. |
| 342 | 344 | * |
| 343 | - * If $question_count is null, returns questions as-is (trusting OpenAI's output). | |
| 344 | - * If $question_count is set, caps to exactly that many questions. | |
| 345 | + * Every question is sanitized and given a known shape regardless of whether an | |
| 346 | + * explicit count was requested. Model output is untrusted input: this state is sent | |
| 347 | + * to the browser, persisted in localStorage, and echoed back on the next turn, so | |
| 348 | + * the previous "trust the model when no count was asked for" path is not safe. | |
| 345 | 349 | * |
| 350 | + * $question_count only caps how many valid questions are kept. | |
| 351 | + * | |
| 346 | 352 | * @param array $questions Raw question payload. |
| 347 | - * @param int|null $question_count Maximum number of questions to keep, or null to trust model. | |
| 353 | + * @param int|null $question_count Exact number of questions to keep, or null to keep all valid ones. | |
| 348 | 354 | * |
| 349 | 355 | * @return array |
| 350 | 356 | */ |
| 351 | 357 | private function sanitize_quiz_questions( array $questions, ?int $question_count = null ): array { |
| 352 | 358 | |
| 353 | - if ( empty( $questions ) || ! is_array( $questions ) ) { | |
| 359 | + if ( empty( $questions ) ) { | |
| 354 | 360 | return array(); |
| 355 | 361 | } |
| 356 | 362 | |
| 357 | - // If no explicit count, trust OpenAI's output (typically 3-5 questions). | |
| 358 | - if ( $question_count === null ) { | |
| 359 | - return array_filter( | |
| 360 | - array_map( static fn( $q ) => is_array( $q ) ? $q : null, $questions ), | |
| 361 | - static fn( $q ) => null !== $q | |
| 362 | - ); | |
| 363 | - } | |
| 364 | - | |
| 365 | 363 | $sanitized = array(); |
| 366 | 364 | foreach ( $questions as $question ) { |
| 367 | 365 | if ( ! is_array( $question ) ) { |
| 368 | 366 | continue; |
| @@ -391,9 +389,9 @@ | ||
| 391 | 389 | 'correct_index' => $correct_index, |
| 392 | 390 | 'explanation' => sanitize_textarea_field( (string) ( $question['explanation'] ?? '' ) ), |
| 393 | 391 | ); |
| 394 | 392 | |
| 395 | - if ( count( $sanitized ) >= max( 1, $question_count ) ) { | |
| 393 | + if ( null !== $question_count && count( $sanitized ) >= max( 1, $question_count ) ) { | |
| 396 | 394 | break; |
| 397 | 395 | } |
| 398 | 396 | } |
| 399 | 397 | |