Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/learnpress/config/settings/open-ai-admin.php
+++ b/learnpress/config/settings/open-ai-admin.php
@@ -37,17 +37,16 @@
'default' => 'gpt-4.1',
'type' => 'select',
'options' => array(
+ 'gpt-5.6' => esc_html__( 'GPT-5.6', 'learnpress' ),
+ 'gpt-5.5' => esc_html__( 'GPT-5.5', 'learnpress' ),
+ 'gpt-5.4' => esc_html__( 'GPT-5.4', 'learnpress' ),
+ 'gpt-5.3' => esc_html__( 'GPT-5.3', 'learnpress' ),
'gpt-5.2' => esc_html__( 'GPT-5.2', 'learnpress' ),
+ 'gpt-5.1' => esc_html__( 'GPT-5.1', 'learnpress' ),
'gpt-5' => esc_html__( 'GPT-5', 'learnpress' ),
'gpt-5-mini' => esc_html__( 'GPT-5 Mini', 'learnpress' ),
'gpt-5-nano' => esc_html__( 'GPT-5 Nano', 'learnpress' ),
'gpt-4.1' => esc_html__( 'GPT-4.1', 'learnpress' ),
- //'chatgpt-4o-latest' => esc_html__( 'ChatGPT 4o-Latest', 'learnpress' ),
- 'gpt-4o' => esc_html__( 'GPT-4o', 'learnpress' ),
- 'gpt-4o-mini' => esc_html__( 'GPT-4o Mini', 'learnpress' ),
- 'gpt-4' => esc_html__( 'GPT-4', 'learnpress' ),
- 'gpt-3.5-turbo' => esc_html__( 'GPT-3.5 Turbo', 'learnpress' ),
- 'gpt-3.5-turbo-instruct' => esc_html__( 'GPT-3.5 Turbo Instruct', 'learnpress' ),
),
],
[
@@ -57,8 +56,7 @@
'type' => 'select',
'options' => array(
'gpt-image-1' => esc_html__( 'GPT Image 1', 'learnpress' ),
- 'dall-e-3' => esc_html__( 'DALL-E 3', 'learnpress' ),
- 'dall-e-2' => esc_html__( 'DALL-E 2', 'learnpress' ),
+ 'gpt-image-2' => esc_html__( 'GPT Image 2', 'learnpress' ),
),
],
[
--- a/learnpress/config/settings/open-ai-modal.php
+++ b/learnpress/config/settings/open-ai-modal.php
@@ -116,26 +116,11 @@
'1536x1024' => esc_html__( '1536x1024 (landscape)', 'learnpress' ),
'1024x1536' => esc_html__( '1024x1536 (portrait)', 'learnpress' ),
],
- 'image-size-dall-e-2' => [
- '256x256' => esc_html__( '256x256', 'learnpress' ),
- '512x512' => esc_html__( '512x512', 'learnpress' ),
- '1024x1024' => esc_html__( '1024x1024', 'learnpress' ),
- ],
- 'image-size-dall-e-3' => [
- '1024x1024' => esc_html__( '1024x1024', 'learnpress' ),
- '1024x1792' => esc_html__( '1024x1792', 'learnpress' ),
- '1792x1024' => esc_html__( '1792x1024', 'learnpress' ),
- ],
'image-quality-gpt-image-1' => [
'auto' => esc_html__( 'Auto', 'learnpress' ),
'high' => esc_html__( 'High', 'learnpress' ),
'medium' => esc_html__( 'low', 'learnpress' ),
],
- 'image-quality-dall-e-3' => [
- 'auto' => esc_html__( 'Auto', 'learnpress' ),
- 'hd' => esc_html__( 'HD', 'learnpress' ),
- 'standard' => esc_html__( 'Standard', 'learnpress' ),
- ],
'image-quality' => [
'auto' => esc_html__( 'Auto', 'learnpress' ),
'standard' => esc_html__( 'Standard', 'learnpress' ),
--- a/learnpress/config/settings/openAi/prompt-create-image-course.php
+++ b/learnpress/config/settings/openAi/prompt-create-image-course.php
@@ -21,7 +21,7 @@
$title = ! empty( $title ) ? "The image should be inspired by the course title: $title." : '';
/**
- *A text description of the desired image(s).
+ * A text description of the desired image(s).
* The maximum length is 32000 characters for gpt-image-1,
* 1000 characters for dall-e-2
* and 4000 characters for dall-e-3.
--- a/learnpress/inc/AI/Assistant/AIAssistantController.php
+++ b/learnpress/inc/AI/Assistant/AIAssistantController.php
@@ -2,9 +2,12 @@
namespace LearnPressAIAssistant;
-use Exception;
+use LearnPressModelsCourseModel;
+use LearnPressModelsPostModel;
use LP_Settings;
+use LP_User;
use LearnPressServicesOpenAiService;
+use Exception;
/**
* AI Assistant Controller — validates requests, sanitizes input, calls Agent.
@@ -76,16 +79,115 @@
}
/**
+ * Course item types the assistant can be grounded on.
+ *
+ * Deliberately excludes LP_QUESTION_CPT and every third-party item type: an item
+ * type is only listed here once the assistant has a data loader and an access rule
+ * for it. Not a filter — widening this is a code change, reviewed as such.
+ *
+ * Naming contract for the `LearnPressAIAssistant` namespace:
+ * - `$item_id` — a course item ID whose type is not yet proven.
+ * - `$item_type` — the resolved curriculum type: LP_LESSON_CPT or LP_QUIZ_CPT.
+ * - `$lesson_id` — an `$item_id` already proven to be LP_LESSON_CPT.
+ * - `$quiz_id` — an `$item_id` already proven to be LP_QUIZ_CPT.
+ *
+ * Only proven IDs may be passed to the DataLoaders layer.
+ *
+ * @return string[]
+ */
+ public static function get_supported_item_types(): array {
+ return array( LP_LESSON_CPT, LP_QUIZ_CPT );
+ }
+
+ /**
+ * Resolve and authorize the composite course-item identity for a request.
+ *
+ * A curriculum item is identified by the tuple (course_id, item_type, item_id).
+ * `item_type` arrives from the client and is therefore untrusted: it selects which
+ * typed lookup runs, and the lookup itself is what proves the tuple. It never grants
+ * access on its own. Nothing is ever resolved from `item_id` alone.
+ *
+ * Used by both the AJAX controller and the template renderer so the two cannot drift.
+ *
+ * @param int $user_id Current user ID.
+ * @param int $course_id Course the item is claimed to belong to.
+ * @param string $item_type Raw item type from the request.
+ * @param int $item_id Course item ID.
+ *
+ * @return array{course:CourseModel,item:PostModel,item_id:int,item_type:string} Trusted context.
+ * @throws Exception When the tuple is invalid or the user may not view the item.
+ */
+ public static function resolve_item_access( int $user_id, int $course_id, string $item_type, int $item_id ): array {
+ $denied = __( 'You do not have permission to use the AI Assistant for this course item.', 'learnpress' );
+
+ // item_type is mandatory: without it there is no identity to verify.
+ $item_type = sanitize_key( $item_type );
+ if ( empty( $item_type ) || ! in_array( $item_type, self::get_supported_item_types(), true ) ) {
+ throw new Exception(
+ __( 'The AI Assistant is not available for this type of course item.', 'learnpress' )
+ );
+ }
+
+ if ( $user_id <= 0 || $course_id <= 0 || $item_id <= 0 ) {
+ throw new Exception( $denied );
+ }
+
+ $courseModel = CourseModel::find( $course_id, true );
+ if ( ! $courseModel instanceof CourseModel ) {
+ throw new Exception( $denied );
+ }
+
+ /**
+ * Resolves through the supplied type AND asserts curriculum membership, so a
+ * course_id/item_id pair from different courses cannot be combined, and a quiz
+ * ID cannot be resolved as a lesson.
+ */
+ $itemModel = $courseModel->get_item_model( $item_id, $item_type, true );
+ if ( ! $itemModel instanceof PostModel ) {
+ throw new Exception( $denied );
+ }
+
+ // Reject drafts, pending, private and trashed items — get_item_model() does not filter status.
+ if ( PostModel::STATUS_PUBLISH !== $itemModel->post_status ) {
+ throw new Exception( $denied );
+ }
+
+ // Canonical LearnPress access policy: course-level gate, then the item-level rule
+ // that lets preview items through. Both must pass.
+ $user = learn_press_get_user( $user_id );
+ if ( ! $user instanceof LP_User ) {
+ throw new Exception( $denied );
+ }
+
+ $can_view_course = $user->can_view_content_course( $course_id );
+ $can_view_item = $user->can_view_item( $item_id, $can_view_course );
+ if ( empty( $can_view_item->flag ) ) {
+ $message = (string) ( $can_view_item->message ?? '' );
+
+ throw new Exception( '' !== $message ? $message : $denied );
+ }
+
+ return array(
+ 'course' => $courseModel,
+ 'item' => $itemModel,
+ 'item_id' => $item_id,
+ 'item_type' => $item_type,
+ );
+ }
+
+ /**
* Handle an assistant chat request.
*
* @param array $data Raw decoded data from the AJAX request.
*
* @return array{type: string, message: string, quiz: array|null}
- * @throws Exception On validation failure or API error.
+ * @throws Exception On validation failure or denied access.
+ * @throws Throwable On provider/transport failure — logged and masked by the AJAX layer.
*/
public function handle_chat( array $data ): array {
$message = trim( $data['message'] ?? '' );
$item_id = absint( $data['item_id'] ?? 0 );
+ $item_type = is_scalar( $data['item_type'] ?? null ) ? (string) $data['item_type'] : '';
$course_id = absint( $data['course_id'] ?? 0 );
$history = $data['history'] ?? array();
$quiz_data = $data['active_quiz_questions'] ?? array();
@@ -109,6 +211,14 @@
throw new Exception( __( 'User must be logged in.', 'learnpress' ) );
}
+ /**
+ * Authoritative gate. Runs before the Agent is constructed, so a denied request
+ * costs no prompt construction, no quota accounting and no OpenAI call. The
+ * widget markup check is advisory only — this endpoint is reachable directly.
+ */
+ $access = self::resolve_item_access( $user_id, $course_id, $item_type, $item_id );
+ $item_type = $access['item_type'];
+
// Sanitize history — only allow safe role/content pairs.
$sanitized_history = array();
if ( is_array( $history ) ) {
@@ -131,6 +241,7 @@
return $agent->run(
sanitize_textarea_field( $message ),
$item_id,
+ $item_type,
$course_id,
$user_id,
$sanitized_history,
--- a/learnpress/inc/AI/Assistant/Agent.php
+++ b/learnpress/inc/AI/Assistant/Agent.php
@@ -40,18 +40,25 @@
/**
* Run the assistant agent loop.
*
+ * Callers must pass an $item_type already resolved and authorized by
+ * AIAssistantController::resolve_item_access(). This method routes on it but does
+ * not authorize — it is not a security boundary.
+ *
* @param string $user_message The learner's message.
- * @param int $item_id Current lesson ID.
+ * @param int $item_id Current course item ID, type proven by $item_type.
+ * @param string $item_type Resolved curriculum type: LP_LESSON_CPT or LP_QUIZ_CPT.
* @param int $course_id Current course ID.
* @param int $user_id Current user ID.
* @param array $history Previous conversation messages (role/content pairs).
* @param array $active_quiz Active quiz state for quiz-mode continuation.
+ * @param string $action_hint Optional validated quick-action hint.
*
* @return array{type: string, message: string, quiz: array|null}
*/
public function run(
string $user_message,
int $item_id,
+ string $item_type,
int $course_id,
int $user_id,
array $history = array(),
@@ -62,8 +69,22 @@
$this->quota_guard->reset();
$data_loaders = new DataLoaders();
- // Resume active quiz session.
+ $is_lesson = LP_LESSON_CPT === $item_type;
+ $is_quiz = LP_QUIZ_CPT === $item_type;
+
+ if ( ! $is_lesson && ! $is_quiz ) {
+ return $this->normalizer->build_response(
+ __( 'The AI Assistant is not available for this type of course item.', 'learnpress' )
+ );
+ }
+
+ // Resume active quiz session. Quick quiz is generated from lesson content, so a
+ // session may only continue while the authorized context is still a lesson.
if ( ! empty( $active_quiz['is_active'] ) && empty( $active_quiz['completed'] ) ) {
+ if ( ! $is_lesson ) {
+ return $this->get_lesson_only_response();
+ }
+
if ( ! AIAssistantController::is_action_enabled( IntentClassifier::INTENT_QUICK_QUIZ ) ) {
return $this->get_disabled_action_response( IntentClassifier::INTENT_QUICK_QUIZ );
}
@@ -82,6 +103,25 @@
return $this->get_disabled_action_response( $intent );
}
+ /**
+ * Typed routing. Smart Review reads a quiz attempt; every other intent is
+ * grounded in lesson content. $item_id is only renamed to $quiz_id/$lesson_id
+ * once the corresponding type check has passed.
+ */
+ if ( IntentClassifier::INTENT_SMART_REVIEW === $intent ) {
+ if ( ! $is_quiz ) {
+ return $this->normalizer->build_response(
+ __( 'Smart Review is only available on a quiz you have completed.', 'learnpress' )
+ );
+ }
+
+ return $this->handle_smart_review( $data_loaders, $user_message, $user_id, $course_id, $item_id, $history );
+ }
+
+ if ( ! $is_lesson ) {
+ return $this->get_lesson_only_response();
+ }
+
switch ( $intent ) {
case IntentClassifier::INTENT_SUMMARIZE:
return $this->handle_summarize( $data_loaders, $user_message, $item_id, $user_id, $history );
@@ -89,9 +129,6 @@
case IntentClassifier::INTENT_EXPLAIN:
return $this->handle_explain( $data_loaders, $user_message, $item_id, $user_id, $history );
- case IntentClassifier::INTENT_SMART_REVIEW:
- return $this->handle_smart_review( $data_loaders, $user_message, $user_id, $course_id, $item_id, $history );
-
case IntentClassifier::INTENT_QUICK_QUIZ:
return $this->quiz_engine->start( $data_loaders, $user_message, $item_id, $user_id, $history );
@@ -106,7 +143,7 @@
*
* @param string $user_message Learner input.
* @param array $history Conversation history.
- * @param int $item_id Current lesson ID.
+ * @param int $item_id Current course item ID (lesson or quiz).
* @param int $course_id Current course ID.
* @param int $user_id Current user ID.
* @param string|null $action_hint Optional quick-action hint from frontend.
@@ -372,6 +409,17 @@
);
}
+ /**
+ * Build a user-facing response for an action that requires a lesson context.
+ *
+ * @return array{type: string, message: string, quiz: array|null}
+ */
+ private function get_lesson_only_response(): array {
+ return $this->normalizer->build_response(
+ __( 'This assistant action is only available on a lesson.', 'learnpress' )
+ );
+ }
+
/**
* Build a user-facing response for a disabled assistant action.
*
--- a/learnpress/inc/AI/Assistant/QuickQuizEngine.php
+++ b/learnpress/inc/AI/Assistant/QuickQuizEngine.php
@@ -115,9 +115,11 @@
'questions' => $questions,
);
+ $intro = sanitize_textarea_field( (string) ( $decoded['intro'] ?? '' ) );
+
return array(
'type' => 'quiz',
- 'message' => $decoded['intro'] ?? __( 'Quick quiz started. Answer each question to continue.', 'learnpress' ),
+ 'message' => ! empty( $intro ) ? $intro : __( 'Quick quiz started. Answer each question to continue.', 'learnpress' ),
'quiz' => $quiz_state,
);
}
@@ -340,28 +342,24 @@
/**
* Sanitize and normalize generated quiz questions from model output.
*
- * If $question_count is null, returns questions as-is (trusting OpenAI's output).
- * If $question_count is set, caps to exactly that many questions.
+ * Every question is sanitized and given a known shape regardless of whether an
+ * explicit count was requested. Model output is untrusted input: this state is sent
+ * to the browser, persisted in localStorage, and echoed back on the next turn, so
+ * the previous "trust the model when no count was asked for" path is not safe.
+ *
+ * $question_count only caps how many valid questions are kept.
*
* @param array $questions Raw question payload.
- * @param int|null $question_count Maximum number of questions to keep, or null to trust model.
+ * @param int|null $question_count Exact number of questions to keep, or null to keep all valid ones.
*
* @return array
*/
private function sanitize_quiz_questions( array $questions, ?int $question_count = null ): array {
- if ( empty( $questions ) || ! is_array( $questions ) ) {
+ if ( empty( $questions ) ) {
return array();
}
- // If no explicit count, trust OpenAI's output (typically 3-5 questions).
- if ( $question_count === null ) {
- return array_filter(
- array_map( static fn( $q ) => is_array( $q ) ? $q : null, $questions ),
- static fn( $q ) => null !== $q
- );
- }
-
$sanitized = array();
foreach ( $questions as $question ) {
if ( ! is_array( $question ) ) {
@@ -392,7 +390,7 @@
'explanation' => sanitize_textarea_field( (string) ( $question['explanation'] ?? '' ) ),
);
- if ( count( $sanitized ) >= max( 1, $question_count ) ) {
+ if ( null !== $question_count && count( $sanitized ) >= max( 1, $question_count ) ) {
break;
}
}
--- a/learnpress/inc/AI/Assistant/TokenQuotaGuard.php
+++ b/learnpress/inc/AI/Assistant/TokenQuotaGuard.php
@@ -19,6 +19,19 @@
private const USER_META_DAILY_TOKEN_USAGE = '_lp_ai_assistant_daily_token_usage';
/**
+ * Prefix of the per-user/per-day advisory lock option.
+ */
+ private const LOCK_OPTION_PREFIX = '_lp_ai_assistant_quota_lock_';
+
+ /**
+ * Seconds after which a held lock is considered abandoned.
+ *
+ * Must exceed the slowest realistic OpenAI round trip, otherwise a second request
+ * could steal the lock while the first is still awaiting a response.
+ */
+ private const LOCK_TTL = 120;
+
+ /**
* Human-readable block message set after the first exhausted-quota call.
*/
private string $block_message = '';
@@ -26,8 +39,12 @@
/**
* Send an OpenAI chat request guarded by the daily token quota.
*
- * On quota exhaustion the guard sets the block message and returns an
- * empty array instead of calling the API.
+ * Check-call-record runs inside a per-user/per-day lock. Without it, N concurrent
+ * requests all read the same pre-call usage figure, all pass the check, and all
+ * spend tokens — the quota is bypassed by exactly the amount of concurrency.
+ *
+ * On quota exhaustion, or when the lock cannot be taken, the guard sets a block
+ * message and returns an empty array instead of calling the API (fail closed).
*
* @param OpenAiService $service OpenAI service instance.
* @param array $messages Chat messages payload.
@@ -38,19 +55,42 @@
*/
public function send_chat_with_guard( OpenAiService $service, array $messages, int $user_id ): array {
- if ( $this->has_reached_daily_token_limit( $user_id ) ) {
- $this->block_message = $this->build_block_message();
- return array();
+ // 0 means unlimited: nothing to serialize, so do not pay for the lock.
+ if ( $this->get_daily_token_limit() <= 0 ) {
+ $response = $service->send_chat_request( array( 'messages' => $messages ) );
+ $this->track_token_usage_from_response( $user_id, $response );
+
+ return $response;
}
- $response = $service->send_chat_request( array( 'messages' => $messages ) );
- $this->track_token_usage_from_response( $user_id, $response );
+ if ( ! $this->acquire_quota_lock( $user_id ) ) {
+ $this->block_message = $this->build_busy_message();
- if ( $this->has_reached_daily_token_limit( $user_id ) ) {
- $this->block_message = $this->build_block_message();
+ return array();
}
- return $response;
+ try {
+ // Re-read under the lock: another request may have consumed the remaining
+ // budget between our first look and acquiring it.
+ if ( $this->has_reached_daily_token_limit( $user_id, true ) ) {
+ $this->block_message = $this->build_block_message();
+
+ return array();
+ }
+
+ $response = $service->send_chat_request( array( 'messages' => $messages ) );
+
+ // Record actual usage before releasing, so the next waiter reads a current total.
+ $this->track_token_usage_from_response( $user_id, $response );
+
+ if ( $this->has_reached_daily_token_limit( $user_id ) ) {
+ $this->block_message = $this->build_block_message();
+ }
+
+ return $response;
+ } finally {
+ $this->release_quota_lock( $user_id );
+ }
}
/**
@@ -81,6 +121,106 @@
}
// ----------------------------------------------------------------
+ // Quota lock
+ // ----------------------------------------------------------------
+
+ /**
+ * Option name of the per-user/per-day lock.
+ *
+ * Scoped by date so a lock abandoned on a previous day can never block today.
+ *
+ * @param int $user_id Current user ID.
+ *
+ * @return string
+ */
+ private function get_lock_option_name( int $user_id ): string {
+ return self::LOCK_OPTION_PREFIX . $user_id . '_' . $this->get_local_current_date();
+ }
+
+ /**
+ * Acquire the quota lock for a user.
+ *
+ * Uses INSERT IGNORE against the unique option_name index, which is atomic across
+ * concurrent PHP workers. add_option()/get_option() cannot be used here: they
+ * read-then-write, leaving exactly the race this lock exists to close. Mirrors the
+ * approach in WP core's WP_Upgrader::create_lock().
+ *
+ * @param int $user_id Current user ID.
+ *
+ * @return bool True when the lock is held by this request.
+ */
+ private function acquire_quota_lock( int $user_id ): bool {
+ global $wpdb;
+
+ if ( $user_id <= 0 ) {
+ return false;
+ }
+
+ $lock_option = $this->get_lock_option_name( $user_id );
+ $now = time();
+
+ $acquired = $wpdb->query(
+ $wpdb->prepare(
+ "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES ( %s, %s, 'no' )",
+ $lock_option,
+ (string) $now
+ )
+ );
+
+ if ( 1 === (int) $acquired ) {
+ return true;
+ }
+
+ // Someone holds it. Recover only if it is older than the TTL, i.e. the holder
+ // died before releasing (fatal error, timeout, killed worker).
+ $held_since = (int) $wpdb->get_var(
+ $wpdb->prepare( "SELECT `option_value` FROM `$wpdb->options` WHERE `option_name` = %s LIMIT 1", $lock_option )
+ );
+
+ if ( $held_since > 0 && ( $now - $held_since ) < self::LOCK_TTL ) {
+ return false;
+ }
+
+ /**
+ * Stale (or unreadable) lock: delete and re-attempt exactly once. The retry is
+ * still an atomic INSERT IGNORE, so if several requests detect the same stale
+ * lock simultaneously only one of them wins.
+ */
+ $wpdb->delete( $wpdb->options, array( 'option_name' => $lock_option ) );
+ wp_cache_delete( $lock_option, 'options' );
+
+ $acquired = $wpdb->query(
+ $wpdb->prepare(
+ "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES ( %s, %s, 'no' )",
+ $lock_option,
+ (string) $now
+ )
+ );
+
+ return 1 === (int) $acquired;
+ }
+
+ /**
+ * Release the quota lock for a user.
+ *
+ * @param int $user_id Current user ID.
+ *
+ * @return void
+ */
+ private function release_quota_lock( int $user_id ): void {
+ global $wpdb;
+
+ if ( $user_id <= 0 ) {
+ return;
+ }
+
+ $lock_option = $this->get_lock_option_name( $user_id );
+
+ $wpdb->delete( $wpdb->options, array( 'option_name' => $lock_option ) );
+ wp_cache_delete( $lock_option, 'options' );
+ }
+
+ // ----------------------------------------------------------------
// Private helpers
// ----------------------------------------------------------------
@@ -107,17 +247,24 @@
/**
* Check whether the learner has reached their daily token limit.
*
- * @param int $user_id Current user ID.
+ * @param int $user_id Current user ID.
+ * @param bool $bypass_cache Re-read usage from the database instead of the object
+ * cache. Required for the recheck under the lock, where a
+ * value cached earlier in this request would be stale.
*
* @return bool
*/
- private function has_reached_daily_token_limit( int $user_id ): bool {
+ private function has_reached_daily_token_limit( int $user_id, bool $bypass_cache = false ): bool {
$limit = $this->get_daily_token_limit();
if ( $limit <= 0 ) {
return false;
}
+ if ( $bypass_cache && $user_id > 0 ) {
+ wp_cache_delete( $user_id, 'user_meta' );
+ }
+
return $this->get_daily_token_usage( $user_id ) >= $limit;
}
@@ -171,6 +318,10 @@
return;
}
+ // Read through to the database: the caller holds the quota lock, and a value
+ // cached earlier in this request would undercount the running total.
+ wp_cache_delete( $user_id, 'user_meta' );
+
$current_total = $this->get_daily_token_usage( $user_id );
$next_total = $current_total + $tokens;
@@ -194,6 +345,18 @@
}
/**
+ * Build user-facing message for a request that could not take the quota lock.
+ *
+ * Reached when another request from the same learner is mid-flight, or a stale lock
+ * has not yet aged past its TTL. Failing closed keeps the quota authoritative.
+ *
+ * @return string
+ */
+ private function build_busy_message(): string {
+ return __( 'Another AI Assistant request is still running. Please wait a moment and try again.', 'learnpress' );
+ }
+
+ /**
* Build user-facing quota exceeded message.
*
* @return string
--- a/learnpress/inc/Ajax/AI/AIAssistantAjax.php
+++ b/learnpress/inc/Ajax/AI/AIAssistantAjax.php
@@ -2,12 +2,13 @@
namespace LearnPressAjaxAI;
-use Exception;
use LearnPressAjaxAbstractAjax;
use LearnPressAIAssistantAIAssistantController;
+use LP_Debug;
use LP_Helper;
use LP_Request;
use LP_REST_Response;
+use Exception;
use Throwable;
/**
@@ -29,13 +30,20 @@
/**
* Handle assistant chat request from a logged-in learner.
*
- * Request data (JSON-encoded in 'data' param):
+ * Nonce is verified by AbstractAjax::catch_lp_ajax() and is CSRF protection only —
+ * never authorization. Login is checked here; per-item authorization is the
+ * controller's job, via AIAssistantController::resolve_item_access().
+ *
+ * Request data (JSON-encoded in 'data' param). A course item is addressed by the
+ * full tuple (course_id, item_type, item_id); all three are required.
* {
* "message": string,
- * "lesson_id": int,
* "course_id": int,
+ * "item_type": string, // lp_lesson | lp_quiz — validated against the curriculum
+ * "item_id": int,
* "history": [{role, content}, ...],
- * "active_quiz_questions": []
+ * "active_quiz_questions": {},
+ * "action_hint": string
* }
*
* Response shape:
@@ -67,7 +75,7 @@
$response->status = 'success';
$response->data = $result;
} catch ( Throwable $e ) {
- $response->message = $e->getMessage();
+ $response->message = __( 'The AI Assistant is unavailable right now. Please try again later.', 'learnpress' );
$response->data = $this->normalize_response_data( array() );
}
--- a/learnpress/inc/Ajax/AI/OpenAiAjax.php
+++ b/learnpress/inc/Ajax/AI/OpenAiAjax.php
@@ -20,6 +20,7 @@
use LP_Helper;
use LP_Request;
use LP_REST_Response;
+use LP_WP_Filesystem;
use Throwable;
/**
@@ -372,7 +373,7 @@
$args = [
'prompt' => $prompt,
'n' => intval( $params['outputs'] ?? 1 ),
- 'size' => $params['size'] ?? '',
+ 'size' => '1024x1024',
];
$result = OpenAiService::instance()->send_request_create_image( $args );
@@ -437,7 +438,7 @@
* Upload image to media and set as feature image for post
*
* @since 4.3.0
- * @version 1.0.1
+ * @version 1.0.2
*/
public function openai_apply_image_feature() {
$response = new LP_REST_Response();
@@ -493,15 +494,24 @@
require_once ABSPATH . 'wp-admin/includes/image.php';
}
- if ( ! empty( $image_url ) ) {
- $tmp = download_url( $image_url );
- $fileExt = pathinfo( parse_url( $image_url, PHP_URL_PATH ), PATHINFO_EXTENSION );
- $filename = sanitize_file_name( $post_slug . '-' . uniqid() . '.' . $fileExt );
-
- } elseif ( ! empty( $image_base64 ) ) {
+ // model gpt-image-1 only return base64, so don't need to download from url
+ if ( ! empty( $image_base64 ) ) {
$decoded_image = base64_decode( $image_base64 );
- $tmp = wp_tempnam();
- file_put_contents( $tmp, $decoded_image );
+ if ( false === $decoded_image || '' === $decoded_image ) {
+ throw new Exception( __( 'Invalid image data.', 'learnpress' ) );
+ }
+
+ $image_info = @getimagesizefromstring( $decoded_image );
+ if ( false === $image_info ) {
+ throw new Exception( __( 'Decoded data is not a valid image.', 'learnpress' ) );
+ }
+
+ $tmp = wp_tempnam();
+ $lp_wp_filesystem = LP_WP_Filesystem::instance();
+ $put_contents_result = $lp_wp_filesystem->put_contents( $tmp, $decoded_image );
+ if ( ! $put_contents_result || ! is_readable( $tmp ) ) {
+ throw new Exception( __( 'Decoded image is not readable.', 'learnpress' ) );
+ }
$filename = sanitize_file_name( $post_slug . '-' . uniqid() . '.png' );
} else {
throw new Exception( __( 'No image data provided.', 'learnpress' ) );
--- a/learnpress/inc/Databases/DataBase.php
+++ b/learnpress/inc/Databases/DataBase.php
@@ -837,14 +837,13 @@
*
* @throws Exception
* @since 4.2.9
- * @version 1.0.1
+ * @version 1.0.2
*/
public function update_data( array $args ): bool {
$data = $args['data'] ?? [];
$filter = $args['filter'] ?? null;
$table_name = $args['table_name'] ?? '';
$where_key = $args['where_key'] ?? '';
- $where_key = sanitize_key( $where_key );
/*if ( ! $filter instanceof FilterBase ) {
throw new Exception( __( 'Invalid filter!', 'learnpress' ) . ' | ' . __FUNCTION__ );
--- a/learnpress/inc/Helpers/Config.php
+++ b/learnpress/inc/Helpers/Config.php
@@ -8,7 +8,7 @@
*
* @package LPHelpers
* @since 4.1.6.4
- * @version 1.0.0
+ * @version 1.0.1
*/
class Config {
/*
@@ -17,8 +17,6 @@
* @var array
*/
protected static $instance;
- protected $file_name = '';
- protected $items = array();
/**
* @var array Array name files config.
*/
@@ -35,8 +33,6 @@
*/
protected function __construct( array $items = array() ) {
$this->dir = LP_PLUGIN_PATH . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR;
-
- $this->items = $items;
}
/**
@@ -90,7 +86,7 @@
}
}
- public static function instance(): self {
+ public static function instance() {
if ( is_null( self::$instance ) ) {
self::$instance = new self();
}
--- a/learnpress/inc/Helpers/Singleton.php
+++ b/learnpress/inc/Helpers/Singleton.php
@@ -20,5 +20,5 @@
$this->init();
}
- abstract function init();
+ abstract public function init();
}
--- a/learnpress/inc/Models/CourseModel.php
+++ b/learnpress/inc/Models/CourseModel.php
@@ -508,27 +508,44 @@
/**
* Get section id of item
*
- * @param int $item_id
+ * A curriculum item's identity is the (item_id, item_type) pair, not the numeric
+ * ID alone: once items live in per-type tables a lesson and a quiz can share the
+ * same ID. Pass $item_type to match the composite identity.
+ *
+ * @param int $item_id
+ * @param string $item_type Optional. Curriculum item type to match as well. Empty
+ * keeps the legacy ID-only match for callers not yet migrated.
*
* @return int
* @since 4.2.8
- * @version 1.0.0
+ * @version 1.1.0
*/
- public function get_section_of_item( int $item_id ): int {
- $section_id = 0;
-
+ public function get_section_of_item( int $item_id, string $item_type = '' ): int {
$section_items = $this->get_section_items();
+
foreach ( $section_items as $section ) {
+ if ( empty( $section->items ) ) {
+ continue;
+ }
+
foreach ( $section->items as $item ) {
$item_id_check = (int) ( $item->item_id ?? $item->id ?? 0 );
- if ( $item_id_check === $item_id ) {
- $section_id = $section->section_id ?? $section->id ?? 0;
- break;
+ if ( $item_id_check !== $item_id ) {
+ continue;
+ }
+
+ if ( '' !== $item_type ) {
+ $item_type_check = (string) ( $item->item_type ?? $item->type ?? '' );
+ if ( $item_type_check !== $item_type ) {
+ continue;
+ }
}
+
+ return (int) ( $section->section_id ?? $section->id ?? 0 );
}
}
- return (int) $section_id;
+ return 0;
}
/**
@@ -1266,14 +1283,15 @@
*
* @return mixed|false|null|WP_Post|PostModel
* @since v4.2.7.6
- * @version 1.0.2
+ * @version 1.0.3
*/
public function get_item_model( int $item_id, string $item_type, bool $check_assign = true ) {
try {
$item = false;
- // Find item has in section
- $section_id = $this->get_section_of_item( $item_id );
+ // Find item has in section. Match the composite (item_id, item_type) identity,
+ // so a quiz ID can never resolve through a lesson request and vice versa.
+ $section_id = $this->get_section_of_item( $item_id, $item_type );
if ( $check_assign && ! $section_id ) {
return $item;
}
--- a/learnpress/inc/Services/OpenAiService.php
+++ b/learnpress/inc/Services/OpenAiService.php
@@ -14,7 +14,7 @@
*
* @package LearnPressServices
* @since 4.3.0
- * @version 1.0.0
+ * @version 1.0.1
*/
class OpenAiService {
use Singleton;
@@ -22,7 +22,6 @@
public string $baseUrl = 'https://api.openai.com/v1/';
public string $urlChartCompletion;
public string $urlResponses;
- public string $urlCompletionLegacy;
public string $urlImage;
public string $secret_key;
@@ -34,10 +33,9 @@
public int $max_token;
public function init() {
- $this->urlChartCompletion = $this->baseUrl . 'chat/completions';
- $this->urlCompletionLegacy = $this->baseUrl . 'completions';
- $this->urlResponses = $this->baseUrl . 'responses';
- $this->urlImage = $this->baseUrl . 'images/generations';
+ $this->urlChartCompletion = $this->baseUrl . 'chat/completions';
+ $this->urlResponses = $this->baseUrl . 'responses';
+ $this->urlImage = $this->baseUrl . 'images/generations';
$this->get_settings();
}
@@ -62,7 +60,7 @@
public function get_settings() {
$this->secret_key = LP_Settings::get_option( 'open_ai_secret_key', '' );
$this->text_model_type = LP_Settings::get_option( 'open_ai_text_model_type', 'gpt-4.1' );
- $this->image_model_type = LP_Settings::get_option( 'open_ai_image_model_type', 'dall-e-3' );
+ $this->image_model_type = LP_Settings::get_option( 'open_ai_image_model_type', 'gpt-image-1' );
$this->frequency_penalty = LP_Settings::get_option( 'open_ai_frequency_penalty_level', 0.0 );
$this->presence_penalty = LP_Settings::get_option( 'open_ai_presence_penalty_level', 0.0 );
$this->creativity_level = LP_Settings::get_option( 'open_ai_creativity_level', 1.0 );
@@ -75,22 +73,8 @@
* @throws Exception
*/
public function send_request( array $args ): array {
- // Handle args before send request
- $url = '';
-
- if ( in_array(
- $this->text_model_type,
- [ 'chatgpt-4o-latest', 'gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo' ]
- ) ) {
- $url = $this->urlChartCompletion;
- $args = $this->handle_params_for_send_chat_completion( $args );
- } elseif ( $this->text_model_type == 'gpt-3.5-turbo-instruct' ) {
- $url = $this->urlCompletionLegacy;
- $args = $this->handle_params_for_send_completion_legacy( $args );
- } else {
- $url = $this->urlResponses;
- $args = $this->handle_params_for_send_responses( $args );
- }
+ $url = $this->urlResponses;
+ $args = $this->handle_params_for_send_responses( $args );
$response = wp_remote_post(
$url,
@@ -185,10 +169,10 @@
unset( $args['n'] );
}
- if ( $model_type == 'dall-e-3' ) {
+ /*if ( $model_type == 'dall-e-3' ) {
// only n=1 is supported.
$args['n'] = 1;
- }
+ }*/
$response = wp_remote_post(
$this->urlImage,
@@ -274,29 +258,6 @@
}
/**
- * Handle params for send chat completion
- *
- * @docs https://platform.openai.com/docs/api-reference/completions/create
- *
- * @throws Exception
- */
- public function handle_params_for_send_completion_legacy( $params ): array {
- $params = [
- 'model' => $this->text_model_type,
- 'temperature' => $this->creativity_level,
- 'max_tokens' => $this->max_token,
- 'n' => $params['outputs'] ?? 1,
- 'prompt' => $params['prompt'] ?? '',
- ];
-
- if ( $this->max_token === 0 ) {
- unset( $params['max_tokens'] );
- }
-
- return $params;
- }
-
- /**
* Handle params for send chat completion
*
* @docs https://platform.openai.com/docs/api-reference/responses/create
--- a/learnpress/inc/TemplateHooks/Admin/AI/AdminEditWithAITemplate.php
+++ b/learnpress/inc/TemplateHooks/Admin/AI/AdminEditWithAITemplate.php
@@ -747,8 +747,8 @@
public function html_image_step_1(): string {
$model_type = LP_Settings::instance()->get( 'open_ai_image_model_type' );
$options = $this->config;
- $size_opts = $options[ "image-size-$model_type" ] ?? [];
- $quality_opts = $options[ "image-quality-$model_type" ] ?? $options['image-quality'] ?? [];
+ $size_opts = $options[ "image-size-$model_type" ] ?? [ 'auto' ];
+ $quality_opts = $options[ "image-quality-$model_type" ] ?? $options['image-quality'] ?? [ 'auto' ];
$components = [
'step' => '<div class="step-content active" data-step="1">',
@@ -843,7 +843,7 @@
<p class="field-description">%s</p>
</div>',
esc_html__( 'Outputs', 'learnpress' ),
- esc_html__( 'Number of images you want the system to generate (model dall-e-3 only 1 supported).', 'learnpress' )
+ esc_html__( 'Number of images you want the system to generate.', 'learnpress' )
),
'form-grid-end' => '</div>',
'step_close' => '</div>',
--- a/learnpress/inc/TemplateHooks/Course/CourseAIAssistantTemplate.php
+++ b/learnpress/inc/TemplateHooks/Course/CourseAIAssistantTemplate.php
@@ -127,9 +127,25 @@
$context = $this->detect_context();
$item = LP_Global::course_item();
$item_id = $item ? absint( $item->get_id() ) : 0;
+ $item_type = $item ? (string) $item->get_item_type() : '';
$course_id = $item ? absint( $item->get_course_id() ) : 0;
$user_id = get_current_user_id();
+ /**
+ * Defense in depth: run the same resolver the AJAX controller uses, so the widget
+ * is never offered for an item the user cannot view. This is not the security
+ * boundary — AIAssistantController::handle_chat() is, because the AJAX action is
+ * reachable without this markup ever rendering.
+ *
+ * Catches Throwable because this runs on wp_enqueue_scripts, outside the
+ * render_panel() try/catch. Any failure denies rather than fatals the page.
+ */
+ try {
+ AIAssistantController::resolve_item_access( $user_id, $course_id, $item_type, $item_id );
+ } catch ( Throwable $e ) {
+ return $this->render_state = false;
+ }
+
$enabled_actions = AIAssistantController::get_enabled_actions();
$free_chat_enabled = LP_Settings::get_option( 'ai_assistant_free_chat', 'no' ) === 'yes';
@@ -163,6 +179,7 @@
return $this->render_state = array(
'context' => $context,
'item_id' => $item_id,
+ 'item_type' => $item_type,
'course_id' => $course_id,
'enabled_actions' => $enabled_actions,
'free_chat_enabled' => $free_chat_enabled,
@@ -182,6 +199,9 @@
'nonce' => wp_create_nonce( 'wp_rest' ),
'lessonId' => $render_state['item_id'],
'itemId' => $render_state['item_id'],
+ // Server-resolved curriculum type. The client echoes it back as item_type
+ // and the server re-validates it; it is transport, not proof.
+ 'itemType' => $render_state['item_type'],
'courseId' => $render_state['course_id'],
'context' => $render_state['context'],
'quizCompleted' => $render_state['context'] === 'quiz',
--- a/learnpress/inc/admin/class-lp-admin-assets.php
+++ b/learnpress/inc/admin/class-lp-admin-assets.php
@@ -83,7 +83,7 @@
'single_instructor_id' => learn_press_get_page_id( 'single_instructor' ),
'lpAi' => array(
'config' => Config::instance()->get( 'open-ai-modal', 'settings' ),
- 'modelImage' => LP_Settings::get_option( 'open_ai_image_model_type', 'dall-e-3' ),
+ 'modelImage' => LP_Settings::get_option( 'open_ai_image_model_type', 'gpt-image-1' ),
),
'enable_open_ai' => LP_Settings::get_option( 'enable_open_ai', 'no' ) === 'yes'
&& ! empty( LP_Settings::get_option( 'open_ai_secret_key', '' ) ),
--- a/learnpress/inc/admin/sub-menus/abstract-submenu.php
+++ b/learnpress/inc/admin/sub-menus/abstract-submenu.php
@@ -96,7 +96,7 @@
}
public function is_displaying() {
- return $this->get_id() === LP_Request::get_string( 'page' );
+ return $this->get_id() === LP_Request::get_param( 'page' );
}
/**
--- a/learnpress/inc/user/class-lp-profile.php
+++ b/learnpress/inc/user/class-lp-profile.php
@@ -263,15 +263,17 @@
$user_of_profile = $this->get_user();
$tabs = self::get_tabs_arr();
- $userModelProfile = UserModel::find( $user_of_profile->get_id(), true );
- if ( $userModelProfile ) {
- $userModelProfileRoles = $userModelProfile->get_roles();
+ if ( $user_of_profile ) {
+ $userModelProfile = UserModel::find( $user_of_profile->get_id(), true );
+ if ( $userModelProfile ) {
+ $userModelProfileRoles = $userModelProfile->get_roles();
- /*
- * Check if user not Admin/Instructor, will be hide tab Courses.
- */
- if ( ! array_intersect( $userModelProfileRoles, [ UserModel::ROLE_ADMINISTRATOR, UserModel::ROLE_INSTRUCTOR ] ) ) {
- unset( $tabs['courses'] );
+ /*
+ * Check if user not Admin/Instructor, will be hide tab Courses.
+ */
+ if ( ! array_intersect( $userModelProfileRoles, [ UserModel::ROLE_ADMINISTRATOR, UserModel::ROLE_INSTRUCTOR ] ) ) {
+ unset( $tabs['courses'] );
+ }
}
}
--- a/learnpress/learnpress.php
+++ b/learnpress/learnpress.php
@@ -4,7 +4,7 @@
* Plugin URI: https://thimpress.com/learnpress
* Description: LearnPress is a WordPress complete solution for creating a Learning Management System (LMS). It can help you to create courses, lessons and quizzes.
* Author: ThimPress
- * Version: 4.4.3
+ * Version: 4.4.4
* Author URI: http://thimpress.com
* Requires at least: 6.0
* Requires PHP: 7.4
--- a/learnpress/vendor/composer/installed.php
+++ b/learnpress/vendor/composer/installed.php
@@ -3,7 +3,7 @@
'name' => 'learnpress/learnpress',
'pretty_version' => 'dev-develop',
'version' => 'dev-develop',
- 'reference' => '2376fb803c4bf4e049479db45fc0fdb584b31668',
+ 'reference' => '6afaa9b574a62307e227053793565c2c439aa63b',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -13,7 +13,7 @@
'learnpress/learnpress' => array(
'pretty_version' => 'dev-develop',
'version' => 'dev-develop',
- 'reference' => '2376fb803c4bf4e049479db45fc0fdb584b31668',
+ 'reference' => '6afaa9b574a62307e227053793565c2c439aa63b',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),