get_error_message() ); } // Emails go out from Notifications, on accessnow_slms_enrolled. $result = Enrollment::enroll( $user_id, $course_id ); if ( $result ) { wp_send_json_success( array( 'message' => __( 'Successfully enrolled!', 'accessnow-lms' ) ) ); } else { wp_send_json_error( __( 'Enrollment failed.', 'accessnow-lms' ) ); } } public static function handle_mark_complete() { check_ajax_referer( 'accessnow_slms_progress', 'nonce' ); // Students are logged in (these handlers have no nopriv hook); enrolment is checked below. if ( ! current_user_can( 'read' ) ) { wp_send_json_error( __( 'Invalid request.', 'accessnow-lms' ), 403 ); } $lesson_id = absint( $_POST['lesson_id'] ?? 0 ); $user_id = get_current_user_id(); if ( ! $lesson_id || ! $user_id ) { wp_send_json_error( __( 'Invalid request.', 'accessnow-lms' ) ); } if ( 'slms_lesson' !== get_post_type( $lesson_id ) || 'publish' !== get_post_status( $lesson_id ) ) { wp_send_json_error( __( 'Invalid request.', 'accessnow-lms' ) ); } // Active or completed: a student who finished the course may still redo changed or new lessons. $course_id = (int) Access_Control::get_course_id_from_content( $lesson_id, 'slms_lesson' ); $enrollment = $course_id ? Enrollment::get_accessible_enrollment( $user_id, $course_id ) : null; if ( ! $enrollment ) { wp_send_json_error( self::no_access_message( $user_id, $course_id ) ); } $enrollment_id = $enrollment->id; // Respect sequential progression: earlier lessons must be complete first. if ( ! Progress::can_access_lesson( $user_id, $lesson_id, $course_id, $enrollment_id ) ) { wp_send_json_error( __( 'Invalid request.', 'accessnow-lms' ) ); } // Check if lesson has a quiz that requires a passing grade $lesson_quiz = get_posts( array( 'post_type' => 'slms_quiz', 'posts_per_page' => 1, 'meta_key' => '_slms_parent_lesson', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- course structure is stored as post meta. 'meta_value' => $lesson_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- course structure is stored as post meta. 'fields' => 'ids', ) ); if ( ! empty( $lesson_quiz ) ) { $quiz_id = $lesson_quiz[0]; $pass_threshold = (int) get_post_meta( $quiz_id, '_slms_pass_threshold', true ); if ( $pass_threshold > 0 ) { $lesson_status = Progress::get_lesson_status( $user_id, $lesson_id, $enrollment_id ); if ( Quiz_Engine::has_pending_attempt( $user_id, $quiz_id, $enrollment_id ) ) { wp_send_json_error( __( 'Your quiz is awaiting review. You can complete this lesson once it has been marked as a pass.', 'accessnow-lms' ) ); } if ( 'invalidated' === $lesson_status ) { // When invalidated, require a passing attempt made after the quiz was last modified $quiz_modified = get_post_field( 'post_modified_gmt', $quiz_id ); $best_recent = Quiz_Engine::get_best_attempt_after( $user_id, $quiz_id, $enrollment_id, $quiz_modified ); if ( ! $best_recent || ! $best_recent->passed ) { wp_send_json_error( sprintf( /* translators: 1: quiz title, 2: pass mark percentage. */ __( 'This quiz has been updated. You must re-pass "%1$s" (minimum %2$d%%) before completing this lesson.', 'accessnow-lms' ), get_the_title( $quiz_id ), $pass_threshold ) ); } } else { $best = Quiz_Engine::get_best_attempt( $user_id, $quiz_id, $enrollment_id ); if ( ! $best || ! $best->passed ) { wp_send_json_error( sprintf( /* translators: 1: quiz title, 2: pass mark percentage. */ __( 'You must pass the quiz "%1$s" (minimum %2$d%%) before completing this lesson.', 'accessnow-lms' ), get_the_title( $quiz_id ), $pass_threshold ) ); } } } } // Anything else that must happen first (accessnow_slms_can_mark_complete), e.g. watching the video. $can_complete = Progress::can_mark_complete( $user_id, $lesson_id, $course_id, $enrollment ); if ( is_wp_error( $can_complete ) ) { wp_send_json_error( $can_complete->get_error_message() ); } // Points and emails are listeners on accessnow_slms_lesson_completed / _course_completed. Progress::mark_complete( $user_id, $lesson_id, $enrollment_id ); // Runs once per completion, including after a reopened enrolment is finished again. $course_completed = Enrollment::maybe_complete( $user_id, $course_id, $enrollment_id ); $progress = Progress::get_course_progress( $user_id, $course_id, $enrollment_id ); $next_lesson = Progress::get_next_lesson( $lesson_id, $course_id ); wp_send_json_success( array( 'message' => __( 'Lesson complete!', 'accessnow-lms' ), 'progress' => $progress, 'course_completed' => $course_completed, 'course_url' => get_permalink( $course_id ), 'next_lesson_url' => $next_lesson ? get_permalink( $next_lesson ) : '', ) ); } public static function handle_submit_quiz() { check_ajax_referer( 'accessnow_slms_quiz', 'nonce' ); // Students are logged in (these handlers have no nopriv hook); enrolment is checked below. if ( ! current_user_can( 'read' ) ) { wp_send_json_error( __( 'Invalid request.', 'accessnow-lms' ), 403 ); } $quiz_id = absint( $_POST['quiz_id'] ?? 0 ); $user_id = get_current_user_id(); $answers = isset( $_POST['answers'] ) && is_array( $_POST['answers'] ) ? map_deep( wp_unslash( $_POST['answers'] ), 'sanitize_text_field' ) : array(); if ( ! $quiz_id || ! $user_id ) { wp_send_json_error( __( 'Invalid request.', 'accessnow-lms' ) ); } if ( 'slms_quiz' !== get_post_type( $quiz_id ) || 'publish' !== get_post_status( $quiz_id ) ) { wp_send_json_error( __( 'Invalid request.', 'accessnow-lms' ) ); } $course_id = (int) Access_Control::get_course_id_from_content( $quiz_id, 'slms_quiz' ); $enrollment = $course_id ? Enrollment::get_accessible_enrollment( $user_id, $course_id ) : null; $enrollment_id = $enrollment ? $enrollment->id : 0; // Only students with access (or LMS managers and the quiz's editors, previewing) may submit a quiz. if ( ! $enrollment && ! Capabilities::can_preview( $quiz_id ) ) { wp_send_json_error( self::no_access_message( $user_id, $course_id ) ); } if ( ! Quiz_Engine::can_retry( $user_id, $quiz_id, $enrollment_id ) ) { wp_send_json_error( __( 'Maximum attempts reached.', 'accessnow-lms' ) ); } // Answers to this attempt's questions only, each in its type's shape: one value, or (for an // added type answered as a list or a map) up to Quiz_Engine::MAX_ANSWER_ITEMS plain values. $context = array( 'user_id' => $user_id, 'enrollment_id' => (int) $enrollment_id, ); $clean_answers = array(); foreach ( Quiz_Engine::get_questions( $quiz_id, array_merge( $context, array( 'purpose' => 'grade' ) ) ) as $q ) { if ( ! isset( $answers[ $q['id'] ] ) || Quiz_Engine::TIMED_OUT_KEY === $q['id'] ) { continue; } $answer = $answers[ $q['id'] ]; $shape = Quiz_Engine::answer_shape( $q['type'] ); if ( 'scalar' === $shape && is_scalar( $answer ) ) { $clean_answers[ $q['id'] ] = (string) $answer; } elseif ( ( 'list' === $shape || 'map' === $shape ) && is_array( $answer ) ) { $items = array_slice( array_filter( $answer, 'is_scalar' ), 0, Quiz_Engine::MAX_ANSWER_ITEMS, 'map' === $shape ); $clean_answers[ $q['id'] ] = 'list' === $shape ? array_values( array_map( 'strval', $items ) ) : array_map( 'strval', $items ); } } // The time limit is enforced here; the page's timer only displays it. $started_at = Quiz_Engine::get_attempt_start( $user_id, $quiz_id, $enrollment_id ); $time_limit = Quiz_Engine::time_limit_seconds( $quiz_id ); if ( $time_limit && ! $started_at ) { // Timed attempts start when the quiz page is shown (Quiz_Engine::start_on_view()). wp_send_json_error( __( 'Please open the quiz page to start this quiz.', 'accessnow-lms' ) ); } // The form says which start it belongs to. A tab left open from an attempt that has since // ended (and been recorded) must not submit into the next attempt's clock. $form_started = absint( $_POST['started_at'] ?? 0 ); if ( $time_limit && $form_started && $form_started !== $started_at ) { wp_send_json_error( __( 'This attempt has already ended. Reload the page to see your results.', 'accessnow-lms' ) ); } $late = Quiz_Engine::is_expired( $quiz_id, $started_at ); $result = Quiz_Engine::grade( $quiz_id, $clean_answers, $context ); if ( $late ) { // Submitted after the time limit: the attempt counts, but scores nothing. $result = Quiz_Engine::zero_result( $result ); $clean_answers[ Quiz_Engine::TIMED_OUT_KEY ] = 1; } $attempt = Quiz_Engine::record_attempt( $user_id, $quiz_id, $course_id, $clean_answers, $result, $enrollment_id, $started_at ); Quiz_Engine::clear_attempt_start( $user_id, $quiz_id, $enrollment_id ); $result['timed_out'] = $late; /** * A student submitted a quiz and the attempt was stored. Not fired for a timed attempt that * was abandoned (recorded as "Timed out" when the quiz is next opened). * * The plugin's quiz points and result email are listeners on this action. * * @since 1.0.0 * * @param int $user_id User ID. * @param int $quiz_id Quiz ID. * @param array $result Graded result (see accessnow_slms_grade_result), plus timed_out. * @param int $attempt_number Attempt number within the enrolment. * @param int $enrollment_id Enrolment ID (0 for an LMS manager previewing). * @param int $course_id Course ID. * @param int $attempt_id The stored attempt's ID (since 1.1.0). */ do_action( 'accessnow_slms_quiz_submitted', (int) $user_id, (int) $quiz_id, $result, (int) $attempt, (int) $enrollment_id, (int) $course_id, Quiz_Engine::last_attempt_id() ); $can_retry = Quiz_Engine::can_retry( $user_id, $quiz_id, $enrollment_id ); // Correct answers and explanations leave the server only when the quiz's setting allows it. $show_answers = Quiz_Engine::may_show_answers( $quiz_id, $result['passed'], $can_retry ); $results = $show_answers ? Quiz_Engine::add_answers( $quiz_id, $result['results'], $context ) : $result['results']; wp_send_json_success( array( 'score' => $result['percentage'], 'passed' => $result['passed'], 'pending' => ! empty( $result['pending'] ), 'results' => $results, 'attempt' => $attempt, 'can_retry' => $can_retry, 'late' => $late, 'answers_shown' => $show_answers, ) ); } public static function handle_save_note() { check_ajax_referer( 'accessnow_slms_notes', 'nonce' ); // Students are logged in (these handlers have no nopriv hook); enrolment is checked below. if ( ! current_user_can( 'read' ) ) { wp_send_json_error( __( 'Invalid request.', 'accessnow-lms' ), 403 ); } $lesson_id = absint( $_POST['lesson_id'] ?? 0 ); $content = isset( $_POST['content'] ) ? wp_kses_post( wp_unslash( $_POST['content'] ) ) : ''; $user_id = get_current_user_id(); if ( ! $lesson_id || ! $user_id || 'slms_lesson' !== get_post_type( $lesson_id ) ) { wp_send_json_error( __( 'Invalid request.', 'accessnow-lms' ) ); } // Notes belong to the student: only for lessons in a course they are enrolled in. $course_id = (int) Access_Control::get_course_id_from_content( $lesson_id, 'slms_lesson' ); if ( ! current_user_can( 'manage_slms' ) && ( ! $course_id || ! Enrollment::has_access( $user_id, $course_id ) ) ) { wp_send_json_error( self::no_access_message( $user_id, $course_id ) ); } Notes::save( $user_id, $lesson_id, $content ); wp_send_json_success( array( 'message' => __( 'Note saved.', 'accessnow-lms' ) ) ); } public static function handle_login() { check_ajax_referer( 'accessnow_slms_auth', 'nonce' ); $username = sanitize_user( wp_unslash( $_POST['log'] ?? '' ) ); // Passwords are used exactly as submitted (still slashed), matching wp-login.php, and never sanitised. $password = $_POST['pwd'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( empty( $username ) || empty( $password ) ) { wp_send_json_error( __( 'Please fill in all fields.', 'accessnow-lms' ) ); } $user = wp_signon( array( 'user_login' => $username, 'user_password' => $password, 'remember' => true, ) ); if ( is_wp_error( $user ) ) { wp_send_json_error( __( 'Invalid username or password.', 'accessnow-lms' ) ); } wp_send_json_success( array( 'message' => __( 'Logged in! Redirecting...', 'accessnow-lms' ), 'redirect' => self::safe_redirect_target(), ) ); } public static function handle_register() { check_ajax_referer( 'accessnow_slms_auth', 'nonce' ); if ( ! self::registration_enabled() ) { wp_send_json_error( __( 'Registration is not enabled.', 'accessnow-lms' ) ); } // Honeypot: if the hidden field is filled, it's a bot if ( ! empty( $_POST['website_url'] ) ) { // Silently reject — don't reveal why wp_send_json_error( __( 'Registration failed. Please try again.', 'accessnow-lms' ) ); } // Signed time check: required, and too fast (< 3 seconds) is a bot. check_ajax_referer() above // already sent the nonce; this proves the form was rendered by this site a moment ago. $ts_status = Antispam::check_timestamp_token( sanitize_text_field( wp_unslash( $_POST['slms_reg_ts'] ?? '' ) ) ); if ( 'ok' !== $ts_status ) { wp_send_json_error( Antispam::timestamp_message( $ts_status ) ); } // Per-IP limit on self-registrations (see Antispam). if ( Antispam::registration_limit_reached() ) { wp_send_json_error( Antispam::limit_message() ); } $username = sanitize_user( wp_unslash( $_POST['user_login'] ?? '' ) ); $email = sanitize_email( wp_unslash( $_POST['user_email'] ?? '' ) ); // Passwords are used exactly as submitted (still slashed), matching core, and never sanitised. $password = $_POST['user_pass'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( empty( $username ) || empty( $email ) || empty( $password ) ) { wp_send_json_error( __( 'Please fill in all fields.', 'accessnow-lms' ) ); } if ( ! is_email( $email ) ) { wp_send_json_error( __( 'Please fill in all fields.', 'accessnow-lms' ) ); } if ( strlen( $password ) < 8 ) { wp_send_json_error( __( 'Password must be at least 8 characters.', 'accessnow-lms' ) ); } if ( username_exists( $username ) ) { wp_send_json_error( __( 'That username is already taken.', 'accessnow-lms' ) ); } if ( email_exists( $email ) ) { wp_send_json_error( __( 'That email is already registered.', 'accessnow-lms' ) ); } $user_id = wp_create_user( $username, $password, $email ); if ( is_wp_error( $user_id ) ) { wp_send_json_error( $user_id->get_error_message() ); } Antispam::record_registration(); // Assign student role $user = new \WP_User( $user_id ); $user->set_role( Antispam::STUDENT_ROLE ); // Auto-login the new user wp_set_current_user( $user_id ); wp_set_auth_cookie( $user_id, true ); wp_send_json_success( array( 'message' => __( 'Account created! Redirecting...', 'accessnow-lms' ), 'redirect' => self::safe_redirect_target(), ) ); } /** * Registration needs both the WordPress "Anyone can register" setting and * the AccessNow LMS "Allow Student Registration" setting. */ public static function registration_enabled() { $slms_settings = Settings::all(); return (bool) get_option( 'users_can_register' ) && ! empty( $slms_settings['allow_registration'] ); } /** * Validate the post-login redirect so it can only point at this site. */ private static function safe_redirect_target() { $fallback = home_url( '/' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. $target = isset( $_POST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_to'] ) ) : ''; return $target ? wp_validate_redirect( $target, $fallback ) : $fallback; } /** * Why a student can't use a course: their access ended, or they aren't enrolled. * * @param int $user_id User ID. * @param int $course_id Course ID. * @return string */ private static function no_access_message( $user_id, $course_id ) { $enrollment = $course_id ? Enrollment::get_enrollment( $user_id, $course_id ) : null; if ( $enrollment && Enrollment::is_expired( $enrollment ) ) { /* translators: %s: date. */ return sprintf( __( 'Your access to this course ended on %s.', 'accessnow-lms' ), Enrollment::access_end_date( $enrollment ) ); } return __( 'Not enrolled.', 'accessnow-lms' ); } public static function handle_reorder() { check_ajax_referer( 'accessnow_slms_admin', 'nonce' ); // Each item is checked below, so an instructor can order their own units and lessons. if ( ! current_user_can( 'edit_slms_units' ) && ! current_user_can( 'edit_slms_lessons' ) ) { wp_send_json_error( __( 'Unauthorized.', 'accessnow-lms' ) ); } $order = isset( $_POST['order'] ) && is_array( $_POST['order'] ) ? array_map( 'absint', wp_unslash( $_POST['order'] ) ) : array(); foreach ( $order as $position => $post_id ) { if ( ! $post_id || ! in_array( get_post_type( $post_id ), array( 'slms_unit', 'slms_lesson' ), true ) || ! current_user_can( 'edit_post', $post_id ) ) { continue; } update_post_meta( $post_id, '_slms_sort_order', absint( $position ) ); } wp_send_json_success(); } }