PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.8
4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 All 139 releases
← All changes | inc/AI/Assistant/AIAssistantController.php +113 -2 4.4.14.4.8 View file →
@@ -1,11 +1,14 @@
1 1 <?php
2 2
3 3 namespace LearnPress\AI\Assistant;
4 4
5 -use Exception;
5 +use LearnPress\Models\CourseModel;
6 +use LearnPress\Models\PostModel;
6 7 use LP_Settings;
8 +use LP_User;
7 9 use LearnPress\Services\OpenAiService;
10 +use Exception;
8 11
9 12 /**
10 13 * AI Assistant Controller — validates requests, sanitizes input, calls Agent.
11 14 *
@@ -75,18 +78,117 @@
75 78 return $enabled_actions[ $action ] ?? true;
76 79 }
77 80
78 81 /**
82 + * Course item types the assistant can be grounded on.
83 + *
84 + * Deliberately excludes LP_QUESTION_CPT and every third-party item type: an item
85 + * type is only listed here once the assistant has a data loader and an access rule
86 + * for it. Not a filter — widening this is a code change, reviewed as such.
87 + *
88 + * Naming contract for the `LearnPress\AI\Assistant` namespace:
89 + * - `$item_id` — a course item ID whose type is not yet proven.
90 + * - `$item_type` — the resolved curriculum type: LP_LESSON_CPT or LP_QUIZ_CPT.
91 + * - `$lesson_id` — an `$item_id` already proven to be LP_LESSON_CPT.
92 + * - `$quiz_id` — an `$item_id` already proven to be LP_QUIZ_CPT.
93 + *
94 + * Only proven IDs may be passed to the DataLoaders layer.
95 + *
96 + * @return string[]
97 + */
98 + public static function get_supported_item_types(): array {
99 + return array( LP_LESSON_CPT, LP_QUIZ_CPT );
100 + }
101 +
102 + /**
103 + * Resolve and authorize the composite course-item identity for a request.
104 + *
105 + * A curriculum item is identified by the tuple (course_id, item_type, item_id).
106 + * `item_type` arrives from the client and is therefore untrusted: it selects which
107 + * typed lookup runs, and the lookup itself is what proves the tuple. It never grants
108 + * access on its own. Nothing is ever resolved from `item_id` alone.
109 + *
110 + * Used by both the AJAX controller and the template renderer so the two cannot drift.
111 + *
112 + * @param int $user_id Current user ID.
113 + * @param int $course_id Course the item is claimed to belong to.
114 + * @param string $item_type Raw item type from the request.
115 + * @param int $item_id Course item ID.
116 + *
117 + * @return array{course:CourseModel,item:PostModel,item_id:int,item_type:string} Trusted context.
118 + * @throws Exception When the tuple is invalid or the user may not view the item.
119 + */
120 + public static function resolve_item_access( int $user_id, int $course_id, string $item_type, int $item_id ): array {
121 + $denied = __( 'You do not have permission to use the AI Assistant for this course item.', 'learnpress' );
122 +
123 + // item_type is mandatory: without it there is no identity to verify.
124 + $item_type = sanitize_key( $item_type );
125 + if ( empty( $item_type ) || ! in_array( $item_type, self::get_supported_item_types(), true ) ) {
126 + throw new Exception(
127 + __( 'The AI Assistant is not available for this type of course item.', 'learnpress' )
128 + );
129 + }
130 +
131 + if ( $user_id <= 0 || $course_id <= 0 || $item_id <= 0 ) {
132 + throw new Exception( $denied );
133 + }
134 +
135 + $courseModel = CourseModel::find( $course_id, true );
136 + if ( ! $courseModel instanceof CourseModel ) {
137 + throw new Exception( $denied );
138 + }
139 +
140 + /**
141 + * Resolves through the supplied type AND asserts curriculum membership, so a
142 + * course_id/item_id pair from different courses cannot be combined, and a quiz
143 + * ID cannot be resolved as a lesson.
144 + */
145 + $itemModel = $courseModel->get_item_model( $item_id, $item_type, true );
146 + if ( ! $itemModel instanceof PostModel ) {
147 + throw new Exception( $denied );
148 + }
149 +
150 + // Reject drafts, pending, private and trashed items — get_item_model() does not filter status.
151 + if ( PostModel::STATUS_PUBLISH !== $itemModel->post_status ) {
152 + throw new Exception( $denied );
153 + }
154 +
155 + // Canonical LearnPress access policy: course-level gate, then the item-level rule
156 + // that lets preview items through. Both must pass.
157 + $user = learn_press_get_user( $user_id );
158 + if ( ! $user instanceof LP_User ) {
159 + throw new Exception( $denied );
160 + }
161 +
162 + $can_view_course = $user->can_view_content_course( $course_id );
163 + $can_view_item = $user->can_view_item( $item_id, $can_view_course );
164 + if ( empty( $can_view_item->flag ) ) {
165 + $message = (string) ( $can_view_item->message ?? '' );
166 +
167 + throw new Exception( '' !== $message ? $message : $denied );
168 + }
169 +
170 + return array(
171 + 'course' => $courseModel,
172 + 'item' => $itemModel,
173 + 'item_id' => $item_id,
174 + 'item_type' => $item_type,
175 + );
176 + }
177 +
178 + /**
79 179 * Handle an assistant chat request.
80 180 *
81 181 * @param array $data Raw decoded data from the AJAX request.
82 182 *
83 183 * @return array{type: string, message: string, quiz: array|null}
84 - * @throws Exception On validation failure or API error.
184 + * @throws Exception On validation failure or denied access.
185 + * @throws Throwable On provider/transport failure — logged and masked by the AJAX layer.
85 186 */
86 187 public function handle_chat( array $data ): array {
87 188 $message = trim( $data['message'] ?? '' );
88 189 $item_id = absint( $data['item_id'] ?? 0 );
190 + $item_type = is_scalar( $data['item_type'] ?? null ) ? (string) $data['item_type'] : '';
89 191 $course_id = absint( $data['course_id'] ?? 0 );
90 192 $history = $data['history'] ?? array();
91 193 $quiz_data = $data['active_quiz_questions'] ?? array();
92 194 $action_hint = $this->sanitize_action_hint( $data['action_hint'] ?? '' );
@@ -108,8 +210,16 @@
108 210 if ( $user_id === 0 ) {
109 211 throw new Exception( __( 'User must be logged in.', 'learnpress' ) );
110 212 }
111 213
214 + /**
215 + * Authoritative gate. Runs before the Agent is constructed, so a denied request
216 + * costs no prompt construction, no quota accounting and no OpenAI call. The
217 + * widget markup check is advisory only — this endpoint is reachable directly.
218 + */
219 + $access = self::resolve_item_access( $user_id, $course_id, $item_type, $item_id );
220 + $item_type = $access['item_type'];
221 +
112 222 // Sanitize history — only allow safe role/content pairs.
113 223 $sanitized_history = array();
114 224 if ( is_array( $history ) ) {
115 225 foreach ( $history as $msg ) {
@@ -130,8 +240,9 @@
130 240
131 241 return $agent->run(
132 242 sanitize_textarea_field( $message ),
133 243 $item_id,
244 + $item_type,
134 245 $course_id,
135 246 $user_id,
136 247 $sanitized_history,
137 248 $sanitized_quiz_state,