| @@ -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, |